Add iPhone local library search
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user