Add iPhone local library search
This commit is contained in:
parent
18ed79e3c4
commit
cb556ccf0a
@ -38,6 +38,13 @@ struct iPhoneLibraryView: View {
|
||||
}
|
||||
|
||||
Section("Remote Library: \(viewModel.remoteTracks.count)") {
|
||||
if viewModel.remoteTracks.isEmpty {
|
||||
if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks {
|
||||
Text("No matching tracks found.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
ForEach(viewModel.remoteTracks) { track in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
@ -63,6 +70,7 @@ struct iPhoneLibraryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Duration: \(track.durationText)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
@ -103,10 +111,15 @@ struct iPhoneLibraryView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Available Offline: \(viewModel.availableOfflineTracks.count)") {
|
||||
if viewModel.availableOfflineTracks.isEmpty {
|
||||
Text("Downloaded tracks with a verified local MP3 will appear here.")
|
||||
Text(
|
||||
viewModel.hasActiveSearch
|
||||
? "No matching tracks found."
|
||||
: "Downloaded tracks with a verified local MP3 will appear here."
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -17,5 +17,9 @@ let package = Package(
|
||||
.target(
|
||||
name: "VelodyDomain"
|
||||
),
|
||||
.testTarget(
|
||||
name: "VelodyDomainTests",
|
||||
dependencies: ["VelodyDomain"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user