import SwiftUI import VelodyDomain #if canImport(UIKit) import UIKit #endif enum NowPlayingCardLayoutMetrics { static let favoriteButtonSize: CGFloat = 36 static let favoriteButtonSymbolSize: CGFloat = 14 struct ControlsLayout: Equatable { enum Style: Equatable { case compact case regular } let style: Style let buttonSpacing: CGFloat let secondaryButtonSize: CGFloat let primaryButtonSize: CGFloat let secondarySymbolSize: CGFloat let primarySymbolSize: CGFloat var firstRowWidth: CGFloat { (secondaryButtonSize * 4) + primaryButtonSize + (buttonSpacing * 4) } } static func artworkHeight(for contentWidth: CGFloat) -> CGFloat { min(max(contentWidth * 0.42, 148), 176) } static func controlsLayout(for availableWidth: CGFloat) -> ControlsLayout { let regular = ControlsLayout( style: .regular, buttonSpacing: 10, secondaryButtonSize: 36, primaryButtonSize: 42, secondarySymbolSize: 14, primarySymbolSize: 16 ) if availableWidth - regular.firstRowWidth >= 40 { return regular } return ControlsLayout( style: .compact, buttonSpacing: 6, secondaryButtonSize: 34, primaryButtonSize: 40, secondarySymbolSize: 14, primarySymbolSize: 16 ) } } 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 controlButtonHeight: CGFloat = 36 static let controlButtonMinWidth: CGFloat = 72 static let controlButtonHorizontalPadding: CGFloat = 14 static let controlButtonSymbolSize: CGFloat = 13 static let controlButtonSpacing: CGFloat = 6 static let badgeMinHeight: CGFloat = 28 static let badgeHorizontalPadding: CGFloat = 12 static let badgeVerticalPadding: CGFloat = 6 } private enum ActionButtonRole { case primary case secondary } private struct ActionButtonColors { let foreground: Color let background: Color let border: Color } 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 } var nowPlayingArtworkHeight: CGFloat { NowPlayingCardLayoutMetrics.artworkHeight(for: contentWidth) } var nowPlayingCardContentWidth: CGFloat { max(contentWidth - (cardPadding * 2), 0) } var nowPlayingControlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout { NowPlayingCardLayoutMetrics.controlsLayout(for: nowPlayingCardContentWidth) } } @State private var viewModel: iPhoneLibraryViewModel @State private var scrubbedPlaybackTime: Double? @MainActor init() { _viewModel = State(initialValue: iPhoneLibraryViewModel()) } @MainActor init(viewModel: iPhoneLibraryViewModel) { _viewModel = State(initialValue: viewModel) } 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: 14) { ViewThatFits(in: .vertical) { HStack(alignment: .top, spacing: 12) { nowPlayingHeaderText Spacer(minLength: 12) statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) } VStack(alignment: .leading, spacing: 10) { nowPlayingHeaderText statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) } } nowPlayingMetadataText(card) nowPlayingArtworkView(localFilePath: card.artworkLocalFilePath, layout: layout) 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, layout: layout) } .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, actionSystemImage: remoteTrackActionSystemImage(for: track), actionRole: remoteTrackActionRole(for: track), 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, actionSystemImage: playbackActionSystemImage(isPlaying: track.playButtonTitle == "Pause"), actionRole: .primary, 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, actionSystemImage: String, actionRole: ActionButtonRole, 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, systemImage: actionSystemImage, role: actionRole, 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, systemImage: actionSystemImage, role: actionRole, 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, systemImage: String, role: ActionButtonRole, isEnabled: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { actionButtonLabel( title: title, systemImage: systemImage, role: role, isEnabled: isEnabled ) } .buttonStyle(.plain) .disabled(!isEnabled) } private func actionButtonLabel( title: String, systemImage: String, role: ActionButtonRole, isEnabled: Bool ) -> some View { let colors = actionButtonColors(for: role, isEnabled: isEnabled) return HStack(spacing: LayoutMetrics.controlButtonSpacing) { Image(systemName: systemImage) .font(.system(size: LayoutMetrics.controlButtonSymbolSize, weight: .semibold)) Text(title) .font(.subheadline.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.82) .allowsTightening(true) } .foregroundStyle(colors.foreground) .padding(.horizontal, LayoutMetrics.controlButtonHorizontalPadding) .frame( minWidth: LayoutMetrics.controlButtonMinWidth, minHeight: LayoutMetrics.controlButtonHeight ) .background( Capsule() .fill(colors.background) ) .overlay( Capsule() .strokeBorder(colors.border, lineWidth: 1) ) } 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 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, LayoutMetrics.badgeHorizontalPadding) .padding(.vertical, LayoutMetrics.badgeVerticalPadding) .frame(minHeight: LayoutMetrics.badgeMinHeight) .fixedSize(horizontal: true, vertical: false) .background(color.opacity(0.14), in: Capsule()) .overlay( Capsule() .strokeBorder(color.opacity(0.18), lineWidth: 1) ) } private var nowPlayingHeaderText: some View { Text("Now Playing") .font(.headline) } private func nowPlayingMetadataText(_ card: NowPlayingCardViewData) -> some View { VStack(alignment: .leading, spacing: 8) { Text(card.title) .font(.title2.weight(.bold)) .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) Text(card.artist) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) Text(card.playbackStateText) .font(.footnote.weight(.medium)) .foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState)) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) .layoutPriority(1) } private func nowPlayingControls( _ card: NowPlayingCardViewData, layout: RootLayout ) -> some View { let controlsLayout = layout.nowPlayingControlsLayout return HStack(alignment: .center, spacing: controlsLayout.buttonSpacing) { nowPlayingShuffleButton(controlsLayout: controlsLayout) nowPlayingPreviousButton(controlsLayout: controlsLayout) nowPlayingToggleButton(card, controlsLayout: controlsLayout) nowPlayingNextButton(controlsLayout: controlsLayout) nowPlayingRepeatButton(controlsLayout: controlsLayout) } .frame(maxWidth: .infinity) } private func nowPlayingToggleButton( _ card: NowPlayingCardViewData, controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout ) -> some View { compactNowPlayingButton( systemImage: playbackActionSystemImage(isPlaying: card.isPlaying), accessibilityLabel: card.isPlaying ? "Pause" : "Play", role: .primary, isEnabled: true, isActive: true, controlSize: controlsLayout.primaryButtonSize, symbolSize: controlsLayout.primarySymbolSize ) { viewModel.togglePlayback(trackID: card.trackID) } } private func nowPlayingShuffleButton( controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout ) -> some View { compactNowPlayingButton( systemImage: "shuffle", accessibilityLabel: viewModel.isShuffleEnabled ? "Disable Shuffle" : "Enable Shuffle", role: .secondary, isEnabled: !viewModel.nowPlaying.queueTrackIDs.isEmpty, isActive: viewModel.isShuffleEnabled, controlSize: controlsLayout.secondaryButtonSize, symbolSize: controlsLayout.secondarySymbolSize ) { viewModel.toggleShuffle() } } private func nowPlayingPreviousButton( controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout ) -> some View { compactNowPlayingButton( systemImage: "backward.fill", accessibilityLabel: "Previous Track", role: .secondary, isEnabled: viewModel.canGoPrevious, isActive: false, controlSize: controlsLayout.secondaryButtonSize, symbolSize: controlsLayout.secondarySymbolSize ) { viewModel.previousTrack() } } private func nowPlayingNextButton( controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout ) -> some View { compactNowPlayingButton( systemImage: "forward.fill", accessibilityLabel: "Next Track", role: .secondary, isEnabled: viewModel.canGoNext, isActive: false, controlSize: controlsLayout.secondaryButtonSize, symbolSize: controlsLayout.secondarySymbolSize ) { viewModel.nextTrack() } } private func nowPlayingRepeatButton( controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout ) -> some View { compactNowPlayingButton( systemImage: repeatButtonSymbol, accessibilityLabel: repeatAccessibilityLabel, role: .secondary, isEnabled: !viewModel.nowPlaying.queueTrackIDs.isEmpty, isActive: viewModel.repeatMode != .off, controlSize: controlsLayout.secondaryButtonSize, symbolSize: controlsLayout.secondarySymbolSize ) { viewModel.cycleRepeatMode() } } 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 { favoriteControlButton(isFavorite: isFavorite, action: action) } private func nowPlayingArtworkView( localFilePath: String?, layout: RootLayout ) -> some View { NowPlayingArtworkView( localFilePath: localFilePath, height: layout.nowPlayingArtworkHeight ) } private func favoriteControlButton( isFavorite: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: isFavorite ? "heart.fill" : "heart") .font(.system(size: NowPlayingCardLayoutMetrics.favoriteButtonSymbolSize, weight: .semibold)) .foregroundStyle(isFavorite ? .red : .gray) .frame( width: NowPlayingCardLayoutMetrics.favoriteButtonSize, height: NowPlayingCardLayoutMetrics.favoriteButtonSize ) .background( Capsule() .fill(Color(uiColor: .tertiarySystemFill)) ) .overlay( Capsule() .strokeBorder(Color(uiColor: .separator).opacity(0.12), lineWidth: 1) ) } .buttonStyle(.plain) .accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite") } private func compactNowPlayingButton( systemImage: String, accessibilityLabel: String, role: ActionButtonRole, isEnabled: Bool, isActive: Bool, controlSize: CGFloat, symbolSize: CGFloat, action: @escaping () -> Void ) -> some View { let colors = compactNowPlayingButtonColors( for: role, isEnabled: isEnabled, isActive: isActive ) return Button(action: action) { Image(systemName: systemImage) .font(.system(size: symbolSize, weight: .semibold)) .foregroundStyle(colors.foreground) .frame(width: controlSize, height: controlSize) .background( Circle() .fill(colors.background) ) .overlay( Circle() .strokeBorder(colors.border, lineWidth: 1) ) } .buttonStyle(.plain) .disabled(!isEnabled) .accessibilityLabel(accessibilityLabel) } private func actionButtonColors(for role: ActionButtonRole, isEnabled: Bool) -> ActionButtonColors { switch role { case .primary: return ActionButtonColors( foreground: .white.opacity(isEnabled ? 1 : 0.92), background: Color.blue.opacity(isEnabled ? 1 : 0.42), border: Color.blue.opacity(isEnabled ? 0.22 : 0.12) ) case .secondary: return ActionButtonColors( foreground: isEnabled ? .primary : .secondary, background: Color(uiColor: .systemGray5).opacity(isEnabled ? 1 : 0.78), border: Color(uiColor: .separator).opacity(isEnabled ? 0.14 : 0.08) ) } } private func compactNowPlayingButtonColors( for role: ActionButtonRole, isEnabled: Bool, isActive: Bool ) -> ActionButtonColors { switch role { case .primary: return actionButtonColors(for: .primary, isEnabled: isEnabled) case .secondary: if isActive { return ActionButtonColors( foreground: Color.blue.opacity(isEnabled ? 1 : 0.42), background: Color.blue.opacity(isEnabled ? 0.12 : 0.06), border: Color.blue.opacity(isEnabled ? 0.18 : 0.08) ) } return ActionButtonColors( foreground: isEnabled ? .primary : .secondary, background: Color(uiColor: .tertiarySystemFill).opacity(isEnabled ? 1 : 0.74), border: Color(uiColor: .separator).opacity(isEnabled ? 0.14 : 0.08) ) } } private var repeatButtonSymbol: String { switch viewModel.repeatMode { case .off, .all: return "repeat" case .one: return "repeat.1" } } private var repeatAccessibilityLabel: String { switch viewModel.repeatMode { case .off: return "Repeat Off" case .one: return "Repeat One" case .all: return "Repeat All" } } private func playbackActionSystemImage(isPlaying: Bool) -> String { isPlaying ? "pause.fill" : "play.fill" } private func remoteTrackActionSystemImage(for track: RemoteTrackRowViewData) -> String { if track.canPlay { return playbackActionSystemImage(isPlaying: track.playButtonTitle == "Pause") } switch track.status { case .notDownloaded, .downloading: return "arrow.down" case .downloaded: return "play.fill" case .missing, .failed: return "arrow.clockwise" } } private func remoteTrackActionRole(for track: RemoteTrackRowViewData) -> ActionButtonRole { if track.canPlay || track.status == .missing || track.status == .failed { return .primary } return .secondary } } private struct NowPlayingArtworkView: View { let localFilePath: String? let height: CGFloat private let cornerRadius: CGFloat = 24 private let placeholderIconSize: CGFloat = 54 var body: some View { ZStack { if let artworkImage { Image(uiImage: artworkImage) .resizable() .scaledToFill() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { artworkPlaceholder(cornerRadius: cornerRadius, iconSize: placeholderIconSize) } } .frame(maxWidth: .infinity) .frame(height: height) .clipped() .clipShape(RoundedRectangle(cornerRadius: cornerRadius, 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 ) ) ) }