Polish iPhone library layout
This commit is contained in:
parent
a147e98b21
commit
a4f83ef151
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)?
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user