import SwiftUI import VelodyDomain #if canImport(UIKit) import UIKit #endif struct iPhoneLibraryView: View { @State private var viewModel = iPhoneLibraryViewModel() @State private var scrubbedPlaybackTime: Double? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { titleSection syncSection searchSection if let nowPlayingCard = viewModel.nowPlayingCard { nowPlayingCardView(nowPlayingCard) } remoteLibrarySection availableOfflineSection } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 24) } .background(Color(uiColor: .systemGroupedBackground)) .task { await viewModel.loadIfNeeded() } } private var titleSection: some View { Text("Velody") .font(.largeTitle.weight(.bold)) .frame(maxWidth: .infinity, alignment: .leading) } private var syncSection: some View { VStack(alignment: .leading, spacing: 10) { Button("Sync Remote Library") { Task { await viewModel.refreshSync() } } .buttonStyle(.borderedProminent) .disabled(viewModel.state == .loading) Text(viewModel.syncStatus) .font(.footnote) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } private var searchSection: some View { VStack(alignment: .leading, spacing: 10) { Text("Search Library") .font(.headline) HStack(spacing: 10) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField( "Search Library", text: Binding( get: { viewModel.searchText }, set: { viewModel.searchText = $0 } ) ) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.search) if !viewModel.searchText.isEmpty { Button { viewModel.searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) .accessibilityLabel("Clear Search") } } .padding(.horizontal, 14) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) ) } } @ViewBuilder private func nowPlayingCardView(_ card: NowPlayingCardViewData) -> some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { Text("Now Playing") .font(.headline) Text(card.playbackStateText) .font(.subheadline) .foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState)) } Spacer(minLength: 12) statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) } ArtworkCoverView(localFilePath: card.artworkLocalFilePath) .frame(maxWidth: .infinity) .frame(height: 220) VStack(alignment: .leading, spacing: 6) { Text(card.title) .font(.title3.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(2) Text(card.artist) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) } VStack(spacing: 8) { Slider( value: Binding( get: { scrubbedPlaybackTime ?? card.currentTime }, set: { newValue in scrubbedPlaybackTime = newValue } ), in: 0...max(card.duration, 1), onEditingChanged: { isEditing in if !isEditing { let targetTime = scrubbedPlaybackTime ?? card.currentTime scrubbedPlaybackTime = nil viewModel.seekPlayback(to: targetTime) } } ) .disabled(!card.canSeek) HStack { Text(card.currentTimeText) Spacer() Text(card.durationText) } .font(.caption) .monospacedDigit() .foregroundStyle(.secondary) } if let errorMessage = card.errorMessage { Text(errorMessage) .font(.footnote) .foregroundStyle(.red) } HStack(spacing: 12) { if viewModel.nowPlayingFavoriteTrackID == card.trackID { favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { Task { await viewModel.toggleFavorite(trackID: card.trackID) } } .frame(width: 48, height: 48) .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } Button { viewModel.togglePlayback(trackID: card.trackID) } label: { HStack(spacing: 8) { Image(systemName: card.isPlaying ? "pause.fill" : "play.fill") Text(card.isPlaying ? "Pause" : "Play") .lineLimit(1) } .font(.headline) .frame(maxWidth: .infinity, minHeight: 48) } .buttonStyle(.borderedProminent) Button { viewModel.stopPlayback() } label: { HStack(spacing: 8) { Image(systemName: "stop.fill") Text("Stop") .lineLimit(1) } .font(.headline) .frame(maxWidth: .infinity, minHeight: 48) } .buttonStyle(.bordered) } } .padding(20) .background( RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) ) .onChange(of: card.trackID) { _, _ in scrubbedPlaybackTime = nil } } @ViewBuilder private var remoteLibrarySection: some View { sectionCard(title: "Remote Library: \(viewModel.remoteTracks.count)") { if viewModel.remoteTracks.isEmpty { if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks { Text("No matching tracks found.") .font(.subheadline) .foregroundStyle(.secondary) } else { remoteLibraryEmptyState } } else { VStack(alignment: .leading, spacing: 0) { ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in if index > 0 { Divider() .padding(.vertical, 12) } 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() favoriteButton(isFavorite: track.isFavorite) { Task { await viewModel.toggleFavorite(trackID: track.id) } } 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) } } } } } @ViewBuilder private var remoteLibraryEmptyState: some View { switch viewModel.state { case .loading: ProgressView("Syncing remote library...") .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 12) case .idle: if viewModel.hasCachedRemoteTracks { Text("Remote tracks will appear here after syncing.") .font(.subheadline) .foregroundStyle(.secondary) } else { ContentUnavailableView( "No Remote Library Yet", systemImage: "music.note.list", description: Text("Tap Sync Remote Library to fetch metadata from the backend.") ) } case .success: Text("Remote tracks will appear here after syncing.") .font(.subheadline) .foregroundStyle(.secondary) 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 { Text("Remote tracks will appear here after syncing.") .font(.subheadline) .foregroundStyle(.secondary) } else { ContentUnavailableView( "Network Error", systemImage: "wifi.exclamationmark", description: Text(message) ) } } } @ViewBuilder private var availableOfflineSection: some View { sectionCard(title: "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 { VStack(alignment: .leading, spacing: 0) { ForEach(Array(viewModel.availableOfflineTracks.enumerated()), id: \.element.id) { index, track in if index > 0 { Divider() .padding(.vertical, 12) } 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() favoriteButton(isFavorite: track.isFavorite) { Task { await viewModel.toggleFavorite(trackID: track.id) } } 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) } } } } } private func sectionCard( title: String, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 14) { Text(title) .font(.headline) content() } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) ) } 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 func favoriteButton( isFavorite: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: isFavorite ? "heart.fill" : "heart") .font(.title3.weight(.semibold)) .foregroundStyle(isFavorite ? .red : .secondary) } .buttonStyle(.borderless) .accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite") } private func badgeColor(for badge: NowPlayingDownloadBadge) -> Color { switch badge { case .downloaded: return .green case .missing: return .orange case .offline: return .blue } } private func playbackStateColor(for state: iPhonePlaybackState) -> Color { switch state { case .playing: return .green case .paused, .stopped: return .secondary case .missingFile: return .orange case .failed: return .red } } private func statusBadge(title: String, color: Color) -> some View { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(color) .padding(.horizontal, 10) .padding(.vertical, 5) .background(color.opacity(0.14), in: Capsule()) } } private struct ArtworkCoverView: View { let localFilePath: String? var body: some View { Group { if let artworkImage { Image(uiImage: artworkImage) .resizable() .scaledToFill() } else { VStack { Spacer(minLength: 0) Image(systemName: "music.note") .font(.system(size: 56, weight: .semibold)) .foregroundStyle(.secondary) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient( colors: [ Color.blue.opacity(0.18), Color.cyan.opacity(0.12), Color.gray.opacity(0.18), ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) } } .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) } private var artworkImage: UIImage? { guard let localFilePath, !localFilePath.isEmpty else { return nil } return UIImage(contentsOfFile: localFilePath) } } private struct ArtworkThumbnailView: View { let localFilePath: String? var body: some View { Group { if let artworkImage { Image(uiImage: artworkImage) .resizable() .scaledToFill() } else { VStack { Spacer(minLength: 0) Image(systemName: "music.note") .font(.headline) .foregroundStyle(.secondary) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.14)) } } .frame(width: 52, height: 52) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } private var artworkImage: UIImage? { guard let localFilePath, !localFilePath.isEmpty else { return nil } return UIImage(contentsOfFile: localFilePath) } }