Add iPhone local library search

This commit is contained in:
diyaa 2026-05-31 08:57:54 +02:00
parent 18ed79e3c4
commit cb556ccf0a
5 changed files with 330 additions and 67 deletions

View File

@ -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",

View File

@ -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

View File

@ -17,5 +17,9 @@ let package = Package(
.target(
name: "VelodyDomain"
),
.testTarget(
name: "VelodyDomainTests",
dependencies: ["VelodyDomain"]
),
]
)

View File

@ -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)
}
}

View File

@ -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
)
}
}