import SwiftUI import VelodyDomain #if canImport(UIKit) import UIKit #endif struct iPhoneLibraryView: View { @State private var viewModel = iPhoneLibraryViewModel() var body: some View { NavigationStack { List { if let currentTitle = viewModel.nowPlaying.title { Section("Now Playing") { HStack(alignment: .center) { VStack(alignment: .leading, spacing: 4) { Text(currentTitle) .font(.headline) if let artist = viewModel.nowPlaying.artist { Text(artist) .foregroundStyle(.secondary) } Text(viewModel.nowPlaying.isPlaying ? "Playing offline" : "Paused") .font(.caption) .foregroundStyle(.secondary) } Spacer() if let trackID = viewModel.nowPlaying.trackID { Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") { viewModel.togglePlayback(trackID: trackID) } .buttonStyle(.borderedProminent) } } } } 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) { 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( 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) { 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() Button(track.playButtonTitle) { viewModel.togglePlayback(trackID: track.id) } .buttonStyle(.borderedProminent) } } } 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) } .padding(.vertical, 4) } } } } .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) { Button("Sync Remote Library") { Task { await viewModel.refreshSync() } } .disabled(viewModel.state == .loading) } } .safeAreaInset(edge: .bottom) { VStack(alignment: .leading, spacing: 4) { if let playbackError = viewModel.nowPlaying.errorMessage { Text(playbackError) .font(.footnote) .foregroundStyle(.red) } Text(viewModel.syncStatus) .font(.footnote) .foregroundStyle(.secondary) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(.ultraThinMaterial) } } .task { await viewModel.loadIfNeeded() } } @ViewBuilder private var overlayView: some View { switch viewModel.state { case .idle: if !viewModel.hasCachedRemoteTracks { ContentUnavailableView( "No Remote Library Yet", systemImage: "music.note.list", description: Text("Tap Sync Remote Library to fetch metadata from the backend.") ) } case .loading: ProgressView("Syncing remote library...") case .success: EmptyView() case .empty: ContentUnavailableView( "Empty Remote Library", systemImage: "music.note.list", description: Text("The backend returned no remote tracks for this iPhone.") ) case .networkError(let message): if !viewModel.hasCachedRemoteTracks { ContentUnavailableView( "Network Error", systemImage: "wifi.exclamationmark", description: Text(message) ) } } } private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color { switch status { case .notDownloaded: return .gray case .downloading: return .blue case .downloaded: return .green case .missing: return .orange case .failed: return .red } } } private struct ArtworkThumbnailView: View { let localFilePath: String? var body: some View { Group { if let artworkImage { Image(uiImage: artworkImage) .resizable() .scaledToFill() } else { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.gray.opacity(0.14)) Image(systemName: "music.note") .font(.headline) .foregroundStyle(.secondary) } } } .frame(width: 52, height: 52) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(Color.secondary.opacity(0.12), lineWidth: 1) ) } private var artworkImage: UIImage? { guard let localFilePath, !localFilePath.isEmpty else { return nil } return UIImage(contentsOfFile: localFilePath) } }