From cb556ccf0a0684845b46dba018f01bb54f9c4f99 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 31 May 2026 08:57:54 +0200 Subject: [PATCH] Add iPhone local library search --- .../Sources/iPhoneLibraryView.swift | 152 +++++++++------- .../Sources/iPhoneLibraryViewModel.swift | 26 ++- packages/apple/VelodyDomain/Package.swift | 4 + .../VelodyDomain/OfflineLibrarySearch.swift | 45 +++++ .../OfflineLibrarySearchTests.swift | 170 ++++++++++++++++++ 5 files changed, 330 insertions(+), 67 deletions(-) create mode 100644 packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibrarySearch.swift create mode 100644 packages/apple/VelodyDomain/Tests/VelodyDomainTests/OfflineLibrarySearchTests.swift diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index 169c6c7..f310f87 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -38,77 +38,90 @@ struct iPhoneLibraryView: View { } Section("Remote Library: \(viewModel.remoteTracks.count)") { - ForEach(viewModel.remoteTracks) { track in - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 12) { - ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath) - - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) - } - - Spacer() - - Text(track.statusText) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(statusColor(for: track.status), in: Capsule()) - .foregroundStyle(.white) - } - } - } - Text("Duration: \(track.durationText)") + if viewModel.remoteTracks.isEmpty { + if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks { + Text("No matching tracks found.") .font(.subheadline) .foregroundStyle(.secondary) - Text("Remote track ID: \(track.remoteTrackID)") - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - Text("Asset ID: \(track.assetID)") - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - - HStack { - Button(track.downloadButtonTitle) { - Task { - await viewModel.downloadTrack(trackID: track.id) - } - } - .buttonStyle(.bordered) - .disabled(!track.canDownload) - - if track.canPlay { - Button(track.playButtonTitle) { - viewModel.togglePlayback(trackID: track.id) - } - .buttonStyle(.borderedProminent) - } - } - - if let error = track.lastDownloadError, - (track.status == .failed || track.status == .missing) - { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } } - .padding(.vertical, 4) + } else { + ForEach(viewModel.remoteTracks) { track in + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(.headline) + Text(track.artist) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(track.statusText) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(statusColor(for: track.status), in: Capsule()) + .foregroundStyle(.white) + } + } + } + + Text("Duration: \(track.durationText)") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Remote track ID: \(track.remoteTrackID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + Text("Asset ID: \(track.assetID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + + HStack { + Button(track.downloadButtonTitle) { + Task { + await viewModel.downloadTrack(trackID: track.id) + } + } + .buttonStyle(.bordered) + .disabled(!track.canDownload) + + if track.canPlay { + Button(track.playButtonTitle) { + viewModel.togglePlayback(trackID: track.id) + } + .buttonStyle(.borderedProminent) + } + } + + if let error = track.lastDownloadError, + (track.status == .failed || track.status == .missing) + { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.vertical, 4) + } } } Section("Available Offline: \(viewModel.availableOfflineTracks.count)") { if viewModel.availableOfflineTracks.isEmpty { - Text("Downloaded tracks with a verified local MP3 will appear here.") - .font(.subheadline) - .foregroundStyle(.secondary) + Text( + viewModel.hasActiveSearch + ? "No matching tracks found." + : "Downloaded tracks with a verified local MP3 will appear here." + ) + .font(.subheadline) + .foregroundStyle(.secondary) } else { ForEach(viewModel.availableOfflineTracks) { track in VStack(alignment: .leading, spacing: 6) { @@ -154,6 +167,15 @@ struct iPhoneLibraryView: View { .overlay { overlayView } + .searchable( + text: Binding( + get: { viewModel.searchText }, + set: { viewModel.searchText = $0 } + ), + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search Library" + ) + .autocorrectionDisabled() .navigationTitle("Velody") .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -191,7 +213,7 @@ struct iPhoneLibraryView: View { private var overlayView: some View { switch viewModel.state { case .idle: - if viewModel.remoteTracks.isEmpty { + if !viewModel.hasCachedRemoteTracks { ContentUnavailableView( "No Remote Library Yet", systemImage: "music.note.list", @@ -209,7 +231,7 @@ struct iPhoneLibraryView: View { description: Text("The backend returned no remote tracks for this iPhone.") ) case .networkError(let message): - if viewModel.remoteTracks.isEmpty { + if !viewModel.hasCachedRemoteTracks { ContentUnavailableView( "Network Error", systemImage: "wifi.exclamationmark", diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 097d85a..1b2418b 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -176,6 +176,15 @@ final class iPhoneLibraryViewModel { var remoteTracks: [RemoteTrackRowViewData] = [] var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = [] + var searchText = "" { + didSet { + guard searchText != oldValue else { + return + } + + rebuildRows() + } + } var syncStatus = "Remote library not synced yet." var state: ViewState = .idle var nowPlaying = iPhoneNowPlayingState( @@ -197,6 +206,14 @@ final class iPhoneLibraryViewModel { private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = [] private var hasLoaded = false + var hasActiveSearch: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var hasCachedRemoteTracks: Bool { + !cachedRemoteLibraryTracks.isEmpty + } + init( player: (any iPhoneLocalAudioPlaying)? = nil, keychainService: any KeychainService = SystemKeychainService( @@ -427,13 +444,18 @@ final class iPhoneLibraryViewModel { } private func rebuildRows() { - remoteTracks = cachedRemoteLibraryTracks.map { track in + let filteredSnapshot = OfflineLibrarySnapshot( + remoteTracks: cachedRemoteLibraryTracks, + availableTracks: cachedAvailableOfflineTracks + ).filtered(searchText: searchText) + + remoteTracks = filteredSnapshot.remoteTracks.map { track in RemoteTrackRowViewData( track: track, nowPlaying: nowPlaying ) } - availableOfflineTracks = cachedAvailableOfflineTracks.map { track in + availableOfflineTracks = filteredSnapshot.availableTracks.map { track in AvailableOfflineTrackRowViewData( track: track, nowPlaying: nowPlaying diff --git a/packages/apple/VelodyDomain/Package.swift b/packages/apple/VelodyDomain/Package.swift index 5cfdb9d..a395a20 100644 --- a/packages/apple/VelodyDomain/Package.swift +++ b/packages/apple/VelodyDomain/Package.swift @@ -17,5 +17,9 @@ let package = Package( .target( name: "VelodyDomain" ), + .testTarget( + name: "VelodyDomainTests", + dependencies: ["VelodyDomain"] + ), ] ) diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibrarySearch.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibrarySearch.swift new file mode 100644 index 0000000..832a21b --- /dev/null +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibrarySearch.swift @@ -0,0 +1,45 @@ +import Foundation + +public extension OfflineLibrarySnapshot { + func filtered(searchText: String) -> OfflineLibrarySnapshot { + guard let normalizedSearchText = OfflineLibrarySearch.normalizedQuery(from: searchText) else { + return self + } + + return OfflineLibrarySnapshot( + remoteTracks: remoteTracks.filter { $0.matchesSearchQuery(normalizedSearchText) }, + availableTracks: availableTracks.filter { $0.matchesSearchQuery(normalizedSearchText) } + ) + } +} + +private enum OfflineLibrarySearch { + static func normalizedQuery(from searchText: String) -> String? { + let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { + return nil + } + + return normalize(trimmedQuery) + } + + static func normalize(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + } +} + +private extension OfflineLibraryRemoteTrack { + func matchesSearchQuery(_ query: String) -> Bool { + OfflineLibrarySearch.normalize(remoteTrack.title).contains(query) + || OfflineLibrarySearch.normalize(remoteTrack.artist).contains(query) + } +} + +private extension OfflineLibraryTrack { + func matchesSearchQuery(_ query: String) -> Bool { + OfflineLibrarySearch.normalize(title).contains(query) + || OfflineLibrarySearch.normalize(artist).contains(query) + } +} diff --git a/packages/apple/VelodyDomain/Tests/VelodyDomainTests/OfflineLibrarySearchTests.swift b/packages/apple/VelodyDomain/Tests/VelodyDomainTests/OfflineLibrarySearchTests.swift new file mode 100644 index 0000000..2ed41a1 --- /dev/null +++ b/packages/apple/VelodyDomain/Tests/VelodyDomainTests/OfflineLibrarySearchTests.swift @@ -0,0 +1,170 @@ +import Foundation +import XCTest +@testable import VelodyDomain + +final class OfflineLibrarySearchTests: XCTestCase { + func testSearchByTitleFiltersMatchingTracks() { + let snapshot = makeSnapshot() + + let filtered = snapshot.filtered(searchText: "trap") + + XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-light-trap"]) + XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-light-trap"]) + } + + func testSearchByArtistFiltersMatchingTracks() { + let snapshot = makeSnapshot() + + let filtered = snapshot.filtered(searchText: "unknown") + + XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-light-trap"]) + XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-light-trap"]) + } + + func testSearchIsCaseInsensitiveAndTrimsWhitespace() { + let snapshot = makeSnapshot() + + let filtered = snapshot.filtered(searchText: " TRAP ") + + XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-light-trap"]) + XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-light-trap"]) + } + + func testEmptySearchResultReturnsEmptySections() { + let snapshot = makeSnapshot() + + let filtered = snapshot.filtered(searchText: "nope") + + XCTAssertTrue(filtered.remoteTracks.isEmpty) + XCTAssertTrue(filtered.availableTracks.isEmpty) + } + + func testClearingSearchRestoresFullList() { + let snapshot = makeSnapshot() + + let filtered = snapshot.filtered(searchText: "") + + XCTAssertEqual(filtered.remoteTracks, snapshot.remoteTracks) + XCTAssertEqual(filtered.availableTracks, snapshot.availableTracks) + } + + func testOfflineSectionFilteringUsesAvailableOfflineTracks() { + let snapshot = makeSnapshot() + + let filtered = snapshot.filtered(searchText: "harbor") + + XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-harbor-lights"]) + XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-harbor-lights"]) + } + + func testRemoteSectionFilteringUsesRemoteLibraryTracks() { + let snapshot = makeSnapshot() + + let filtered = snapshot.filtered(searchText: "shadow") + + XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-shadow-city"]) + XCTAssertTrue(filtered.availableTracks.isEmpty) + } + + func testFilteringDoesNotMutateUnderlyingSnapshotData() { + let snapshot = makeSnapshot() + let originalRemoteTracks = snapshot.remoteTracks + let originalAvailableTracks = snapshot.availableTracks + + _ = snapshot.filtered(searchText: "light") + + XCTAssertEqual(snapshot.remoteTracks, originalRemoteTracks) + XCTAssertEqual(snapshot.availableTracks, originalAvailableTracks) + } + + private func makeSnapshot() -> OfflineLibrarySnapshot { + let remoteTracks = [ + makeRemoteLibraryTrack( + trackId: "remote-light-trap", + assetId: "asset-light-trap", + title: "Light Trap", + artist: "Unknown Artist", + isFileAvailable: true + ), + makeRemoteLibraryTrack( + trackId: "remote-shadow-city", + assetId: "asset-shadow-city", + title: "Shadow City", + artist: "Night Runner", + isFileAvailable: false + ), + makeRemoteLibraryTrack( + trackId: "remote-harbor-lights", + assetId: "asset-harbor-lights", + title: "Harbor Lights", + artist: "The Northline", + isFileAvailable: true + ), + ] + + let availableTracks = [ + makeOfflineTrack( + trackId: "remote-light-trap", + assetId: "asset-light-trap", + title: "Light Trap", + artist: "Unknown Artist" + ), + makeOfflineTrack( + trackId: "remote-harbor-lights", + assetId: "asset-harbor-lights", + title: "Harbor Lights", + artist: "The Northline" + ), + ] + + return OfflineLibrarySnapshot( + remoteTracks: remoteTracks, + availableTracks: availableTracks + ) + } + + private func makeRemoteLibraryTrack( + trackId: String, + assetId: String, + title: String, + artist: String, + isFileAvailable: Bool + ) -> OfflineLibraryRemoteTrack { + OfflineLibraryRemoteTrack( + remoteTrack: RemoteTrack( + trackId: trackId, + title: title, + artist: artist, + durationSeconds: 180, + sha256: String(repeating: "a", count: 64), + assetId: assetId, + createdAt: "2026-05-30T10:00:00.000Z", + updatedAt: "2026-05-30T10:00:00.000Z" + ), + localFilePath: isFileAvailable ? "/tmp/\(assetId).mp3" : "", + downloadedAt: isFileAvailable ? Date(timeIntervalSince1970: 1_000) : nil, + isFileAvailable: isFileAvailable, + status: isFileAvailable ? .downloaded : .notDownloaded, + lastDownloadError: nil + ) + } + + private func makeOfflineTrack( + trackId: String, + assetId: String, + title: String, + artist: String + ) -> OfflineLibraryTrack { + OfflineLibraryTrack( + remoteTrackId: trackId, + assetId: assetId, + title: title, + artist: artist, + durationSeconds: 180, + sha256: String(repeating: "b", count: 64), + localFilePath: "/tmp/\(assetId).mp3", + downloadedAt: Date(timeIntervalSince1970: 1_000), + isFileAvailable: true + ) + } +}