import SwiftUI import VelodyDomain #if canImport(UIKit) import UIKit #endif struct iPhoneLibraryView: View { private enum LayoutMetrics { static let horizontalPadding: CGFloat = 16 static let contentSpacing: CGFloat = 20 static let topPadding: CGFloat = 16 static let bottomPadding: CGFloat = 24 static let compactContentThreshold: CGFloat = 350 static let rowArtworkSize: CGFloat = 60 static let rowFavoriteButtonSize: CGFloat = 36 static let nowPlayingFavoriteButtonSize: CGFloat = 48 static let rowActionMinHeight: CGFloat = 38 static let nowPlayingActionMinHeight: CGFloat = 48 static let badgeMinHeight: CGFloat = 28 } private struct RootLayout { let rootWidth: CGFloat var horizontalPadding: CGFloat { LayoutMetrics.horizontalPadding } var contentWidth: CGFloat { max(rootWidth - (horizontalPadding * 2), 0) } var isCompact: Bool { contentWidth < LayoutMetrics.compactContentThreshold } var sectionPadding: CGFloat { isCompact ? 16 : 18 } var cardPadding: CGFloat { isCompact ? 16 : 20 } var rowSpacing: CGFloat { isCompact ? 12 : 14 } var trackRowPadding: CGFloat { isCompact ? 12 : 14 } } @State private var viewModel = iPhoneLibraryViewModel() @State private var scrubbedPlaybackTime: Double? var body: some View { GeometryReader { proxy in let layout = RootLayout(rootWidth: proxy.size.width) ZStack { Color(uiColor: .systemGroupedBackground) .ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: LayoutMetrics.contentSpacing) { titleSection syncSection searchSection if let nowPlayingCard = viewModel.nowPlayingCard { nowPlayingCardView(nowPlayingCard, layout: layout) } remoteLibrarySection(layout: layout) availableOfflineSection(layout: layout) } .frame(width: layout.contentWidth, alignment: .leading) .padding(.horizontal, layout.horizontalPadding) .padding(.top, LayoutMetrics.topPadding) .padding(.bottom, LayoutMetrics.bottomPadding) } .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) } .frame(maxWidth: .infinity, maxHeight: .infinity) } .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(viewModel.syncButtonTitle) { Task { await viewModel.refreshSync() } } .buttonStyle(.borderedProminent) .disabled(viewModel.isSyncing) if viewModel.isSyncing { HStack(spacing: 10) { ProgressView() Text(viewModel.syncStatus) .font(.footnote) .foregroundStyle(.secondary) } } else if let errorMessage = viewModel.inlineSyncErrorMessage { messageCard(errorMessage) } else { 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, layout: RootLayout) -> some View { VStack(alignment: .leading, spacing: 16) { ViewThatFits(in: .vertical) { HStack(alignment: .top, spacing: 12) { nowPlayingHeaderText(card) Spacer(minLength: 12) statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) } VStack(alignment: .leading, spacing: 10) { nowPlayingHeaderText(card) 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) } nowPlayingControls(card) } .frame(maxWidth: .infinity, alignment: .leading) .padding(layout.cardPadding) .background( RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) ) .onChange(of: card.trackID) { _, _ in scrubbedPlaybackTime = nil } } @ViewBuilder private func remoteLibrarySection(layout: RootLayout) -> some View { sectionCard(title: viewModel.remoteSectionTitle, layout: layout) { if viewModel.isSyncing && viewModel.remoteTracks.isEmpty && !viewModel.hasCachedRemoteTracks && !viewModel.hasActiveSearch { loadingStateView("Syncing your remote library...") } else if let message = viewModel.remoteEmptyStateMessage { messageCard(message) } else { VStack(alignment: .leading, spacing: 12) { ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in if index > 0 { Divider() .padding(.vertical, 2) } remoteTrackRow(track, layout: layout) } } } } } @ViewBuilder private func availableOfflineSection(layout: RootLayout) -> some View { sectionCard(title: viewModel.availableOfflineSectionTitle, layout: layout) { if let message = viewModel.availableOfflineEmptyStateMessage { messageCard(message) } else { VStack(alignment: .leading, spacing: 12) { ForEach(Array(viewModel.availableOfflineTracks.enumerated()), id: \.element.id) { index, track in if index > 0 { Divider() .padding(.vertical, 2) } availableOfflineTrackRow(track, layout: layout) } } } } } private func sectionCard( title: String, layout: RootLayout, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 14) { Text(title) .font(.title3.weight(.semibold)) .fixedSize(horizontal: false, vertical: true) content() } .frame(maxWidth: .infinity, alignment: .leading) .padding(layout.sectionPadding) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) ) } private func remoteTrackRow(_ track: RemoteTrackRowViewData, layout: RootLayout) -> some View { libraryTrackRow( artworkLocalFilePath: track.artworkLocalFilePath, title: track.title, artist: track.artist, durationText: track.durationText, isFavorite: track.isFavorite, badgeTitle: track.statusBadgeTitle, badgeColor: statusColor(for: track.status), detailText: track.statusDetailText, actionTitle: track.canPlay ? track.playButtonTitle : track.downloadButtonTitle, actionProminence: track.canPlay, actionEnabled: track.canPlay || track.canDownload, favoriteAction: { Task { await viewModel.toggleFavorite(trackID: track.id) } }, action: { if track.canPlay { viewModel.togglePlayback(trackID: track.id) } else { Task { await viewModel.downloadTrack(trackID: track.id) } } }, layout: layout ) } private func availableOfflineTrackRow(_ track: AvailableOfflineTrackRowViewData, layout: RootLayout) -> some View { libraryTrackRow( artworkLocalFilePath: track.artworkLocalFilePath, title: track.title, artist: track.artist, durationText: track.durationText, isFavorite: track.isFavorite, badgeTitle: track.statusBadgeTitle, badgeColor: .green, detailText: nil, actionTitle: track.playButtonTitle, actionProminence: true, actionEnabled: true, favoriteAction: { Task { await viewModel.toggleFavorite(trackID: track.id) } }, action: { viewModel.togglePlayback(trackID: track.id) }, layout: layout ) } private func libraryTrackRow( artworkLocalFilePath: String?, title: String, artist: String, durationText: String, isFavorite: Bool, badgeTitle: String, badgeColor: Color, detailText: String?, actionTitle: String, actionProminence: Bool, actionEnabled: Bool, favoriteAction: @escaping () -> Void, action: @escaping () -> Void, layout: RootLayout ) -> some View { VStack(alignment: .leading, spacing: 12) { if layout.isCompact { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: layout.rowSpacing) { ArtworkThumbnailView(localFilePath: artworkLocalFilePath) libraryTrackText( title: title, artist: artist, durationText: durationText ) trackFavoriteButton(action: favoriteAction, isFavorite: isFavorite) } HStack(alignment: .center, spacing: 12) { statusBadge(title: badgeTitle, color: badgeColor) Spacer(minLength: 12) rowActionButton( title: actionTitle, isProminent: actionProminence, isEnabled: actionEnabled, action: action ) } } } else { HStack(alignment: .top, spacing: layout.rowSpacing) { ArtworkThumbnailView(localFilePath: artworkLocalFilePath) libraryTrackText( title: title, artist: artist, durationText: durationText ) VStack(alignment: .trailing, spacing: 10) { trackFavoriteButton(action: favoriteAction, isFavorite: isFavorite) statusBadge(title: badgeTitle, color: badgeColor) rowActionButton( title: actionTitle, isProminent: actionProminence, isEnabled: actionEnabled, action: action ) } .fixedSize(horizontal: true, vertical: false) } .frame(maxWidth: .infinity, alignment: .leading) } if let detailText { Text(detailText) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(layout.trackRowPadding) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Color(uiColor: .tertiarySystemGroupedBackground)) ) } @ViewBuilder private func rowActionButton( title: String, isProminent: Bool, isEnabled: Bool, action: @escaping () -> Void ) -> some View { if isProminent { Button(action: action) { rowActionButtonLabel(title: title) } .buttonStyle(.borderedProminent) .disabled(!isEnabled) } else { Button(action: action) { rowActionButtonLabel(title: title) } .buttonStyle(.bordered) .disabled(!isEnabled) } } private func rowActionButtonLabel(title: String) -> some View { Text(title) .font(.subheadline.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.75) .allowsTightening(true) .frame( minHeight: LayoutMetrics.rowActionMinHeight ) } private func nowPlayingActionButtonLabel(title: String, systemImage: String) -> some View { HStack(spacing: 8) { Image(systemName: systemImage) Text(title) .lineLimit(1) .minimumScaleFactor(0.9) .allowsTightening(true) } .font(.headline) .frame( maxWidth: .infinity, minHeight: LayoutMetrics.nowPlayingActionMinHeight ) } private func loadingStateView(_ title: String) -> some View { HStack(spacing: 12) { ProgressView() Text(title) .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: .tertiarySystemGroupedBackground)) ) } private func messageCard(_ message: LibrarySectionMessage) -> some View { HStack(alignment: .top, spacing: 12) { Image(systemName: message.systemImage) .font(.title3.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 28) VStack(alignment: .leading, spacing: 4) { Text(message.title) .font(.headline) .foregroundStyle(.primary) Text(message.body) .font(.subheadline) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: .tertiarySystemGroupedBackground)) ) } private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color { switch status { case .notDownloaded: return .blue case .downloading: return .cyan 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) .lineLimit(1) .minimumScaleFactor(0.75) .allowsTightening(true) .padding(.horizontal, 10) .frame(minHeight: LayoutMetrics.badgeMinHeight) .multilineTextAlignment(.center) .background(color.opacity(0.14), in: Capsule()) } private func nowPlayingHeaderText(_ card: NowPlayingCardViewData) -> some View { VStack(alignment: .leading, spacing: 6) { Text("Now Playing") .font(.headline) Text(card.playbackStateText) .font(.subheadline) .foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState)) } } private func nowPlayingControls(_ card: NowPlayingCardViewData) -> some View { ViewThatFits(in: .vertical) { HStack(spacing: 12) { if viewModel.nowPlayingFavoriteTrackID == card.trackID { nowPlayingFavoriteButton(trackID: card.trackID) } nowPlayingToggleButton(card) nowPlayingStopButton } VStack(alignment: .leading, spacing: 12) { if viewModel.nowPlayingFavoriteTrackID == card.trackID { nowPlayingFavoriteButton(trackID: card.trackID) } HStack(spacing: 12) { nowPlayingToggleButton(card) nowPlayingStopButton } } } .frame(maxWidth: .infinity, alignment: .leading) } private func nowPlayingFavoriteButton(trackID: String) -> some View { favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { Task { await viewModel.toggleFavorite(trackID: trackID) } } .frame( width: LayoutMetrics.nowPlayingFavoriteButtonSize, height: LayoutMetrics.nowPlayingFavoriteButtonSize ) .background( Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous) ) } private func nowPlayingToggleButton(_ card: NowPlayingCardViewData) -> some View { Button { viewModel.togglePlayback(trackID: card.trackID) } label: { nowPlayingActionButtonLabel( title: card.isPlaying ? "Pause" : "Play", systemImage: card.isPlaying ? "pause.fill" : "play.fill" ) } .buttonStyle(.borderedProminent) } private var nowPlayingStopButton: some View { Button { viewModel.stopPlayback() } label: { nowPlayingActionButtonLabel( title: "Stop", systemImage: "stop.fill" ) } .buttonStyle(.bordered) } private func libraryTrackText( title: String, artist: String, durationText: String ) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.headline) .foregroundStyle(.primary) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) Text(artist) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.95) Label(durationText, systemImage: "clock") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) .layoutPriority(1) } private func trackFavoriteButton( action: @escaping () -> Void, isFavorite: Bool ) -> some View { favoriteButton(isFavorite: isFavorite, action: action) .frame( width: LayoutMetrics.rowFavoriteButtonSize, height: LayoutMetrics.rowFavoriteButtonSize ) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.secondary.opacity(0.08)) ) } } private struct ArtworkCoverView: View { let localFilePath: String? var body: some View { Group { if let artworkImage { Image(uiImage: artworkImage) .resizable() .scaledToFill() } else { artworkPlaceholder(cornerRadius: 24, iconSize: 54) } } .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 { artworkPlaceholder(cornerRadius: 12, iconSize: 18) } } .frame(width: 60, height: 60) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } private var artworkImage: UIImage? { guard let localFilePath, !localFilePath.isEmpty else { return nil } return UIImage(contentsOfFile: localFilePath) } } private func artworkPlaceholder(cornerRadius: CGFloat, iconSize: CGFloat) -> some View { VStack(spacing: 8) { Spacer(minLength: 0) Image(systemName: "music.note") .font(.system(size: iconSize, weight: .semibold)) .foregroundStyle(Color.primary.opacity(0.35)) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill( LinearGradient( colors: [ Color.blue.opacity(0.18), Color.cyan.opacity(0.14), Color.mint.opacity(0.08), ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) ) }