From a147e98b210ed24fec9dcb2afaf596ce42df55ec Mon Sep 17 00:00:00 2001 From: diyaa Date: Mon, 1 Jun 2026 00:24:16 +0200 Subject: [PATCH] Implement iPhone now playing experience --- apps/apple/Velody.xcodeproj/project.pbxproj | 46 +- .../Sources/iPhoneLibraryView.swift | 699 ++++++++++++------ .../Sources/iPhoneLibraryViewModel.swift | 524 +++++++++---- ...iPhoneLibraryViewModelFavoritesTests.swift | 163 ---- .../iPhoneLibraryViewModelPlaybackTests.swift | 290 ++++++++ .../iPhoneLibraryViewModelTestSupport.swift | 347 +++++++++ apps/apple/project.yml | 4 + 7 files changed, 1524 insertions(+), 549 deletions(-) create mode 100644 apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift create mode 100644 apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift diff --git a/apps/apple/Velody.xcodeproj/project.pbxproj b/apps/apple/Velody.xcodeproj/project.pbxproj index a882e61..58221b3 100644 --- a/apps/apple/Velody.xcodeproj/project.pbxproj +++ b/apps/apple/Velody.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 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 = ""; }; AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryViewModel.swift; sourceTree = ""; }; AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModel.swift; sourceTree = ""; }; 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 = ""; }; DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; sourceTree = ""; }; DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryView.swift; sourceTree = ""; }; F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VelodyiPhoneApp.swift; sourceTree = ""; }; @@ -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; diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index 1af6028..9e668cc 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -6,246 +6,330 @@ 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 - Spacer() - - if let trackID = viewModel.nowPlaying.trackID { - HStack(spacing: 12) { - if viewModel.nowPlayingFavoriteTrackID == trackID { - favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { - Task { - await viewModel.toggleFavorite(trackID: trackID) - } - } - } - - Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") { - viewModel.togglePlayback(trackID: trackID) - } - .buttonStyle(.borderedProminent) - } - } - } - } + if let nowPlayingCard = viewModel.nowPlayingCard { + nowPlayingCardView(nowPlayingCard) } - Section("Remote Library: \(viewModel.remoteTracks.count)") { - if viewModel.remoteTracks.isEmpty { - if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks { - Text("No matching tracks found.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } else { - ForEach(viewModel.remoteTracks) { track in - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 12) { - ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath) - - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) - } - - Spacer() - - favoriteButton(isFavorite: track.isFavorite) { - Task { - await viewModel.toggleFavorite(trackID: track.id) - } - } - - Text(track.statusText) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(statusColor(for: track.status), in: Capsule()) - .foregroundStyle(.white) - } - } - } - - Text("Duration: \(track.durationText)") - .font(.subheadline) - .foregroundStyle(.secondary) - Text("Remote track ID: \(track.remoteTrackID)") - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - Text("Asset ID: \(track.assetID)") - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - - HStack { - Button(track.downloadButtonTitle) { - Task { - await viewModel.downloadTrack(trackID: track.id) - } - } - .buttonStyle(.bordered) - .disabled(!track.canDownload) - - if track.canPlay { - Button(track.playButtonTitle) { - viewModel.togglePlayback(trackID: track.id) - } - .buttonStyle(.borderedProminent) - } - } - - if let error = track.lastDownloadError, - (track.status == .failed || track.status == .missing) - { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } - .padding(.vertical, 4) - } - } - } - - Section("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 { - ForEach(viewModel.availableOfflineTracks) { track in - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 12) { - ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath) - - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) - } - - Spacer() - - favoriteButton(isFavorite: track.isFavorite) { - Task { - await viewModel.toggleFavorite(trackID: track.id) - } - } - - Button(track.playButtonTitle) { - viewModel.togglePlayback(trackID: track.id) - } - .buttonStyle(.borderedProminent) - } - } - } - - Text("Duration: \(track.durationText)") - .font(.subheadline) - .foregroundStyle(.secondary) - Text("Remote track ID: \(track.remoteTrackID)") - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - Text("Asset ID: \(track.assetID)") - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - .padding(.vertical, 4) - } - } - } - } - .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) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(.ultraThinMaterial) + remoteLibrarySection + availableOfflineSection } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 24) } + .background(Color(uiColor: .systemGroupedBackground)) .task { await viewModel.loadIfNeeded() } } + 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.refreshSync() + } + } + .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)) + ) + } + } + @ViewBuilder - private var overlayView: some View { + 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 { + 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) + + 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) + } + } + } + } + } + + @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 { + 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 .loading: - ProgressView("Syncing remote library...") case .success: - EmptyView() + Text("Remote tracks will appear here after syncing.") + .font(.subheadline) + .foregroundStyle(.secondary) case .empty: ContentUnavailableView( "Empty Remote Library", @@ -253,7 +337,11 @@ struct iPhoneLibraryView: View { description: Text("The backend returned no remote tracks for this iPhone.") ) case .networkError(let message): - if !viewModel.hasCachedRemoteTracks { + if viewModel.hasCachedRemoteTracks { + Text("Remote tracks will appear here after syncing.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { ContentUnavailableView( "Network Error", systemImage: "wifi.exclamationmark", @@ -263,6 +351,91 @@ struct iPhoneLibraryView: View { } } + @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 + if index > 0 { + Divider() + .padding(.vertical, 12) + } + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(.headline) + Text(track.artist) + .foregroundStyle(.secondary) + } + + Spacer() + + favoriteButton(isFavorite: track.isFavorite) { + Task { + await viewModel.toggleFavorite(trackID: track.id) + } + } + + Button(track.playButtonTitle) { + viewModel.togglePlayback(trackID: track.id) + } + .buttonStyle(.borderedProminent) + } + } + } + + Text("Duration: \(track.durationText)") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Remote track ID: \(track.remoteTrackID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + Text("Asset ID: \(track.assetID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + .padding(.vertical, 4) + } + } + } + } + } + + private func sectionCard( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 14) { + Text(title) + .font(.headline) + + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + } + private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color { switch status { case .notDownloaded: @@ -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? { diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 8defbf9..250eaad 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -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,154 +15,282 @@ 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." - ) - throw NSError( - domain: "VelodyiPhonePlayback", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "The downloaded file could not be found."] - ) - } - - 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 + init( + controller: PlaybackController? = nil + ) { + self.controller = controller ?? PlaybackController( + sessionStore: iPhonePlaybackSessionStore() + ) + self.state = Self.makeState( + from: self.controller.nowPlayingState, + idleStateHint: .stopped + ) + 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) + } + + func seek(to time: Double) { + controller.seek(to: time) + apply(controller.nowPlayingState) + } + + 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 } - Task { @MainActor [weak self] in - self?.state.isPlaying = false - } + state = Self.makeState( + from: nowPlayingState, + idleStateHint: idleStateHint + ) } - private func configureAudioSession() throws { - let session = AVAudioSession.sharedInstance() - try session.setCategory(.playback, mode: .default) - try session.setActive(true) + 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 @Observable final class iPhoneLibraryViewModel { @@ -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 diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift index e615936..05e0cbe 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift @@ -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 }) -} diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift new file mode 100644 index 0000000..366f14c --- /dev/null +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift @@ -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) + } +} diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift new file mode 100644 index 0000000..f7947af --- /dev/null +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift @@ -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() + var missingTrackIDs = Set() + + 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 +} diff --git a/apps/apple/project.yml b/apps/apple/project.yml index 52d4248..3d1fcb2 100644 --- a/apps/apple/project.yml +++ b/apps/apple/project.yml @@ -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