Implement iPhone now playing experience

This commit is contained in:
diyaa 2026-06-01 00:24:16 +02:00
parent 3d7252ce6d
commit a147e98b21
7 changed files with 1524 additions and 549 deletions

View File

@ -9,26 +9,30 @@
/* Begin PBXBuildFile section */
0AF7078E8BD078ECE18B6C0A /* VelodyiPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */; };
151772779307EFC3B3A17477 /* MacLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */; };
2180184D8B90349397750F62 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = C199F1B9F55E51FD373EA729 /* VelodyUtilities */; };
2E9DD262BF65832378F37DD4 /* VelodyPlayback in Frameworks */ = {isa = PBXBuildFile; productRef = EA45DE5B08D71D666009BB5E /* VelodyPlayback */; };
2F9E426A66F4887C301AB13C /* FolderAccessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */; };
3B9765B34963F430467B7527 /* LocalMusicScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */; };
3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; };
4F8B36EB80C008CDD9B2F6A6 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = C199F1B9F55E51FD373EA729 /* VelodyUtilities */; };
4F8B36EB80C008CDD9B2F6A6 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = A571882D6B46CA9F729D13CB /* VelodySync */; };
58E2E18AAC9318AB98F81004 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 14CD56063C10911F40C9CBA3 /* VelodyDomain */; };
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; };
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; };
783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; };
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; };
A1FB43063B59B52B1C90A7A7 /* iPhoneLibraryViewModelTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2446CD01A27662F88EA0F43 /* iPhoneLibraryViewModelTestSupport.swift */; };
A2DDB28F9916D0DFB1ADD4FF /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */; };
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
AB6C7E42A3A850D395E4F5E7 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; };
AC8B414ECE5493BD52DEC44A /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = B99672FF5519DDF310A5EBD1 /* VelodyPersistence */; };
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
A62771F49BF9AA1ABCF7961E /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
AB6C7E42A3A850D395E4F5E7 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
AC8B414ECE5493BD52DEC44A /* VelodyPlayback in Frameworks */ = {isa = PBXBuildFile; productRef = A9678775BC86EBB3155ECBDE /* VelodyPlayback */; };
CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */; };
D0D65CE73B9DFF3C73F432DB /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; };
D4B554447B262C7B946ED21F /* VelodyPlayback in Frameworks */ = {isa = PBXBuildFile; productRef = BE8A38167B5A10BAECF48787 /* VelodyPlayback */; };
DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */; };
E4627FCDF7EB169D83EA50DB /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = A571882D6B46CA9F729D13CB /* VelodySync */; };
E4627FCDF7EB169D83EA50DB /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = B99672FF5519DDF310A5EBD1 /* VelodyPersistence */; };
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; };
FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; };
FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; };
@ -54,11 +58,13 @@
6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModelFavoritesTests.swift; sourceTree = "<group>"; };
6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; };
7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VelodyMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModelPlaybackTests.swift; sourceTree = "<group>"; };
89FE56E825FC42D026EAC784 /* VelodyUtilities */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyUtilities; path = ../../packages/apple/VelodyUtilities; sourceTree = SOURCE_ROOT; };
96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VelodyMacApp.swift; sourceTree = "<group>"; };
AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryViewModel.swift; sourceTree = "<group>"; };
AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModel.swift; sourceTree = "<group>"; };
BF223F7E40D4B594935F5BC2 /* VelodyiPhone.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = VelodyiPhone.app; sourceTree = BUILT_PRODUCTS_DIR; };
D2446CD01A27662F88EA0F43 /* iPhoneLibraryViewModelTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModelTestSupport.swift; sourceTree = "<group>"; };
DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; sourceTree = "<group>"; };
DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryView.swift; sourceTree = "<group>"; };
F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VelodyiPhoneApp.swift; sourceTree = "<group>"; };
@ -85,9 +91,10 @@
files = (
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */,
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */,
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */,
AB6C7E42A3A850D395E4F5E7 /* VelodySync in Frameworks */,
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */,
D4B554447B262C7B946ED21F /* VelodyPlayback in Frameworks */,
AB6C7E42A3A850D395E4F5E7 /* VelodyPersistence in Frameworks */,
D0D65CE73B9DFF3C73F432DB /* VelodySync in Frameworks */,
A62771F49BF9AA1ABCF7961E /* VelodyUtilities in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -97,9 +104,10 @@
files = (
58E2E18AAC9318AB98F81004 /* VelodyDomain in Frameworks */,
A2DDB28F9916D0DFB1ADD4FF /* VelodyNetworking in Frameworks */,
AC8B414ECE5493BD52DEC44A /* VelodyPersistence in Frameworks */,
E4627FCDF7EB169D83EA50DB /* VelodySync in Frameworks */,
4F8B36EB80C008CDD9B2F6A6 /* VelodyUtilities in Frameworks */,
AC8B414ECE5493BD52DEC44A /* VelodyPlayback in Frameworks */,
E4627FCDF7EB169D83EA50DB /* VelodyPersistence in Frameworks */,
4F8B36EB80C008CDD9B2F6A6 /* VelodySync in Frameworks */,
2180184D8B90349397750F62 /* VelodyUtilities in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -110,6 +118,8 @@
isa = PBXGroup;
children = (
6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */,
7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */,
D2446CD01A27662F88EA0F43 /* iPhoneLibraryViewModelTestSupport.swift */,
);
name = Tests;
path = VelodyiPhone/Tests;
@ -216,6 +226,7 @@
packageProductDependencies = (
14CD56063C10911F40C9CBA3 /* VelodyDomain */,
A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */,
A9678775BC86EBB3155ECBDE /* VelodyPlayback */,
B99672FF5519DDF310A5EBD1 /* VelodyPersistence */,
A571882D6B46CA9F729D13CB /* VelodySync */,
C199F1B9F55E51FD373EA729 /* VelodyUtilities */,
@ -239,6 +250,7 @@
packageProductDependencies = (
48BF8F8596E7A86383A9CCD1 /* VelodyDomain */,
713279722B202FB1CF4A869E /* VelodyNetworking */,
BE8A38167B5A10BAECF48787 /* VelodyPlayback */,
C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */,
2449C403E81DD84D7A8DD7E1 /* VelodySync */,
B15F842ACBB110CC8A766669 /* VelodyUtilities */,
@ -301,6 +313,8 @@
buildActionMask = 2147483647;
files = (
DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */,
CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */,
A1FB43063B59B52B1C90A7A7 /* iPhoneLibraryViewModelTestSupport.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -651,6 +665,10 @@
isa = XCSwiftPackageProductDependency;
productName = VelodyNetworking;
};
A9678775BC86EBB3155ECBDE /* VelodyPlayback */ = {
isa = XCSwiftPackageProductDependency;
productName = VelodyPlayback;
};
AAD3F903A475AA0B0159C79E /* VelodyUtilities */ = {
isa = XCSwiftPackageProductDependency;
productName = VelodyUtilities;
@ -663,6 +681,10 @@
isa = XCSwiftPackageProductDependency;
productName = VelodyPersistence;
};
BE8A38167B5A10BAECF48787 /* VelodyPlayback */ = {
isa = XCSwiftPackageProductDependency;
productName = VelodyPlayback;
};
C199F1B9F55E51FD373EA729 /* VelodyUtilities */ = {
isa = XCSwiftPackageProductDependency;
productName = VelodyUtilities;

View File

@ -6,56 +6,232 @@ import UIKit
struct iPhoneLibraryView: View {
@State private var viewModel = iPhoneLibraryViewModel()
@State private var scrubbedPlaybackTime: Double?
var body: some View {
NavigationStack {
List {
if let currentTitle = viewModel.nowPlaying.title {
Section("Now Playing") {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(currentTitle)
.font(.headline)
if let artist = viewModel.nowPlaying.artist {
Text(artist)
.foregroundStyle(.secondary)
}
Text(viewModel.nowPlaying.isPlaying ? "Playing offline" : "Paused")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 20) {
titleSection
syncSection
searchSection
if let nowPlayingCard = viewModel.nowPlayingCard {
nowPlayingCardView(nowPlayingCard)
}
Spacer()
remoteLibrarySection
availableOfflineSection
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 24)
}
.background(Color(uiColor: .systemGroupedBackground))
.task {
await viewModel.loadIfNeeded()
}
}
if let trackID = viewModel.nowPlaying.trackID {
HStack(spacing: 12) {
if viewModel.nowPlayingFavoriteTrackID == trackID {
favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
private var titleSection: some View {
Text("Velody")
.font(.largeTitle.weight(.bold))
.frame(maxWidth: .infinity, alignment: .leading)
}
private var syncSection: some View {
VStack(alignment: .leading, spacing: 10) {
Button("Sync Remote Library") {
Task {
await viewModel.toggleFavorite(trackID: trackID)
await viewModel.refreshSync()
}
}
}
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
viewModel.togglePlayback(trackID: trackID)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.state == .loading)
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var searchSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Search Library")
.font(.headline)
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(
"Search Library",
text: Binding(
get: { viewModel.searchText },
set: { viewModel.searchText = $0 }
)
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear Search")
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
}
}
Section("Remote Library: \(viewModel.remoteTracks.count)") {
@ViewBuilder
private func nowPlayingCardView(_ card: NowPlayingCardViewData) -> some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text("Now Playing")
.font(.headline)
Text(card.playbackStateText)
.font(.subheadline)
.foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState))
}
Spacer(minLength: 12)
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
}
ArtworkCoverView(localFilePath: card.artworkLocalFilePath)
.frame(maxWidth: .infinity)
.frame(height: 220)
VStack(alignment: .leading, spacing: 6) {
Text(card.title)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(2)
Text(card.artist)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
VStack(spacing: 8) {
Slider(
value: Binding(
get: {
scrubbedPlaybackTime ?? card.currentTime
},
set: { newValue in
scrubbedPlaybackTime = newValue
}
),
in: 0...max(card.duration, 1),
onEditingChanged: { isEditing in
if !isEditing {
let targetTime = scrubbedPlaybackTime ?? card.currentTime
scrubbedPlaybackTime = nil
viewModel.seekPlayback(to: targetTime)
}
}
)
.disabled(!card.canSeek)
HStack {
Text(card.currentTimeText)
Spacer()
Text(card.durationText)
}
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
}
if let errorMessage = card.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
HStack(spacing: 12) {
if viewModel.nowPlayingFavoriteTrackID == card.trackID {
favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
Task {
await viewModel.toggleFavorite(trackID: card.trackID)
}
}
.frame(width: 48, height: 48)
.background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
Button {
viewModel.togglePlayback(trackID: card.trackID)
} label: {
HStack(spacing: 8) {
Image(systemName: card.isPlaying ? "pause.fill" : "play.fill")
Text(card.isPlaying ? "Pause" : "Play")
.lineLimit(1)
}
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.borderedProminent)
Button {
viewModel.stopPlayback()
} label: {
HStack(spacing: 8) {
Image(systemName: "stop.fill")
Text("Stop")
.lineLimit(1)
}
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.bordered)
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
.onChange(of: card.trackID) { _, _ in
scrubbedPlaybackTime = nil
}
}
@ViewBuilder
private var remoteLibrarySection: some View {
sectionCard(title: "Remote Library: \(viewModel.remoteTracks.count)") {
if viewModel.remoteTracks.isEmpty {
if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks {
Text("No matching tracks found.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
remoteLibraryEmptyState
}
} else {
ForEach(viewModel.remoteTracks) { track in
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in
if index > 0 {
Divider()
.padding(.vertical, 12)
}
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 12) {
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
@ -128,8 +304,56 @@ struct iPhoneLibraryView: View {
}
}
}
}
}
Section("Available Offline: \(viewModel.availableOfflineTracks.count)") {
@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)
} 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
@ -139,7 +363,13 @@ struct iPhoneLibraryView: View {
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.availableOfflineTracks) { track in
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.availableOfflineTracks.enumerated()), id: \.element.id) { index, track in
if index > 0 {
Divider()
.padding(.vertical, 12)
}
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 12) {
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
@ -186,82 +416,25 @@ struct iPhoneLibraryView: View {
}
}
}
.overlay {
overlayView
}
.searchable(
text: Binding(
get: { viewModel.searchText },
set: { viewModel.searchText = $0 }
),
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search Library"
)
.autocorrectionDisabled()
.navigationTitle("Velody")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sync Remote Library") {
Task {
await viewModel.refreshSync()
}
}
.disabled(viewModel.state == .loading)
}
}
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
if let playbackError = viewModel.nowPlaying.errorMessage {
Text(playbackError)
.font(.footnote)
.foregroundStyle(.red)
}
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
private func sectionCard<Content: View>(
title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 14) {
Text(title)
.font(.headline)
content()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial)
}
}
.task {
await viewModel.loadIfNeeded()
}
}
@ViewBuilder
private var overlayView: some View {
switch viewModel.state {
case .idle:
if !viewModel.hasCachedRemoteTracks {
ContentUnavailableView(
"No Remote Library Yet",
systemImage: "music.note.list",
description: Text("Tap Sync Remote Library to fetch metadata from the backend.")
.padding(18)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
}
case .loading:
ProgressView("Syncing remote library...")
case .success:
EmptyView()
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 {
ContentUnavailableView(
"Network Error",
systemImage: "wifi.exclamationmark",
description: Text(message)
)
}
}
}
private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color {
switch status {
@ -290,6 +463,82 @@ struct iPhoneLibraryView: View {
.buttonStyle(.borderless)
.accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite")
}
private func badgeColor(for badge: NowPlayingDownloadBadge) -> Color {
switch badge {
case .downloaded:
return .green
case .missing:
return .orange
case .offline:
return .blue
}
}
private func playbackStateColor(for state: iPhonePlaybackState) -> Color {
switch state {
case .playing:
return .green
case .paused, .stopped:
return .secondary
case .missingFile:
return .orange
case .failed:
return .red
}
}
private func statusBadge(title: String, color: Color) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(color.opacity(0.14), in: Capsule())
}
}
private struct ArtworkCoverView: View {
let localFilePath: String?
var body: some View {
Group {
if let artworkImage {
Image(uiImage: artworkImage)
.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
)
)
}
}
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
private var artworkImage: UIImage? {
guard let localFilePath, !localFilePath.isEmpty else {
return nil
}
return UIImage(contentsOfFile: localFilePath)
}
}
private struct ArtworkThumbnailView: View {
@ -302,21 +551,19 @@ private struct ArtworkThumbnailView: View {
.resizable()
.scaledToFill()
} else {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.gray.opacity(0.14))
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))
}
}
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.12), lineWidth: 1)
)
}
private var artworkImage: UIImage? {

View File

@ -2,11 +2,11 @@ import Foundation
import Observation
import VelodyDomain
import VelodyNetworking
import VelodyPlayback
import VelodyPersistence
import VelodySync
import VelodyUtilities
#if canImport(UIKit)
import AVFoundation
import UIKit
#endif
@ -15,152 +15,280 @@ protocol iPhoneLocalAudioPlaying: AnyObject {
var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set }
var state: iPhoneNowPlayingState { get }
func play(
trackID: String,
title: String,
artist: String,
fileURL: URL
) throws
func resume() throws
func setCatalogTracks(_ tracks: [LibraryTrack])
func play(trackID: String)
func pause()
func stop()
func seek(to time: Double)
}
enum iPhonePlaybackState: Equatable {
case playing
case paused
case stopped
case missingFile
case failed
var displayText: String {
switch self {
case .playing:
return "Playing"
case .paused:
return "Paused"
case .stopped:
return "Stopped"
case .missingFile:
return "Missing file"
case .failed:
return "Failed"
}
}
}
struct iPhoneNowPlayingState: Equatable {
var trackID: String?
var title: String?
var artist: String?
var isPlaying: Bool
var playbackState: iPhonePlaybackState
var currentTime: Double
var duration: Double
var errorMessage: String?
}
@MainActor
final class iPhoneLocalAudioPlayer: NSObject, iPhoneLocalAudioPlaying, AVAudioPlayerDelegate {
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
private(set) var state = iPhoneNowPlayingState(
static let empty = iPhoneNowPlayingState(
trackID: nil,
title: nil,
artist: nil,
isPlaying: false,
playbackState: .stopped,
currentTime: 0,
duration: 0,
errorMessage: nil
)
var isPlaying: Bool {
playbackState == .playing
}
var hasTrack: Bool {
trackID != nil
}
var canSeek: Bool {
hasTrack && duration > 0 && playbackState != .missingFile && playbackState != .failed
}
}
private struct StorediPhonePlaybackSession: Codable {
var currentTrackID: String?
var currentTime: Double
}
struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
private let userDefaults: UserDefaults
private let storageKey: String
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(
userDefaults: UserDefaults = .standard,
storageKey: String = "velody.iphone.playback.session"
) {
self.userDefaults = userDefaults
self.storageKey = storageKey
}
func loadSession() -> PlaybackSessionSnapshot? {
guard let data = userDefaults.data(forKey: storageKey),
let storedSession = try? decoder.decode(StorediPhonePlaybackSession.self, from: data)
else {
return nil
}
return PlaybackSessionSnapshot(
queueTrackIDs: [],
currentTrackID: storedSession.currentTrackID,
currentTime: storedSession.currentTime,
isShuffleEnabled: false,
repeatMode: .off
)
}
func saveSession(_ session: PlaybackSessionSnapshot) {
guard let currentTrackID = session.currentTrackID,
!currentTrackID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
clearSession()
return
}
let storedSession = StorediPhonePlaybackSession(
currentTrackID: currentTrackID,
currentTime: session.currentTime
)
guard let data = try? encoder.encode(storedSession) else {
return
}
userDefaults.set(data, forKey: storageKey)
}
func clearSession() {
userDefaults.removeObject(forKey: storageKey)
}
}
@MainActor
final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying {
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
private(set) var state: iPhoneNowPlayingState = .empty {
didSet {
onStateChange?(state)
}
}
private var audioPlayer: AVAudioPlayer?
private let controller: PlaybackController
private var idleStateHint: iPhonePlaybackState = .stopped
func play(
trackID: String,
title: String,
artist: String,
fileURL: URL
) throws {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: false,
errorMessage: "The downloaded file could not be found."
init(
controller: PlaybackController? = nil
) {
self.controller = controller ?? PlaybackController(
sessionStore: iPhonePlaybackSessionStore()
)
throw NSError(
domain: "VelodyiPhonePlayback",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "The downloaded file could not be found."]
self.state = Self.makeState(
from: self.controller.nowPlayingState,
idleStateHint: .stopped
)
}
do {
try configureAudioSession()
audioPlayer?.stop()
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
audioPlayer.delegate = self
audioPlayer.prepareToPlay()
self.audioPlayer = audioPlayer
guard audioPlayer.play() else {
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: false,
errorMessage: "Playback could not be started."
)
throw NSError(
domain: "VelodyiPhonePlayback",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Playback could not be started."]
)
}
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: true,
errorMessage: nil
)
} catch {
if state.trackID == nil {
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: false,
errorMessage: "The downloaded audio file could not be opened."
)
}
throw error
self.controller.onStateChange = { [weak self] state in
self?.apply(state)
}
}
func resume() throws {
guard let audioPlayer else {
state.errorMessage = "No downloaded track is loaded."
throw NSError(
domain: "VelodyiPhonePlayback",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "No downloaded track is loaded."]
)
func setCatalogTracks(_ tracks: [LibraryTrack]) {
controller.setCatalogTracks(tracks)
apply(controller.nowPlayingState)
}
guard audioPlayer.play() else {
state.errorMessage = "Playback could not be resumed."
throw NSError(
domain: "VelodyiPhonePlayback",
code: 4,
userInfo: [NSLocalizedDescriptionKey: "Playback could not be resumed."]
)
}
state.isPlaying = true
state.errorMessage = nil
func play(trackID: String) {
idleStateHint = .paused
controller.play(trackID: trackID)
apply(controller.nowPlayingState)
}
func pause() {
audioPlayer?.pause()
state.isPlaying = false
idleStateHint = .paused
controller.pause()
apply(controller.nowPlayingState)
}
nonisolated func audioPlayerDidFinishPlaying(
_ player: AVAudioPlayer,
successfully flag: Bool
) {
guard flag else {
return
func stop() {
idleStateHint = .stopped
controller.stop()
apply(controller.nowPlayingState)
}
Task { @MainActor [weak self] in
self?.state.isPlaying = false
}
func seek(to time: Double) {
controller.seek(to: time)
apply(controller.nowPlayingState)
}
private func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
private func apply(_ nowPlayingState: NowPlayingState) {
let effectiveDuration = max(
nowPlayingState.duration,
nowPlayingState.currentTrack?.durationSeconds ?? 0
)
if nowPlayingState.isPlaying {
idleStateHint = .paused
} else if nowPlayingState.currentTrack == nil {
idleStateHint = .stopped
} else if effectiveDuration > 0,
nowPlayingState.currentTime >= max(effectiveDuration - 0.25, 0) {
idleStateHint = .stopped
}
state = Self.makeState(
from: nowPlayingState,
idleStateHint: idleStateHint
)
}
private static func makeState(
from nowPlayingState: NowPlayingState,
idleStateHint: iPhonePlaybackState
) -> iPhoneNowPlayingState {
let effectiveDuration = max(
nowPlayingState.duration,
nowPlayingState.currentTrack?.durationSeconds ?? 0
)
let playbackState: iPhonePlaybackState
if let playbackError = nowPlayingState.error {
switch playbackError {
case .missingLocalFile:
playbackState = .missingFile
default:
playbackState = .failed
}
} else if nowPlayingState.isPlaying {
playbackState = .playing
} else if nowPlayingState.currentTrack != nil {
if nowPlayingState.currentTime > 0,
effectiveDuration > 0,
nowPlayingState.currentTime < effectiveDuration {
playbackState = .paused
} else {
playbackState = idleStateHint
}
} else {
playbackState = .stopped
}
return iPhoneNowPlayingState(
trackID: nowPlayingState.currentTrackID,
title: nowPlayingState.currentTrack?.title,
artist: nowPlayingState.currentTrack?.artist,
playbackState: playbackState,
currentTime: nowPlayingState.currentTime,
duration: effectiveDuration,
errorMessage: nowPlayingState.error?.localizedDescription
)
}
}
enum NowPlayingDownloadBadge: Equatable {
case downloaded
case missing
case offline
var title: String {
switch self {
case .downloaded:
return "Downloaded"
case .missing:
return "Missing"
case .offline:
return "Offline"
}
}
}
struct NowPlayingCardViewData: Equatable {
let trackID: String
let title: String
let artist: String
let artworkLocalFilePath: String?
let playbackStateText: String
let downloadBadge: NowPlayingDownloadBadge
let currentTime: Double
let duration: Double
let currentTimeText: String
let durationText: String
let progress: Double
let isPlaying: Bool
let canSeek: Bool
let errorMessage: String?
}
@MainActor
@ -187,13 +315,7 @@ final class iPhoneLibraryViewModel {
}
var syncStatus = "Remote library not synced yet."
var state: ViewState = .idle
var nowPlaying = iPhoneNowPlayingState(
trackID: nil,
title: nil,
artist: nil,
isPlaying: false,
errorMessage: nil
)
var nowPlaying: iPhoneNowPlayingState = .empty
var nowPlayingFavoriteTrackID: String?
var isNowPlayingTrackFavorite = false
@ -218,6 +340,60 @@ final class iPhoneLibraryViewModel {
!cachedRemoteLibraryTracks.isEmpty
}
var nowPlayingCard: NowPlayingCardViewData? {
guard let trackID = nowPlaying.trackID,
let title = nowPlaying.title
else {
return nil
}
let remoteTrack = cachedRemoteLibraryTracks.first(where: {
$0.remoteTrack.trackId == trackID
})
let fallbackOfflineTrack = cachedAvailableOfflineTracks.first(where: {
$0.remoteTrackId == trackID
})
let artist = nowPlaying.artist
?? remoteTrack?.remoteTrack.artist
?? fallbackOfflineTrack?.artist
?? "Unknown Artist"
let duration = max(
nowPlaying.duration,
Double(remoteTrack?.remoteTrack.durationSeconds ?? fallbackOfflineTrack?.durationSeconds ?? 0)
)
let clampedCurrentTime: Double
if duration > 0 {
clampedCurrentTime = min(max(nowPlaying.currentTime, 0), duration)
} else {
clampedCurrentTime = max(nowPlaying.currentTime, 0)
}
let progress: Double
if duration > 0 {
progress = min(max(clampedCurrentTime / duration, 0), 1)
} else {
progress = 0
}
return NowPlayingCardViewData(
trackID: trackID,
title: title,
artist: artist,
artworkLocalFilePath: remoteTrack?.localArtworkFilePath ?? fallbackOfflineTrack?.localArtworkFilePath,
playbackStateText: nowPlaying.playbackState.displayText,
downloadBadge: nowPlayingDownloadBadge(for: trackID),
currentTime: clampedCurrentTime,
duration: duration,
currentTimeText: Self.formatPlaybackTime(clampedCurrentTime),
durationText: Self.formatPlaybackTime(duration),
progress: progress,
isPlaying: nowPlaying.isPlaying,
canSeek: nowPlaying.canSeek && duration > 0,
errorMessage: nowPlaying.errorMessage
)
}
convenience init(
player: (any iPhoneLocalAudioPlaying)? = nil,
keychainService: any KeychainService = SystemKeychainService(
@ -256,7 +432,7 @@ final class iPhoneLibraryViewModel {
syncService: syncService,
offlineLibraryService: offlineLibraryService,
favoriteTrackStore: favoriteTrackStore,
player: player ?? iPhoneLocalAudioPlayer(),
player: player ?? iPhonePlaybackControllerPlayer(),
keychainService: keychainService
)
}
@ -277,6 +453,7 @@ final class iPhoneLibraryViewModel {
self.favoriteTrackStore = favoriteTrackStore
self.keychainService = keychainService
self.player = player
self.nowPlaying = player.state
self.player.onStateChange = { [weak self] state in
self?.handleNowPlayingStateChange(state)
}
@ -356,40 +533,25 @@ final class iPhoneLibraryViewModel {
return
}
if nowPlaying.trackID == track.trackId, !nowPlaying.isPlaying {
do {
try player.resume()
} catch {
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
refreshOfflineLibraryInBackground()
}
return
}
guard let offlineTrack = cachedAvailableOfflineTracks
.first(where: { $0.remoteTrackId == track.trackId })
else {
if cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == track.trackId })?.status == .missing {
guard canAttemptPlayback(for: track.trackId) else {
if remoteTrackStatus(for: track.trackId) == .missing {
syncStatus = "The downloaded file for \(track.title) is missing."
} else {
syncStatus = "Download the track before playing it offline."
}
refreshOfflineLibraryInBackground()
return
}
let fileURL = URL(fileURLWithPath: offlineTrack.localFilePath)
do {
try player.play(
trackID: track.trackId,
title: track.title,
artist: track.artist,
fileURL: fileURL
)
} catch {
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
refreshOfflineLibraryInBackground()
player.play(trackID: track.trackId)
handlePlaybackResult(for: track)
}
func stopPlayback() {
player.stop()
}
func seekPlayback(to time: Double) {
player.seek(to: time)
}
func toggleFavorite(trackID: String) async {
@ -521,6 +683,7 @@ final class iPhoneLibraryViewModel {
cachedRemoteTracksByID = Dictionary(
uniqueKeysWithValues: snapshot.remoteTracks.map { ($0.remoteTrack.trackId, $0.remoteTrack) }
)
player.setCatalogTracks(Self.makePlaybackCatalog(from: snapshot.remoteTracks))
rebuildRows()
return snapshot
@ -561,6 +724,45 @@ final class iPhoneLibraryViewModel {
rebuildRows()
}
private func handlePlaybackResult(for track: RemoteTrack) {
switch nowPlaying.playbackState {
case .missingFile:
syncStatus = "The downloaded file for \(track.title) is missing."
refreshOfflineLibraryInBackground()
case .failed:
syncStatus = "Playback failed for \(track.title): \(nowPlaying.errorMessage ?? "Unknown error.")"
case .playing, .paused, .stopped:
break
}
}
private func canAttemptPlayback(for trackID: String) -> Bool {
guard let remoteTrack = cachedRemoteLibraryTracks.first(where: {
$0.remoteTrack.trackId == trackID
}) else {
return false
}
return remoteTrack.isFileAvailable || remoteTrack.status == .missing
}
private func remoteTrackStatus(for trackID: String) -> OfflineLibraryRemoteTrackStatus? {
cachedRemoteLibraryTracks.first(where: {
$0.remoteTrack.trackId == trackID
})?.status
}
private func nowPlayingDownloadBadge(for trackID: String) -> NowPlayingDownloadBadge {
switch remoteTrackStatus(for: trackID) {
case .downloaded:
return .downloaded
case .missing:
return .missing
default:
return .offline
}
}
private func updateRemoteTrack(
trackID: String,
transform: (OfflineLibraryRemoteTrack) -> OfflineLibraryRemoteTrack
@ -588,6 +790,32 @@ final class iPhoneLibraryViewModel {
}
}
private static func makePlaybackCatalog(
from tracks: [OfflineLibraryRemoteTrack]
) -> [LibraryTrack] {
tracks.map { track in
LibraryTrack(
id: track.remoteTrack.trackId,
title: track.remoteTrack.title,
artist: track.remoteTrack.artist,
durationSeconds: Double(track.remoteTrack.durationSeconds),
localFilePath: track.localFilePath,
remoteTrackId: track.remoteTrack.trackId
)
}
}
private static func formatPlaybackTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else {
return "0:00"
}
let totalSeconds = Int(seconds.rounded(.towardZero))
let minutes = totalSeconds / 60
let remainingSeconds = totalSeconds % 60
return "\(minutes):\(String(format: "%02d", remainingSeconds))"
}
#if canImport(UIKit)
private static var currentDeviceName: String {
UIDevice.current.name

View File

@ -286,166 +286,3 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
}
}
@MainActor
private final class TestPlayer: iPhoneLocalAudioPlaying {
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
private(set) var state = iPhoneNowPlayingState(
trackID: nil,
title: nil,
artist: nil,
isPlaying: false,
errorMessage: nil
)
func play(
trackID: String,
title: String,
artist: String,
fileURL: URL
) throws {
_ = fileURL
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: true,
errorMessage: nil
)
onStateChange?(state)
}
func resume() throws {
state.isPlaying = true
state.errorMessage = nil
onStateChange?(state)
}
func pause() {
state.isPlaying = false
onStateChange?(state)
}
}
private actor TestRemoteLibraryRepository: RemoteLibraryRepository {
private let tracks: [RemoteTrack]
init(tracks: [RemoteTrack]) {
self.tracks = tracks
}
func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
tracks
}
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
_ = deviceId
return tracks
}
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data {
_ = assetId
_ = deviceId
throw TestRepositoryError.unexpectedDownload
}
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
_ = artworkId
_ = deviceId
throw TestRepositoryError.unexpectedDownload
}
}
private enum TestRepositoryError: Error {
case unexpectedDownload
}
@MainActor
private func makeViewModel(
remoteTracks: [RemoteTrack],
downloadStates: [RemoteTrackDownloadState] = [],
favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(),
audioFiles: [String: Data] = [:],
player: (any iPhoneLocalAudioPlaying)? = nil
) -> iPhoneLibraryViewModel {
let repository = TestRemoteLibraryRepository(tracks: remoteTracks)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates)
let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles)
let artworkStore = InMemoryArtworkStore()
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
return iPhoneLibraryViewModel(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
),
apiClient: URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
)
),
syncService: syncService,
offlineLibraryService: offlineLibraryService,
favoriteTrackStore: favoriteTrackStore,
player: player ?? TestPlayer(),
keychainService: MemoryKeychainService()
)
}
private func makeRemoteTrack(
trackId: String,
assetId: String,
title: String
) -> RemoteTrack {
RemoteTrack(
trackId: trackId,
title: title,
artist: "Velody Artist",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: assetId,
createdAt: "2026-05-30T08:00:00.000Z",
updatedAt: "2026-05-30T08:05:00.000Z"
)
}
private func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState {
RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: localFilePath(for: track),
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded
)
}
private func localFilePath(for track: RemoteTrack) -> String {
"/in-memory/\(track.assetId).mp3"
}
@MainActor
private func remoteRow(
in viewModel: iPhoneLibraryViewModel,
trackID: String
) -> RemoteTrackRowViewData? {
viewModel.remoteTracks.first(where: { $0.id == trackID })
}
@MainActor
private func offlineRow(
in viewModel: iPhoneLibraryViewModel,
trackID: String
) -> AvailableOfflineTrackRowViewData? {
viewModel.availableOfflineTracks.first(where: { $0.id == trackID })
}

View File

@ -0,0 +1,290 @@
import Foundation
import XCTest
import VelodyDomain
import VelodyPlayback
import VelodyPersistence
@testable import VelodyiPhone
@MainActor
final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
func testNowPlayingCardShowsTitleArtistAndArtwork() async throws {
let artwork = RemoteArtwork(
artworkId: "artwork-midnight",
sha256: String(repeating: "b", count: 64),
mimeType: "image/png"
)
let track = makeRemoteTrack(
trackId: "remote-midnight-city",
assetId: "asset-midnight-city",
title: "Midnight City",
artwork: artwork
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1, 0x2])],
artworkPayloadsByArtworkID: [artwork.artworkId: Data([0x9, 0x8])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
let card = try XCTUnwrap(viewModel.nowPlayingCard)
XCTAssertEqual(card.title, track.title)
XCTAssertEqual(card.artist, track.artist)
XCTAssertEqual(card.artworkLocalFilePath, localArtworkFilePath(for: artwork))
XCTAssertEqual(card.downloadBadge, .downloaded)
XCTAssertEqual(card.playbackStateText, "Playing")
}
func testMissingLocalFilePreventsPlayingAndDisablesSeek() async throws {
let track = makeRemoteTrack(
trackId: "remote-missing-file",
assetId: "asset-missing-file",
title: "Lost Signal"
)
let player = TestPlayer()
player.missingTrackIDs.insert(track.trackId)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
let card = try XCTUnwrap(viewModel.nowPlayingCard)
XCTAssertEqual(viewModel.nowPlaying.playbackState, .missingFile)
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
XCTAssertFalse(card.canSeek)
XCTAssertEqual(card.downloadBadge, .missing)
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))")
}
func testPauseStopsProgressUpdates() async throws {
let track = makeRemoteTrack(
trackId: "remote-pause",
assetId: "asset-pause",
title: "Pause Study"
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
player.advanceProgress(by: 12)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
viewModel.togglePlayback(trackID: track.trackId)
player.advanceProgress(by: 8)
XCTAssertEqual(viewModel.nowPlaying.playbackState, .paused)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
}
func testStopResetsProgressSafely() async throws {
let track = makeRemoteTrack(
trackId: "remote-stop",
assetId: "asset-stop",
title: "Stop Motion"
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
player.advanceProgress(by: 33)
viewModel.stopPlayback()
XCTAssertEqual(viewModel.nowPlaying.playbackState, .stopped)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 0)
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
}
func testSearchDoesNotClearNowPlaying() async throws {
let firstTrack = makeRemoteTrack(
trackId: "remote-first-search",
assetId: "asset-first-search",
title: "Blue Avenue"
)
let secondTrack = makeRemoteTrack(
trackId: "remote-second-search",
assetId: "asset-second-search",
title: "Golden Hour"
)
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 = secondTrack.title
let card = try XCTUnwrap(viewModel.nowPlayingCard)
XCTAssertEqual(card.trackID, firstTrack.trackId)
XCTAssertNil(remoteRow(in: viewModel, trackID: firstTrack.trackId))
XCTAssertEqual(viewModel.remoteTracks.count, 1)
}
func testPlaybackErrorDoesNotLeaveStateAsPlaying() async throws {
let track = makeRemoteTrack(
trackId: "remote-failure",
assetId: "asset-failure",
title: "Hard Stop"
)
let player = TestPlayer()
player.failingTrackIDs.insert(track.trackId)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
XCTAssertEqual(viewModel.nowPlaying.playbackState, .failed)
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
XCTAssertEqual(viewModel.nowPlaying.errorMessage, "Playback could not be started.")
}
func testSeekUpdatesCurrentTimeWhenTrackIsLoaded() async throws {
let track = makeRemoteTrack(
trackId: "remote-seek",
assetId: "asset-seek",
title: "Seeklight"
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
viewModel.seekPlayback(to: 90)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 90)
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
}
func testRelaunchRestoresMetadataButDoesNotAutoplay() {
let suiteName = "de.diyaa.velody.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let sessionStore = iPhonePlaybackSessionStore(
userDefaults: defaults,
storageKey: "playback"
)
let track = LibraryTrack(
id: "remote-restore",
title: "Restore Point",
artist: "Velody Artist",
durationSeconds: 245,
localFilePath: "/in-memory/asset-restore.mp3",
remoteTrackId: "remote-restore"
)
let firstEngine = FakePlaybackEngine()
firstEngine.durationByPath[track.localFilePath] = 245
let firstPlayer = iPhonePlaybackControllerPlayer(
controller: PlaybackController(
engine: firstEngine,
sessionStore: sessionStore
)
)
firstPlayer.setCatalogTracks([track])
firstPlayer.play(trackID: track.id)
firstEngine.currentTime = 48
firstPlayer.pause()
let storedSession = sessionStore.loadSession()
XCTAssertEqual(storedSession?.currentTrackID, track.id)
XCTAssertEqual(storedSession?.currentTime, 48)
XCTAssertEqual(storedSession?.queueTrackIDs, [])
let secondEngine = FakePlaybackEngine()
secondEngine.durationByPath[track.localFilePath] = 245
let relaunchedPlayer = iPhonePlaybackControllerPlayer(
controller: PlaybackController(
engine: secondEngine,
sessionStore: sessionStore
)
)
relaunchedPlayer.setCatalogTracks([track])
XCTAssertEqual(relaunchedPlayer.state.trackID, track.id)
XCTAssertEqual(relaunchedPlayer.state.title, track.title)
XCTAssertEqual(relaunchedPlayer.state.artist, track.artist)
XCTAssertEqual(relaunchedPlayer.state.currentTime, 48)
XCTAssertEqual(relaunchedPlayer.state.playbackState, .paused)
XCTAssertFalse(relaunchedPlayer.state.isPlaying)
XCTAssertNil(relaunchedPlayer.state.errorMessage)
}
}
@MainActor
private final class FakePlaybackEngine: PlaybackEngine {
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?
var currentTime: Double = 0
var duration: Double = 0
var isPlaying = false
var durationByPath: [String: Double] = [:]
func loadTrack(at fileURL: URL, startTime: Double) throws {
currentTime = startTime
duration = durationByPath[fileURL.path] ?? 0
isPlaying = false
}
func play() throws {
isPlaying = true
}
func pause() {
isPlaying = false
}
func stop() {
isPlaying = false
currentTime = 0
}
func seek(to time: Double) throws {
currentTime = min(max(time, 0), duration)
}
}

View File

@ -0,0 +1,347 @@
import Foundation
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
import VelodySync
import VelodyUtilities
@testable import VelodyiPhone
@MainActor
final class TestPlayer: iPhoneLocalAudioPlaying {
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
private(set) var state: iPhoneNowPlayingState = .empty
var failingTrackIDs = Set<String>()
var missingTrackIDs = Set<String>()
private var catalogTracksByID: [String: LibraryTrack] = [:]
func setCatalogTracks(_ tracks: [LibraryTrack]) {
catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
guard let trackID = state.trackID,
let currentTrack = catalogTracksByID[trackID]
else {
return
}
state = Self.makeState(
for: currentTrack,
playbackState: state.playbackState,
currentTime: state.currentTime,
errorMessage: state.errorMessage
)
onStateChange?(state)
}
func play(trackID: String) {
guard let track = catalogTracksByID[trackID] else {
return
}
let currentTime = state.trackID == trackID ? state.currentTime : 0
if missingTrackIDs.contains(trackID) ||
track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
state = Self.makeState(
for: track,
playbackState: .missingFile,
currentTime: currentTime,
errorMessage: "The local file could not be found: \(track.localFilePath)"
)
} else if failingTrackIDs.contains(trackID) {
state = Self.makeState(
for: track,
playbackState: .failed,
currentTime: currentTime,
errorMessage: "Playback could not be started."
)
} else {
state = Self.makeState(
for: track,
playbackState: .playing,
currentTime: currentTime,
errorMessage: nil
)
}
onStateChange?(state)
}
func pause() {
guard let currentTrack else {
return
}
state = Self.makeState(
for: currentTrack,
playbackState: .paused,
currentTime: state.currentTime,
errorMessage: nil
)
onStateChange?(state)
}
func stop() {
guard let currentTrack else {
state = .empty
onStateChange?(state)
return
}
state = Self.makeState(
for: currentTrack,
playbackState: .stopped,
currentTime: 0,
errorMessage: nil
)
onStateChange?(state)
}
func seek(to time: Double) {
guard let currentTrack else {
return
}
state = Self.makeState(
for: currentTrack,
playbackState: state.playbackState,
currentTime: time,
errorMessage: state.errorMessage
)
onStateChange?(state)
}
func advanceProgress(by timeDelta: Double) {
guard state.isPlaying,
let currentTrack
else {
return
}
let duration = max(currentTrack.durationSeconds ?? state.duration, 0)
let updatedTime = min(state.currentTime + timeDelta, duration)
let playbackState: iPhonePlaybackState
if duration > 0, updatedTime >= duration {
playbackState = .stopped
} else {
playbackState = .playing
}
state = Self.makeState(
for: currentTrack,
playbackState: playbackState,
currentTime: updatedTime,
errorMessage: nil
)
onStateChange?(state)
}
private var currentTrack: LibraryTrack? {
guard let trackID = state.trackID else {
return nil
}
return catalogTracksByID[trackID]
}
private static func makeState(
for track: LibraryTrack,
playbackState: iPhonePlaybackState,
currentTime: Double,
errorMessage: String?
) -> iPhoneNowPlayingState {
let duration = max(track.durationSeconds ?? 0, 0)
let clampedTime: Double
if duration > 0 {
clampedTime = min(max(currentTime, 0), duration)
} else {
clampedTime = max(currentTime, 0)
}
return iPhoneNowPlayingState(
trackID: track.id,
title: track.title,
artist: track.artist,
playbackState: playbackState,
currentTime: clampedTime,
duration: duration,
errorMessage: errorMessage
)
}
}
private actor TestRemoteLibraryRepository: RemoteLibraryRepository {
private let tracks: [RemoteTrack]
init(tracks: [RemoteTrack]) {
self.tracks = tracks
}
func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
tracks
}
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
_ = deviceId
return tracks
}
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data {
_ = assetId
_ = deviceId
throw TestRepositoryError.unexpectedDownload
}
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
_ = artworkId
_ = deviceId
throw TestRepositoryError.unexpectedDownload
}
}
private enum TestRepositoryError: Error {
case unexpectedDownload
}
@MainActor
func makeViewModel(
remoteTracks: [RemoteTrack],
downloadStates: [RemoteTrackDownloadState] = [],
favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(),
audioFiles: [String: Data] = [:],
artworkPayloadsByArtworkID: [String: Data] = [:],
player: (any iPhoneLocalAudioPlaying)? = nil
) -> iPhoneLibraryViewModel {
let repository = TestRemoteLibraryRepository(tracks: remoteTracks)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates)
let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles)
let artworkStore = InMemoryArtworkStore(
files: makeArtworkFiles(
from: remoteTracks,
artworkPayloadsByArtworkID: artworkPayloadsByArtworkID
)
)
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
return iPhoneLibraryViewModel(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
),
apiClient: URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
)
),
syncService: syncService,
offlineLibraryService: offlineLibraryService,
favoriteTrackStore: favoriteTrackStore,
player: player ?? TestPlayer(),
keychainService: MemoryKeychainService()
)
}
func makeRemoteTrack(
trackId: String,
assetId: String,
title: String,
artwork: RemoteArtwork? = nil
) -> RemoteTrack {
RemoteTrack(
trackId: trackId,
title: title,
artist: "Velody Artist",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: assetId,
createdAt: "2026-05-30T08:00:00.000Z",
updatedAt: "2026-05-30T08:05:00.000Z",
artwork: artwork
)
}
func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState {
RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: localFilePath(for: track),
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded
)
}
func localFilePath(for track: RemoteTrack) -> String {
"/in-memory/\(track.assetId).mp3"
}
func localArtworkFilePath(for artwork: RemoteArtwork) -> String {
let fileExtension: String
switch artwork.mimeType.lowercased() {
case "image/jpeg", "image/jpg":
fileExtension = "jpg"
case "image/png":
fileExtension = "png"
case "image/webp":
fileExtension = "webp"
case "image/heic":
fileExtension = "heic"
case "image/heif":
fileExtension = "heif"
case "image/gif":
fileExtension = "gif"
default:
fileExtension = "img"
}
return "/in-memory/\(artwork.artworkId).\(fileExtension)"
}
@MainActor
func remoteRow(
in viewModel: iPhoneLibraryViewModel,
trackID: String
) -> RemoteTrackRowViewData? {
viewModel.remoteTracks.first(where: { $0.id == trackID })
}
@MainActor
func offlineRow(
in viewModel: iPhoneLibraryViewModel,
trackID: String
) -> AvailableOfflineTrackRowViewData? {
viewModel.availableOfflineTracks.first(where: { $0.id == trackID })
}
private func makeArtworkFiles(
from remoteTracks: [RemoteTrack],
artworkPayloadsByArtworkID: [String: Data]
) -> [String: Data] {
var files: [String: Data] = [:]
for remoteTrack in remoteTracks {
guard let artwork = remoteTrack.artwork,
let data = artworkPayloadsByArtworkID[artwork.artworkId]
else {
continue
}
files[localArtworkFilePath(for: artwork)] = data
}
return files
}

View File

@ -60,6 +60,8 @@ targets:
product: VelodyDomain
- package: VelodyNetworking
product: VelodyNetworking
- package: VelodyPlayback
product: VelodyPlayback
- package: VelodyPersistence
product: VelodyPersistence
- package: VelodySync
@ -85,6 +87,8 @@ targets:
product: VelodyDomain
- package: VelodyNetworking
product: VelodyNetworking
- package: VelodyPlayback
product: VelodyPlayback
- package: VelodyPersistence
product: VelodyPersistence
- package: VelodySync