Polish iPhone library layout

This commit is contained in:
diyaa 2026-06-02 08:59:44 +02:00
parent a147e98b21
commit a4f83ef151
4 changed files with 944 additions and 319 deletions

View File

@ -5,29 +5,85 @@ import UIKit
#endif #endif
struct iPhoneLibraryView: View { 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 viewModel = iPhoneLibraryViewModel()
@State private var scrubbedPlaybackTime: Double? @State private var scrubbedPlaybackTime: Double?
var body: some View { var body: some View {
ScrollView { GeometryReader { proxy in
VStack(alignment: .leading, spacing: 20) { let layout = RootLayout(rootWidth: proxy.size.width)
titleSection
syncSection
searchSection
if let nowPlayingCard = viewModel.nowPlayingCard { ZStack {
nowPlayingCardView(nowPlayingCard) 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)
remoteLibrarySection
availableOfflineSection
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 24)
} }
.background(Color(uiColor: .systemGroupedBackground))
.task { .task {
await viewModel.loadIfNeeded() await viewModel.loadIfNeeded()
} }
@ -41,17 +97,28 @@ struct iPhoneLibraryView: View {
private var syncSection: some View { private var syncSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Button("Sync Remote Library") { Button(viewModel.syncButtonTitle) {
Task { Task {
await viewModel.refreshSync() await viewModel.refreshSync()
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(viewModel.state == .loading) .disabled(viewModel.isSyncing)
Text(viewModel.syncStatus) if viewModel.isSyncing {
.font(.footnote) HStack(spacing: 10) {
.foregroundStyle(.secondary) 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) .frame(maxWidth: .infinity, alignment: .leading)
} }
@ -97,20 +164,21 @@ struct iPhoneLibraryView: View {
} }
@ViewBuilder @ViewBuilder
private func nowPlayingCardView(_ card: NowPlayingCardViewData) -> some View { private func nowPlayingCardView(_ card: NowPlayingCardViewData, layout: RootLayout) -> some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 12) { ViewThatFits(in: .vertical) {
VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 12) {
Text("Now Playing") nowPlayingHeaderText(card)
.font(.headline)
Text(card.playbackStateText) Spacer(minLength: 12)
.font(.subheadline)
.foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState)) statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
} }
Spacer(minLength: 12) VStack(alignment: .leading, spacing: 10) {
nowPlayingHeaderText(card)
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
}
} }
ArtworkCoverView(localFilePath: card.artworkLocalFilePath) ArtworkCoverView(localFilePath: card.artworkLocalFilePath)
@ -165,45 +233,10 @@ struct iPhoneLibraryView: View {
.foregroundStyle(.red) .foregroundStyle(.red)
} }
HStack(spacing: 12) { nowPlayingControls(card)
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) .frame(maxWidth: .infinity, alignment: .leading)
.padding(layout.cardPadding)
.background( .background(
RoundedRectangle(cornerRadius: 24, style: .continuous) RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground)) .fill(Color(uiColor: .secondarySystemGroupedBackground))
@ -214,93 +247,21 @@ struct iPhoneLibraryView: View {
} }
@ViewBuilder @ViewBuilder
private var remoteLibrarySection: some View { private func remoteLibrarySection(layout: RootLayout) -> some View {
sectionCard(title: "Remote Library: \(viewModel.remoteTracks.count)") { sectionCard(title: viewModel.remoteSectionTitle, layout: layout) {
if viewModel.remoteTracks.isEmpty { if viewModel.isSyncing && viewModel.remoteTracks.isEmpty && !viewModel.hasCachedRemoteTracks && !viewModel.hasActiveSearch {
if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks { loadingStateView("Syncing your remote library...")
Text("No matching tracks found.") } else if let message = viewModel.remoteEmptyStateMessage {
.font(.subheadline) messageCard(message)
.foregroundStyle(.secondary)
} else {
remoteLibraryEmptyState
}
} else { } else {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) {
ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in
if index > 0 { if index > 0 {
Divider() Divider()
.padding(.vertical, 12) .padding(.vertical, 2)
} }
VStack(alignment: .leading, spacing: 6) { remoteTrackRow(track, layout: layout)
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)
} }
} }
} }
@ -308,110 +269,19 @@ struct iPhoneLibraryView: View {
} }
@ViewBuilder @ViewBuilder
private var remoteLibraryEmptyState: some View { private func availableOfflineSection(layout: RootLayout) -> some View {
switch viewModel.state { sectionCard(title: viewModel.availableOfflineSectionTitle, layout: layout) {
case .loading: if let message = viewModel.availableOfflineEmptyStateMessage {
ProgressView("Syncing remote library...") messageCard(message)
.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 { } else {
ContentUnavailableView( VStack(alignment: .leading, spacing: 12) {
"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 ForEach(Array(viewModel.availableOfflineTracks.enumerated()), id: \.element.id) { index, track in
if index > 0 { if index > 0 {
Divider() Divider()
.padding(.vertical, 12) .padding(.vertical, 2)
} }
VStack(alignment: .leading, spacing: 6) { availableOfflineTrackRow(track, layout: layout)
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)
} }
} }
} }
@ -420,28 +290,261 @@ struct iPhoneLibraryView: View {
private func sectionCard<Content: View>( private func sectionCard<Content: View>(
title: String, title: String,
layout: RootLayout,
@ViewBuilder content: () -> Content @ViewBuilder content: () -> Content
) -> some View { ) -> some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
Text(title) Text(title)
.font(.headline) .font(.title3.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
content() content()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(18) .padding(layout.sectionPadding)
.background( .background(
RoundedRectangle(cornerRadius: 20, style: .continuous) RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground)) .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 { private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color {
switch status { switch status {
case .notDownloaded: case .notDownloaded:
return .gray
case .downloading:
return .blue return .blue
case .downloading:
return .cyan
case .downloaded: case .downloaded:
return .green return .green
case .missing: case .missing:
@ -492,10 +595,131 @@ struct iPhoneLibraryView: View {
Text(title) Text(title)
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(color) .foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.75)
.allowsTightening(true)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 5) .frame(minHeight: LayoutMetrics.badgeMinHeight)
.multilineTextAlignment(.center)
.background(color.opacity(0.14), in: Capsule()) .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 { private struct ArtworkCoverView: View {
@ -508,25 +732,7 @@ private struct ArtworkCoverView: View {
.resizable() .resizable()
.scaledToFill() .scaledToFill()
} else { } else {
VStack { artworkPlaceholder(cornerRadius: 24, iconSize: 54)
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)) .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
@ -551,19 +757,11 @@ private struct ArtworkThumbnailView: View {
.resizable() .resizable()
.scaledToFill() .scaledToFill()
} else { } else {
VStack { artworkPlaceholder(cornerRadius: 12, iconSize: 18)
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) .frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
private var artworkImage: UIImage? { private var artworkImage: UIImage? {
@ -574,3 +772,28 @@ private struct ArtworkThumbnailView: View {
return UIImage(contentsOfFile: localFilePath) 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
)
)
)
}

View File

@ -291,6 +291,12 @@ struct NowPlayingCardViewData: Equatable {
let errorMessage: String? let errorMessage: String?
} }
struct LibrarySectionMessage: Equatable {
let title: String
let body: String
let systemImage: String
}
@MainActor @MainActor
@Observable @Observable
final class iPhoneLibraryViewModel { final class iPhoneLibraryViewModel {
@ -313,7 +319,7 @@ final class iPhoneLibraryViewModel {
rebuildRows() rebuildRows()
} }
} }
var syncStatus = "Remote library not synced yet." var syncStatus = "Sync your remote library to see your tracks here."
var state: ViewState = .idle var state: ViewState = .idle
var nowPlaying: iPhoneNowPlayingState = .empty var nowPlaying: iPhoneNowPlayingState = .empty
var nowPlayingFavoriteTrackID: String? var nowPlayingFavoriteTrackID: String?
@ -340,6 +346,65 @@ final class iPhoneLibraryViewModel {
!cachedRemoteLibraryTracks.isEmpty !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? { var nowPlayingCard: NowPlayingCardViewData? {
guard let trackID = nowPlaying.trackID, guard let trackID = nowPlaying.trackID,
let title = nowPlaying.title let title = nowPlaying.title
@ -390,7 +455,7 @@ final class iPhoneLibraryViewModel {
progress: progress, progress: progress,
isPlaying: nowPlaying.isPlaying, isPlaying: nowPlaying.isPlaying,
canSeek: nowPlaying.canSeek && duration > 0, 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() let snapshot = try await reloadOfflineLibrary()
applyRestoredTracks(snapshot) applyRestoredTracks(snapshot)
if let favoriteRestoreError { 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 { } catch {
Self.logError(error, context: "Cached library restore failed")
state = .idle state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)" syncStatus = "Could not restore the cached library."
} }
} }
func refreshSync() async { func refreshSync() async {
guard !isSyncing else {
return
}
state = .loading state = .loading
syncStatus = "Syncing remote library..." syncStatus = "Syncing your remote library..."
do { do {
let deviceId = try await currentOrRegisterDeviceID() let deviceId = try await currentOrRegisterDeviceID()
@ -493,8 +564,9 @@ final class iPhoneLibraryViewModel {
let snapshot = try await reloadOfflineLibrary() let snapshot = try await reloadOfflineLibrary()
applySyncedTracks(snapshot) applySyncedTracks(snapshot)
} catch { } catch {
state = .networkError("Remote library sync failed.") Self.logError(error, context: "Remote library sync failed")
syncStatus = "Remote library sync failed: \(error.localizedDescription)" state = .networkError(error.localizedDescription)
syncStatus = Self.connectionFailedMessage.body
} }
} }
@ -518,8 +590,9 @@ final class iPhoneLibraryViewModel {
_ = try await reloadOfflineLibrary() _ = try await reloadOfflineLibrary()
syncStatus = "Downloaded \(track.title)." syncStatus = "Downloaded \(track.title)."
} catch { } catch {
Self.logError(error, context: "Download failed for \(track.title)")
_ = try? await reloadOfflineLibrary() _ = 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 { guard canAttemptPlayback(for: track.trackId) else {
if remoteTrackStatus(for: track.trackId) == .missing { 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 { } else {
syncStatus = "Download the track before playing it offline." syncStatus = "Download the track before playing it offline."
} }
@ -574,9 +647,10 @@ final class iPhoneLibraryViewModel {
do { do {
try await favoriteTrackStore.saveFavoriteTracks(Array(cachedFavoriteTrackRecordsByID.values)) try await favoriteTrackStore.saveFavoriteTracks(Array(cachedFavoriteTrackRecordsByID.values))
} catch { } catch {
Self.logError(error, context: "Favorite update failed")
cachedFavoriteTrackRecordsByID = previousFavorites cachedFavoriteTrackRecordsByID = previousFavorites
rebuildRows() 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) { private func applyRestoredTracks(_ snapshot: OfflineLibrarySnapshot) {
if snapshot.remoteTracks.isEmpty { if snapshot.remoteTracks.isEmpty {
state = .idle state = .idle
syncStatus = "Tap Sync Remote Library to load remote metadata." syncStatus = "Sync your remote library to see your tracks here."
} else { } else {
state = .success 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) { private func applySyncedTracks(_ snapshot: OfflineLibrarySnapshot) {
if snapshot.remoteTracks.isEmpty { if snapshot.remoteTracks.isEmpty {
state = .empty state = .empty
syncStatus = "Remote library is empty." syncStatus = "No music was returned for this library."
} else { } else {
state = .success 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) { private func handlePlaybackResult(for track: RemoteTrack) {
switch nowPlaying.playbackState { switch nowPlaying.playbackState {
case .missingFile: 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() refreshOfflineLibraryInBackground()
case .failed: 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: case .playing, .paused, .stopped:
break break
} }
@ -816,6 +890,21 @@ final class iPhoneLibraryViewModel {
return "\(minutes):\(String(format: "%02d", remainingSeconds))" 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) #if canImport(UIKit)
private static var currentDeviceName: String { private static var currentDeviceName: String {
UIDevice.current.name UIDevice.current.name
@ -826,6 +915,26 @@ final class iPhoneLibraryViewModel {
private static let deviceIDKey = "velody.iphone.device-id" private static let deviceIDKey = "velody.iphone.device-id"
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token" 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 { struct RemoteTrackRowViewData: Identifiable, Equatable {
@ -834,15 +943,13 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let artist: String let artist: String
let durationText: String let durationText: String
let isFavorite: Bool let isFavorite: Bool
let remoteTrackID: String
let assetID: String
let status: OfflineLibraryRemoteTrackStatus let status: OfflineLibraryRemoteTrackStatus
let statusText: String let statusBadgeTitle: String
let statusDetailText: String?
let canDownload: Bool let canDownload: Bool
let downloadButtonTitle: String let downloadButtonTitle: String
let canPlay: Bool let canPlay: Bool
let playButtonTitle: String let playButtonTitle: String
let lastDownloadError: String?
let artworkLocalFilePath: String? let artworkLocalFilePath: String?
init( init(
@ -855,17 +962,15 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
artist = track.remoteTrack.artist artist = track.remoteTrack.artist
durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds) durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds)
self.isFavorite = isFavorite self.isFavorite = isFavorite
remoteTrackID = track.remoteTrack.trackId
assetID = track.remoteTrack.assetId
status = track.status 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 canDownload = track.status == .notDownloaded || track.status == .failed || track.status == .missing
downloadButtonTitle = Self.downloadButtonTitle(for: track.status) downloadButtonTitle = Self.downloadButtonTitle(for: track.status)
canPlay = track.isFileAvailable canPlay = track.isFileAvailable
playButtonTitle = nowPlaying.trackID == track.remoteTrack.trackId && nowPlaying.isPlaying playButtonTitle = nowPlaying.trackID == track.remoteTrack.trackId && nowPlaying.isPlaying
? "Pause" ? "Pause"
: "Play" : "Play"
lastDownloadError = track.lastDownloadError
artworkLocalFilePath = track.localArtworkFilePath artworkLocalFilePath = track.localArtworkFilePath
} }
@ -875,10 +980,10 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
return "\(minutes):\(String(format: "%02d", remainingSeconds))" return "\(minutes):\(String(format: "%02d", remainingSeconds))"
} }
private static func statusText(for status: OfflineLibraryRemoteTrackStatus) -> String { private static func statusBadgeTitle(for status: OfflineLibraryRemoteTrackStatus) -> String {
switch status { switch status {
case .notDownloaded: case .notDownloaded:
return "Not downloaded" return "Offline"
case .downloading: case .downloading:
return "Downloading" return "Downloading"
case .downloaded: 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 { private static func downloadButtonTitle(for status: OfflineLibraryRemoteTrackStatus) -> String {
switch status { switch status {
case .notDownloaded: case .notDownloaded:
return "Download" return "Download"
case .downloading, .downloaded: case .downloading:
return "Download" return "Downloading..."
case .downloaded:
return "Play"
case .missing: case .missing:
return "Re-download" return "Re-download"
case .failed: case .failed:
@ -910,8 +1028,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
let artist: String let artist: String
let durationText: String let durationText: String
let isFavorite: Bool let isFavorite: Bool
let remoteTrackID: String let statusBadgeTitle: String
let assetID: String
let playButtonTitle: String let playButtonTitle: String
let artworkLocalFilePath: String? let artworkLocalFilePath: String?
@ -925,8 +1042,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
artist = track.artist artist = track.artist
durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds) durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds)
self.isFavorite = isFavorite self.isFavorite = isFavorite
remoteTrackID = track.remoteTrackId statusBadgeTitle = "Downloaded"
assetID = track.assetId
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
? "Pause" ? "Pause"
: "Play" : "Play"

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import XCTest import XCTest
import VelodyDomain import VelodyDomain
import VelodyNetworking
import VelodyPlayback import VelodyPlayback
import VelodyPersistence import VelodyPersistence
@testable import VelodyiPhone @testable import VelodyiPhone
@ -64,7 +65,10 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
XCTAssertEqual(card.title, track.title) XCTAssertEqual(card.title, track.title)
XCTAssertEqual(card.artist, track.artist) XCTAssertEqual(card.artist, track.artist)
XCTAssertEqual(card.playbackStateText, "Missing file") 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 { 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 @MainActor
private final class FakePlaybackEngine: PlaybackEngine { private final class FakePlaybackEngine: PlaybackEngine {
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)? var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?

View File

@ -213,7 +213,9 @@ func makeViewModel(
favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(), favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(),
audioFiles: [String: Data] = [:], audioFiles: [String: Data] = [:],
artworkPayloadsByArtworkID: [String: Data] = [:], artworkPayloadsByArtworkID: [String: Data] = [:],
player: (any iPhoneLocalAudioPlaying)? = nil player: (any iPhoneLocalAudioPlaying)? = nil,
apiClient: (any VelodyAPIClient)? = nil,
keychainService: any KeychainService = MemoryKeychainService()
) -> iPhoneLibraryViewModel { ) -> iPhoneLibraryViewModel {
let repository = TestRemoteLibraryRepository(tracks: remoteTracks) let repository = TestRemoteLibraryRepository(tracks: remoteTracks)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates)
@ -241,7 +243,7 @@ func makeViewModel(
baseURL: ServerEnvironment.defaultLocalBaseURL, baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests" appVersion: "Tests"
), ),
apiClient: URLSessionVelodyAPIClient( apiClient: apiClient ?? URLSessionVelodyAPIClient(
environment: ServerEnvironment( environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL, baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests" appVersion: "Tests"
@ -251,7 +253,7 @@ func makeViewModel(
offlineLibraryService: offlineLibraryService, offlineLibraryService: offlineLibraryService,
favoriteTrackStore: favoriteTrackStore, favoriteTrackStore: favoriteTrackStore,
player: player ?? TestPlayer(), player: player ?? TestPlayer(),
keychainService: MemoryKeychainService() keychainService: keychainService
) )
} }