Polish iPhone library layout
This commit is contained in:
parent
a147e98b21
commit
a4f83ef151
@ -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 {
|
||||
GeometryReader { proxy in
|
||||
let layout = RootLayout(rootWidth: proxy.size.width)
|
||||
|
||||
ZStack {
|
||||
Color(uiColor: .systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: LayoutMetrics.contentSpacing) {
|
||||
titleSection
|
||||
syncSection
|
||||
searchSection
|
||||
|
||||
if let nowPlayingCard = viewModel.nowPlayingCard {
|
||||
nowPlayingCardView(nowPlayingCard)
|
||||
nowPlayingCardView(nowPlayingCard, layout: layout)
|
||||
}
|
||||
|
||||
remoteLibrarySection
|
||||
availableOfflineSection
|
||||
remoteLibrarySection(layout: layout)
|
||||
availableOfflineSection(layout: layout)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 24)
|
||||
.frame(width: layout.contentWidth, alignment: .leading)
|
||||
.padding(.horizontal, layout.horizontalPadding)
|
||||
.padding(.top, LayoutMetrics.topPadding)
|
||||
.padding(.bottom, LayoutMetrics.bottomPadding)
|
||||
}
|
||||
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .top)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.task {
|
||||
await viewModel.loadIfNeeded()
|
||||
}
|
||||
@ -41,18 +97,29 @@ 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)
|
||||
|
||||
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,22 +164,23 @@ 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) {
|
||||
ViewThatFits(in: .vertical) {
|
||||
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))
|
||||
}
|
||||
nowPlayingHeaderText(card)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
nowPlayingHeaderText(card)
|
||||
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
|
||||
}
|
||||
}
|
||||
|
||||
ArtworkCoverView(localFilePath: card.artworkLocalFilePath)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
@ -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)
|
||||
nowPlayingControls(card)
|
||||
}
|
||||
}
|
||||
.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(
|
||||
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)
|
||||
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 {
|
||||
remoteLibraryEmptyState
|
||||
}
|
||||
} 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<Content: View>(
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)?
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user