Implement iPhone now playing experience
This commit is contained in:
parent
3d7252ce6d
commit
a147e98b21
@ -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;
|
||||
|
||||
@ -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<Content: View>(
|
||||
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? {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user