diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index 9e668cc..19be807 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -5,29 +5,85 @@ 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 { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - titleSection - syncSection - searchSection + GeometryReader { proxy in + let layout = RootLayout(rootWidth: proxy.size.width) - if let nowPlayingCard = viewModel.nowPlayingCard { - nowPlayingCardView(nowPlayingCard) + 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) } - - remoteLibrarySection - availableOfflineSection + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .background(Color(uiColor: .systemGroupedBackground)) .task { await viewModel.loadIfNeeded() } @@ -41,17 +97,28 @@ struct iPhoneLibraryView: View { private var syncSection: some View { VStack(alignment: .leading, spacing: 10) { - Button("Sync Remote Library") { + Button(viewModel.syncButtonTitle) { Task { await viewModel.refreshSync() } } .buttonStyle(.borderedProminent) - .disabled(viewModel.state == .loading) + .disabled(viewModel.isSyncing) - Text(viewModel.syncStatus) - .font(.footnote) - .foregroundStyle(.secondary) + 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) } @@ -97,20 +164,21 @@ struct iPhoneLibraryView: View { } @ViewBuilder - private func nowPlayingCardView(_ card: NowPlayingCardViewData) -> some View { + private func nowPlayingCardView(_ card: NowPlayingCardViewData, layout: RootLayout) -> 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)) + ViewThatFits(in: .vertical) { + HStack(alignment: .top, spacing: 12) { + nowPlayingHeaderText(card) + + Spacer(minLength: 12) + + statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) } - 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) @@ -165,45 +233,10 @@ struct iPhoneLibraryView: View { .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) - } + nowPlayingControls(card) } - .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(layout.cardPadding) .background( RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) @@ -214,93 +247,21 @@ struct iPhoneLibraryView: View { } @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 - } + 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: 0) { + VStack(alignment: .leading, spacing: 12) { ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in if index > 0 { Divider() - .padding(.vertical, 12) + .padding(.vertical, 2) } - 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) + remoteTrackRow(track, layout: layout) } } } @@ -308,110 +269,19 @@ struct iPhoneLibraryView: View { } @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) + private func availableOfflineSection(layout: RootLayout) -> some View { + sectionCard(title: viewModel.availableOfflineSectionTitle, layout: layout) { + if let message = viewModel.availableOfflineEmptyStateMessage { + messageCard(message) } 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) { + VStack(alignment: .leading, spacing: 12) { ForEach(Array(viewModel.availableOfflineTracks.enumerated()), id: \.element.id) { index, track in if index > 0 { Divider() - .padding(.vertical, 12) + .padding(.vertical, 2) } - 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) + availableOfflineTrackRow(track, layout: layout) } } } @@ -420,28 +290,261 @@ struct iPhoneLibraryView: View { private func sectionCard( title: String, + layout: RootLayout, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 14) { Text(title) - .font(.headline) + .font(.title3.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) content() } .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) + .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 .gray - case .downloading: return .blue + case .downloading: + return .cyan case .downloaded: return .green case .missing: @@ -492,10 +595,131 @@ struct iPhoneLibraryView: View { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.75) + .allowsTightening(true) .padding(.horizontal, 10) - .padding(.vertical, 5) + .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 { @@ -508,25 +732,7 @@ private struct ArtworkCoverView: View { .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 - ) - ) + artworkPlaceholder(cornerRadius: 24, iconSize: 54) } } .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) @@ -551,19 +757,11 @@ private struct ArtworkThumbnailView: View { .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)) + artworkPlaceholder(cornerRadius: 12, iconSize: 18) } } - .frame(width: 52, height: 52) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } private var artworkImage: UIImage? { @@ -574,3 +772,28 @@ private struct ArtworkThumbnailView: View { 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 + ) + ) + ) +} diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 250eaad..9811093 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -291,6 +291,12 @@ struct NowPlayingCardViewData: Equatable { let errorMessage: String? } +struct LibrarySectionMessage: Equatable { + let title: String + let body: String + let systemImage: String +} + @MainActor @Observable final class iPhoneLibraryViewModel { @@ -313,7 +319,7 @@ final class iPhoneLibraryViewModel { rebuildRows() } } - var syncStatus = "Remote library not synced yet." + var syncStatus = "Sync your remote library to see your tracks here." var state: ViewState = .idle var nowPlaying: iPhoneNowPlayingState = .empty var nowPlayingFavoriteTrackID: String? @@ -340,6 +346,65 @@ final class iPhoneLibraryViewModel { !cachedRemoteLibraryTracks.isEmpty } + var isSyncing: Bool { + state == .loading + } + + var syncButtonTitle: String { + isSyncing ? "Syncing..." : "Sync Remote Library" + } + + var remoteSectionTitle: String { + let count = remoteTracks.count + return hasActiveSearch + ? "Remote Library Results (\(count))" + : "Remote Library (\(count))" + } + + var availableOfflineSectionTitle: String { + let count = availableOfflineTracks.count + return hasActiveSearch + ? "Offline Results (\(count))" + : "Available Offline (\(count))" + } + + var inlineSyncErrorMessage: LibrarySectionMessage? { + guard hasCachedRemoteTracks, case .networkError = state else { + return nil + } + + return Self.connectionFailedMessage + } + + var remoteEmptyStateMessage: LibrarySectionMessage? { + guard remoteTracks.isEmpty else { + return nil + } + + if hasActiveSearch { + return Self.searchEmptyMessage + } + + switch state { + case .loading: + return nil + case .networkError: + return Self.connectionFailedMessage + case .idle, .success, .empty: + return Self.remoteLibraryEmptyMessage + } + } + + var availableOfflineEmptyStateMessage: LibrarySectionMessage? { + guard availableOfflineTracks.isEmpty else { + return nil + } + + return hasActiveSearch + ? Self.searchEmptyMessage + : Self.availableOfflineEmptyMessage + } + var nowPlayingCard: NowPlayingCardViewData? { guard let trackID = nowPlaying.trackID, let title = nowPlaying.title @@ -390,7 +455,7 @@ final class iPhoneLibraryViewModel { progress: progress, isPlaying: nowPlaying.isPlaying, canSeek: nowPlaying.canSeek && duration > 0, - errorMessage: nowPlaying.errorMessage + errorMessage: Self.userFacingPlaybackErrorMessage(for: nowPlaying) ) } @@ -475,17 +540,23 @@ final class iPhoneLibraryViewModel { let snapshot = try await reloadOfflineLibrary() applyRestoredTracks(snapshot) if let favoriteRestoreError { - syncStatus += " Favorites could not be restored: \(favoriteRestoreError.localizedDescription)" + Self.logError(favoriteRestoreError, context: "Favorites restore failed") + syncStatus += " Favorites may be out of date." } } catch { + Self.logError(error, context: "Cached library restore failed") state = .idle - syncStatus = "Failed to load cached remote library: \(error.localizedDescription)" + syncStatus = "Could not restore the cached library." } } func refreshSync() async { + guard !isSyncing else { + return + } + state = .loading - syncStatus = "Syncing remote library..." + syncStatus = "Syncing your remote library..." do { let deviceId = try await currentOrRegisterDeviceID() @@ -493,8 +564,9 @@ final class iPhoneLibraryViewModel { let snapshot = try await reloadOfflineLibrary() applySyncedTracks(snapshot) } catch { - state = .networkError("Remote library sync failed.") - syncStatus = "Remote library sync failed: \(error.localizedDescription)" + Self.logError(error, context: "Remote library sync failed") + state = .networkError(error.localizedDescription) + syncStatus = Self.connectionFailedMessage.body } } @@ -518,8 +590,9 @@ final class iPhoneLibraryViewModel { _ = try await reloadOfflineLibrary() syncStatus = "Downloaded \(track.title)." } catch { + Self.logError(error, context: "Download failed for \(track.title)") _ = try? await reloadOfflineLibrary() - syncStatus = "Download failed for \(track.title): \(error.localizedDescription)" + syncStatus = "Could not download \(track.title). Try again." } } @@ -535,7 +608,7 @@ final class iPhoneLibraryViewModel { guard canAttemptPlayback(for: track.trackId) else { if remoteTrackStatus(for: track.trackId) == .missing { - syncStatus = "The downloaded file for \(track.title) is missing." + syncStatus = "The downloaded file for \(track.title) is missing. Re-download it to play again." } else { syncStatus = "Download the track before playing it offline." } @@ -574,9 +647,10 @@ final class iPhoneLibraryViewModel { do { try await favoriteTrackStore.saveFavoriteTracks(Array(cachedFavoriteTrackRecordsByID.values)) } catch { + Self.logError(error, context: "Favorite update failed") cachedFavoriteTrackRecordsByID = previousFavorites rebuildRows() - syncStatus = "Favorite update failed: \(error.localizedDescription)" + syncStatus = "Could not update favorites. Try again." } } @@ -607,20 +681,20 @@ final class iPhoneLibraryViewModel { private func applyRestoredTracks(_ snapshot: OfflineLibrarySnapshot) { if snapshot.remoteTracks.isEmpty { state = .idle - syncStatus = "Tap Sync Remote Library to load remote metadata." + syncStatus = "Sync your remote library to see your tracks here." } else { state = .success - syncStatus = "Restored \(snapshot.remoteTracks.count) cached remote track(s). Offline available: \(snapshot.availableTracks.count)." + syncStatus = "Library restored from local cache." } } private func applySyncedTracks(_ snapshot: OfflineLibrarySnapshot) { if snapshot.remoteTracks.isEmpty { state = .empty - syncStatus = "Remote library is empty." + syncStatus = "No music was returned for this library." } else { state = .success - syncStatus = "Sync Remote Library completed. Remote tracks: \(snapshot.remoteTracks.count). Offline available: \(snapshot.availableTracks.count)." + syncStatus = "Library synced successfully." } } @@ -727,10 +801,10 @@ final class iPhoneLibraryViewModel { private func handlePlaybackResult(for track: RemoteTrack) { switch nowPlaying.playbackState { case .missingFile: - syncStatus = "The downloaded file for \(track.title) is missing." + syncStatus = "The downloaded file for \(track.title) is missing. Re-download it to play again." refreshOfflineLibraryInBackground() case .failed: - syncStatus = "Playback failed for \(track.title): \(nowPlaying.errorMessage ?? "Unknown error.")" + syncStatus = "Playback couldn't start for \(track.title). Try again." case .playing, .paused, .stopped: break } @@ -816,6 +890,21 @@ final class iPhoneLibraryViewModel { return "\(minutes):\(String(format: "%02d", remainingSeconds))" } + private static func userFacingPlaybackErrorMessage(for state: iPhoneNowPlayingState) -> String? { + switch state.playbackState { + case .missingFile: + return "This downloaded file is missing. Re-download the track to play it again." + case .failed: + return "Playback couldn't start. Try again." + case .playing, .paused, .stopped: + return nil + } + } + + private static func logError(_ error: Error, context: String) { + print("iPhoneLibraryViewModel \(context): \(error.localizedDescription)") + } + #if canImport(UIKit) private static var currentDeviceName: String { UIDevice.current.name @@ -826,6 +915,26 @@ final class iPhoneLibraryViewModel { private static let deviceIDKey = "velody.iphone.device-id" private static let bootstrapTokenKey = "velody.iphone.bootstrap-token" + private static let remoteLibraryEmptyMessage = LibrarySectionMessage( + title: "No music synced yet", + body: "Sync your remote library to see your tracks here.", + systemImage: "music.note.list" + ) + private static let availableOfflineEmptyMessage = LibrarySectionMessage( + title: "No offline tracks yet", + body: "Download tracks to listen without internet.", + systemImage: "arrow.down.circle" + ) + private static let searchEmptyMessage = LibrarySectionMessage( + title: "No matching tracks", + body: "Try a different title or artist.", + systemImage: "magnifyingglass" + ) + private static let connectionFailedMessage = LibrarySectionMessage( + title: "Connection failed", + body: "Could not reach the backend. Check that the server is running and try again.", + systemImage: "wifi.exclamationmark" + ) } struct RemoteTrackRowViewData: Identifiable, Equatable { @@ -834,15 +943,13 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { let artist: String let durationText: String let isFavorite: Bool - let remoteTrackID: String - let assetID: String let status: OfflineLibraryRemoteTrackStatus - let statusText: String + let statusBadgeTitle: String + let statusDetailText: String? let canDownload: Bool let downloadButtonTitle: String let canPlay: Bool let playButtonTitle: String - let lastDownloadError: String? let artworkLocalFilePath: String? init( @@ -855,17 +962,15 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { artist = track.remoteTrack.artist durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds) self.isFavorite = isFavorite - remoteTrackID = track.remoteTrack.trackId - assetID = track.remoteTrack.assetId status = track.status - statusText = Self.statusText(for: track.status) + statusBadgeTitle = Self.statusBadgeTitle(for: track.status) + statusDetailText = Self.statusDetailText(for: track.status) canDownload = track.status == .notDownloaded || track.status == .failed || track.status == .missing downloadButtonTitle = Self.downloadButtonTitle(for: track.status) canPlay = track.isFileAvailable playButtonTitle = nowPlaying.trackID == track.remoteTrack.trackId && nowPlaying.isPlaying ? "Pause" : "Play" - lastDownloadError = track.lastDownloadError artworkLocalFilePath = track.localArtworkFilePath } @@ -875,10 +980,10 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { return "\(minutes):\(String(format: "%02d", remainingSeconds))" } - private static func statusText(for status: OfflineLibraryRemoteTrackStatus) -> String { + private static func statusBadgeTitle(for status: OfflineLibraryRemoteTrackStatus) -> String { switch status { case .notDownloaded: - return "Not downloaded" + return "Offline" case .downloading: return "Downloading" case .downloaded: @@ -890,12 +995,25 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { } } + private static func statusDetailText(for status: OfflineLibraryRemoteTrackStatus) -> String? { + switch status { + case .missing: + return "Downloaded file missing. Re-download to restore offline playback." + case .failed: + return "Download failed. Try again." + case .notDownloaded, .downloading, .downloaded: + return nil + } + } + private static func downloadButtonTitle(for status: OfflineLibraryRemoteTrackStatus) -> String { switch status { case .notDownloaded: return "Download" - case .downloading, .downloaded: - return "Download" + case .downloading: + return "Downloading..." + case .downloaded: + return "Play" case .missing: return "Re-download" case .failed: @@ -910,8 +1028,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable { let artist: String let durationText: String let isFavorite: Bool - let remoteTrackID: String - let assetID: String + let statusBadgeTitle: String let playButtonTitle: String let artworkLocalFilePath: String? @@ -925,8 +1042,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable { artist = track.artist durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds) self.isFavorite = isFavorite - remoteTrackID = track.remoteTrackId - assetID = track.assetId + statusBadgeTitle = "Downloaded" playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying ? "Pause" : "Play" diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift index 366f14c..970570e 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift @@ -1,6 +1,7 @@ import Foundation import XCTest import VelodyDomain +import VelodyNetworking import VelodyPlayback import VelodyPersistence @testable import VelodyiPhone @@ -64,7 +65,10 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { XCTAssertEqual(card.title, track.title) XCTAssertEqual(card.artist, track.artist) XCTAssertEqual(card.playbackStateText, "Missing file") - XCTAssertEqual(card.errorMessage, "The local file could not be found: \(localFilePath(for: track))") + XCTAssertEqual( + card.errorMessage, + "This downloaded file is missing. Re-download the track to play it again." + ) } func testPauseStopsProgressUpdates() async throws { @@ -257,6 +261,286 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { } } +@MainActor +final class iPhoneLibraryViewModelPolishTests: XCTestCase { + func testSyncingStatePreventsDuplicateRefreshCalls() async { + let track = makeRemoteTrack( + trackId: "remote-sync-dedupe", + assetId: "asset-sync-dedupe", + title: "Sync Once" + ) + let counter = RegisterCallCounter() + let apiClient = TestRegisterAPIClient( + counter: counter, + delayNanoseconds: 200_000_000 + ) + let viewModel = makeViewModel( + remoteTracks: [track], + apiClient: apiClient + ) + + await viewModel.loadIfNeeded() + + async let firstRefresh: Void = viewModel.refreshSync() + async let secondRefresh: Void = viewModel.refreshSync() + _ = await (firstRefresh, secondRefresh) + + let syncCallCount = await counter.count + XCTAssertEqual(syncCallCount, 1) + XCTAssertFalse(viewModel.isSyncing) + } + + func testUserFacingConnectionErrorDoesNotExposeRawExceptionDetails() async { + let rawErrorText = "socket closed for 10.0.0.8:3017" + let apiClient = TestRegisterAPIClient( + counter: RegisterCallCounter(), + registerError: VelodyAPIError.requestFailed(rawErrorText) + ) + let viewModel = makeViewModel( + remoteTracks: [], + apiClient: apiClient + ) + + await viewModel.loadIfNeeded() + await viewModel.refreshSync() + + XCTAssertEqual( + viewModel.syncStatus, + "Could not reach the backend. Check that the server is running and try again." + ) + XCTAssertEqual(viewModel.remoteEmptyStateMessage?.title, "Connection failed") + XCTAssertEqual( + viewModel.remoteEmptyStateMessage?.body, + "Could not reach the backend. Check that the server is running and try again." + ) + XCTAssertFalse(viewModel.syncStatus.contains(rawErrorText)) + + guard case let .networkError(debugMessage) = viewModel.state else { + return XCTFail("Expected network error state.") + } + + XCTAssertTrue(debugMessage.contains(rawErrorText)) + } + + func testSectionTitlesReflectFilteredAndUnfilteredCounts() async { + let firstTrack = makeRemoteTrack( + trackId: "remote-counts-first", + assetId: "asset-counts-first", + title: "Trap Door" + ) + let secondTrack = makeRemoteTrack( + trackId: "remote-counts-second", + assetId: "asset-counts-second", + title: "Harbor Lights" + ) + let viewModel = makeViewModel( + remoteTracks: [firstTrack, secondTrack], + downloadStates: [ + makeDownloadedState(for: firstTrack), + makeDownloadedState(for: secondTrack), + ], + audioFiles: [ + localFilePath(for: firstTrack): Data([0x1]), + localFilePath(for: secondTrack): Data([0x2]), + ] + ) + + await viewModel.loadIfNeeded() + + XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library (2)") + XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (2)") + + viewModel.searchText = "Trap" + + XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library Results (1)") + XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Offline Results (1)") + + viewModel.searchText = "zzz" + + XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library Results (0)") + XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Offline Results (0)") + } + + func testSearchEmptyStateUsesFriendlyCopyAndKeepsNowPlaying() async throws { + let firstTrack = makeRemoteTrack( + trackId: "remote-empty-search-first", + assetId: "asset-empty-search-first", + title: "Light Trap" + ) + let secondTrack = makeRemoteTrack( + trackId: "remote-empty-search-second", + assetId: "asset-empty-search-second", + title: "Harbor Lights" + ) + let player = TestPlayer() + let viewModel = makeViewModel( + remoteTracks: [firstTrack, secondTrack], + downloadStates: [ + makeDownloadedState(for: firstTrack), + makeDownloadedState(for: secondTrack), + ], + audioFiles: [ + localFilePath(for: firstTrack): Data([0x1]), + localFilePath(for: secondTrack): Data([0x2]), + ], + player: player + ) + + await viewModel.loadIfNeeded() + viewModel.togglePlayback(trackID: firstTrack.trackId) + viewModel.searchText = "zzz" + + XCTAssertEqual(viewModel.remoteEmptyStateMessage?.title, "No matching tracks") + XCTAssertEqual(viewModel.remoteEmptyStateMessage?.body, "Try a different title or artist.") + XCTAssertEqual(viewModel.availableOfflineEmptyStateMessage?.title, "No matching tracks") + XCTAssertEqual(try XCTUnwrap(viewModel.nowPlayingCard).trackID, firstTrack.trackId) + } + + func testDisplayRowsDoNotExposeDebugIdentifiers() async throws { + let track = makeRemoteTrack( + trackId: "remote-polish-row", + assetId: "asset-polish-row", + title: "Clean Display" + ) + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + audioFiles: [localFilePath(for: track): Data([0x1])] + ) + + await viewModel.loadIfNeeded() + + let remoteLabels = Set(Mirror(reflecting: try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))).children.compactMap(\.label)) + let offlineLabels = Set(Mirror(reflecting: try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId))).children.compactMap(\.label)) + + XCTAssertFalse(remoteLabels.contains("remoteTrackID")) + XCTAssertFalse(remoteLabels.contains("assetID")) + XCTAssertFalse(remoteLabels.contains("lastDownloadError")) + XCTAssertFalse(offlineLabels.contains("remoteTrackID")) + XCTAssertFalse(offlineLabels.contains("assetID")) + } + + func testMissingTracksStayOutOfAvailableOffline() async throws { + let track = makeRemoteTrack( + trackId: "remote-missing-offline", + assetId: "asset-missing-offline", + title: "Lost File" + ) + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)] + ) + + await viewModel.loadIfNeeded() + + let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) + XCTAssertEqual(remoteTrack.status, .missing) + XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing") + XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) + XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)") + } +} + +private actor RegisterCallCounter { + private(set) var count = 0 + + func increment() { + count += 1 + } +} + +private struct TestRegisterAPIClient: VelodyAPIClient { + let counter: RegisterCallCounter + var delayNanoseconds: UInt64 = 0 + var registerError: VelodyAPIError? + + private let environment = ServerEnvironment( + baseURL: ServerEnvironment.defaultLocalBaseURL, + appVersion: "Tests" + ) + + func registerDevice( + _ payload: DeviceRegistrationPayload + ) async throws -> DeviceRegistrationResponse { + await counter.increment() + + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + + if let registerError { + throw registerError + } + + return try await stubClient.registerDevice(payload) + } + + func sendHeartbeat( + _ payload: DeviceHeartbeatPayload + ) async throws -> DeviceHeartbeatResponse { + try await stubClient.sendHeartbeat(payload) + } + + func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { + try await stubClient.fetchSyncBootstrap() + } + + func fetchRemoteLibrary( + deviceId: String + ) async throws -> RemoteLibraryResponseDTO { + try await stubClient.fetchRemoteLibrary(deviceId: deviceId) + } + + func downloadAudioAsset( + assetId: String, + deviceId: String + ) async throws -> Data { + try await stubClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId) + } + + func downloadArtwork( + artworkId: String, + deviceId: String + ) async throws -> Data { + try await stubClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId) + } + + func prepareUpload( + _ payload: UploadPrepareRequest + ) async throws -> UploadPrepareResponse { + try await stubClient.prepareUpload(payload) + } + + func fetchUploadStatus( + uploadId: String + ) async throws -> UploadSessionStatusResponse { + try await stubClient.fetchUploadStatus(uploadId: uploadId) + } + + func uploadFile( + uploadId: String, + fileURL: URL, + mimeType: String + ) async throws -> UploadSessionStatusResponse { + try await stubClient.uploadFile( + uploadId: uploadId, + fileURL: fileURL, + mimeType: mimeType + ) + } + + func finalizeUpload( + uploadId: String, + payload: UploadFinalizeRequest + ) async throws -> UploadFinalizeResponse { + try await stubClient.finalizeUpload(uploadId: uploadId, payload: payload) + } + + private var stubClient: StubVelodyAPIClient { + StubVelodyAPIClient(environment: environment) + } +} + @MainActor private final class FakePlaybackEngine: PlaybackEngine { var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)? diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift index f7947af..d9682b3 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift @@ -213,7 +213,9 @@ func makeViewModel( favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(), audioFiles: [String: Data] = [:], artworkPayloadsByArtworkID: [String: Data] = [:], - player: (any iPhoneLocalAudioPlaying)? = nil + player: (any iPhoneLocalAudioPlaying)? = nil, + apiClient: (any VelodyAPIClient)? = nil, + keychainService: any KeychainService = MemoryKeychainService() ) -> iPhoneLibraryViewModel { let repository = TestRemoteLibraryRepository(tracks: remoteTracks) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates) @@ -241,7 +243,7 @@ func makeViewModel( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "Tests" ), - apiClient: URLSessionVelodyAPIClient( + apiClient: apiClient ?? URLSessionVelodyAPIClient( environment: ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "Tests" @@ -251,7 +253,7 @@ func makeViewModel( offlineLibraryService: offlineLibraryService, favoriteTrackStore: favoriteTrackStore, player: player ?? TestPlayer(), - keychainService: MemoryKeychainService() + keychainService: keychainService ) }