Add macOS local playback engine
This commit is contained in:
parent
183d5db4a9
commit
799c07b068
@ -9,14 +9,14 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0AF7078E8BD078ECE18B6C0A /* VelodyiPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */; };
|
0AF7078E8BD078ECE18B6C0A /* VelodyiPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */; };
|
||||||
151772779307EFC3B3A17477 /* MacLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */; };
|
151772779307EFC3B3A17477 /* MacLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */; };
|
||||||
2E9DD262BF65832378F37DD4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
|
2E9DD262BF65832378F37DD4 /* VelodyPlayback in Frameworks */ = {isa = PBXBuildFile; productRef = EA45DE5B08D71D666009BB5E /* VelodyPlayback */; };
|
||||||
2F9E426A66F4887C301AB13C /* FolderAccessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */; };
|
2F9E426A66F4887C301AB13C /* FolderAccessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */; };
|
||||||
3B9765B34963F430467B7527 /* LocalMusicScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */; };
|
3B9765B34963F430467B7527 /* LocalMusicScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */; };
|
||||||
3D22DE55C4A27A4DE68E6359 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; };
|
3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
|
||||||
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; };
|
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; };
|
||||||
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; };
|
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; };
|
||||||
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; };
|
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; };
|
||||||
783EBF82108F7A04B6DD33B4 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
|
783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
|
||||||
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; };
|
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; };
|
||||||
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; };
|
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; };
|
||||||
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
|
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
|
||||||
@ -24,12 +24,14 @@
|
|||||||
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
||||||
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
|
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
|
||||||
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; };
|
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 */; };
|
FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; };
|
07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; };
|
||||||
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; };
|
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; };
|
||||||
|
15A17C02F8CBB0A492A82C14 /* VelodyPlayback */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPlayback; path = ../../packages/apple/VelodyPlayback; sourceTree = SOURCE_ROOT; };
|
||||||
3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMusicScanner.swift; sourceTree = "<group>"; };
|
3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMusicScanner.swift; sourceTree = "<group>"; };
|
||||||
5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = "<group>"; };
|
5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = "<group>"; };
|
||||||
6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; };
|
6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; };
|
||||||
@ -52,9 +54,10 @@
|
|||||||
files = (
|
files = (
|
||||||
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */,
|
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */,
|
||||||
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */,
|
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */,
|
||||||
2E9DD262BF65832378F37DD4 /* VelodyPersistence in Frameworks */,
|
2E9DD262BF65832378F37DD4 /* VelodyPlayback in Frameworks */,
|
||||||
783EBF82108F7A04B6DD33B4 /* VelodySync in Frameworks */,
|
783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */,
|
||||||
3D22DE55C4A27A4DE68E6359 /* VelodyUtilities in Frameworks */,
|
3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */,
|
||||||
|
FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -90,6 +93,7 @@
|
|||||||
F7EF830CE1ABB841E624A660 /* VelodyDomain */,
|
F7EF830CE1ABB841E624A660 /* VelodyDomain */,
|
||||||
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */,
|
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */,
|
||||||
6E7BCB85A35B8286E4472822 /* VelodyPersistence */,
|
6E7BCB85A35B8286E4472822 /* VelodyPersistence */,
|
||||||
|
15A17C02F8CBB0A492A82C14 /* VelodyPlayback */,
|
||||||
07508485E10C6E2942FE29AB /* VelodySync */,
|
07508485E10C6E2942FE29AB /* VelodySync */,
|
||||||
89FE56E825FC42D026EAC784 /* VelodyUtilities */,
|
89FE56E825FC42D026EAC784 /* VelodyUtilities */,
|
||||||
);
|
);
|
||||||
@ -146,6 +150,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
3C910108376E6ECEF152DCE1 /* VelodyDomain */,
|
3C910108376E6ECEF152DCE1 /* VelodyDomain */,
|
||||||
0682A261A6F2F050F4B83AF6 /* VelodyNetworking */,
|
0682A261A6F2F050F4B83AF6 /* VelodyNetworking */,
|
||||||
|
EA45DE5B08D71D666009BB5E /* VelodyPlayback */,
|
||||||
4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */,
|
4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */,
|
||||||
DA5EBADF45BC0977F73F241C /* VelodySync */,
|
DA5EBADF45BC0977F73F241C /* VelodySync */,
|
||||||
AAD3F903A475AA0B0159C79E /* VelodyUtilities */,
|
AAD3F903A475AA0B0159C79E /* VelodyUtilities */,
|
||||||
@ -200,6 +205,7 @@
|
|||||||
43AF8C7D2C6F46AB2748AAD9 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyDomain" */,
|
43AF8C7D2C6F46AB2748AAD9 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyDomain" */,
|
||||||
5DC2C05FD0EC7BD980C0BF69 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyNetworking" */,
|
5DC2C05FD0EC7BD980C0BF69 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyNetworking" */,
|
||||||
E9AE6C97FDD54CE81E584DDC /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPersistence" */,
|
E9AE6C97FDD54CE81E584DDC /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPersistence" */,
|
||||||
|
2FCEB9E0A092D6C2F4B66645 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPlayback" */,
|
||||||
60A72563C2E87FC2BA78729A /* XCLocalSwiftPackageReference "../../packages/apple/VelodySync" */,
|
60A72563C2E87FC2BA78729A /* XCLocalSwiftPackageReference "../../packages/apple/VelodySync" */,
|
||||||
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */,
|
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */,
|
||||||
);
|
);
|
||||||
@ -457,6 +463,10 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
2FCEB9E0A092D6C2F4B66645 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPlayback" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../../packages/apple/VelodyPlayback;
|
||||||
|
};
|
||||||
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */ = {
|
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = ../../packages/apple/VelodyUtilities;
|
relativePath = ../../packages/apple/VelodyUtilities;
|
||||||
@ -520,6 +530,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = VelodySync;
|
productName = VelodySync;
|
||||||
};
|
};
|
||||||
|
EA45DE5B08D71D666009BB5E /* VelodyPlayback */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyPlayback;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = D14070EEF27F35DD4F5F547B /* Project object */;
|
rootObject = D14070EEF27F35DD4F5F547B /* Project object */;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import VelodyDomain
|
|||||||
|
|
||||||
struct MacLibraryView: View {
|
struct MacLibraryView: View {
|
||||||
@State private var viewModel = MacLibraryViewModel()
|
@State private var viewModel = MacLibraryViewModel()
|
||||||
|
@State private var scrubbedPlaybackTime: Double?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
@ -94,31 +95,54 @@ struct MacLibraryView: View {
|
|||||||
Text(viewModel.scanStatus)
|
Text(viewModel.scanStatus)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
List(viewModel.tracks) { track in
|
List {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
ForEach(viewModel.tracks, id: \.id) { track in
|
||||||
Text(track.title)
|
HStack(alignment: .top, spacing: 12) {
|
||||||
.font(.headline)
|
Button {
|
||||||
Text(track.artist)
|
viewModel.togglePlayback(for: track)
|
||||||
.foregroundStyle(.secondary)
|
} label: {
|
||||||
|
Image(systemName: viewModel.playbackButtonSymbol(for: track))
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
|
||||||
if let album = track.album {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(album)
|
Text(track.title)
|
||||||
.font(.caption)
|
.font(.headline)
|
||||||
.foregroundStyle(.secondary)
|
Text(track.artist)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let album = track.album {
|
||||||
|
Text(album)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let durationSeconds = track.durationSeconds {
|
||||||
|
Text("Duration: \(format(durationSeconds: durationSeconds))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(track.localFilePath)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
|
if viewModel.isCurrentTrack(track) {
|
||||||
|
Text(viewModel.nowPlayingState.isPlaying ? "Playing" : "Paused")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.quaternary, in: Capsule())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
if let durationSeconds = track.durationSeconds {
|
|
||||||
Text("Duration: \(format(durationSeconds: durationSeconds))")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(track.localFilePath)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if viewModel.tracks.isEmpty && !viewModel.isScanning {
|
if viewModel.tracks.isEmpty && !viewModel.isScanning {
|
||||||
@ -130,7 +154,7 @@ struct MacLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
miniPlayer
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
.task {
|
.task {
|
||||||
@ -145,6 +169,133 @@ struct MacLibraryView: View {
|
|||||||
return String(format: "%d:%02d", minutes, seconds)
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var displayedPlaybackTime: Double {
|
||||||
|
scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
private var playbackDuration: Double {
|
||||||
|
max(
|
||||||
|
viewModel.nowPlayingState.duration,
|
||||||
|
viewModel.nowPlayingState.currentTrack?.durationSeconds ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var miniPlayer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(viewModel.nowPlayingState.currentTrack?.title ?? "No track selected")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
viewModel.nowPlayingState.currentTrack?.artist
|
||||||
|
?? "Select a scanned MP3 to begin local playback."
|
||||||
|
)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 16)
|
||||||
|
|
||||||
|
if let playbackErrorMessage = viewModel.playbackErrorMessage {
|
||||||
|
Text(playbackErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
viewModel.toggleShuffle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "shuffle")
|
||||||
|
}
|
||||||
|
.foregroundStyle(
|
||||||
|
viewModel.nowPlayingState.isShuffleEnabled
|
||||||
|
? Color.accentColor
|
||||||
|
: Color.secondary
|
||||||
|
)
|
||||||
|
.disabled(viewModel.tracks.isEmpty)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.playPreviousTrack()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "backward.fill")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.tracks.isEmpty)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.togglePlayPause()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.nowPlayingState.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(viewModel.tracks.isEmpty)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.stopPlayback()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.nowPlayingState.currentTrack == nil)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.playNextTrack()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "forward.fill")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.tracks.isEmpty)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.cycleRepeatMode()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.repeatButtonSymbol)
|
||||||
|
}
|
||||||
|
.foregroundStyle(
|
||||||
|
viewModel.nowPlayingState.repeatMode == .off
|
||||||
|
? .secondary
|
||||||
|
: Color.accentColor
|
||||||
|
)
|
||||||
|
.disabled(viewModel.tracks.isEmpty)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(format(durationSeconds: displayedPlaybackTime))
|
||||||
|
.font(.caption)
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: {
|
||||||
|
displayedPlaybackTime
|
||||||
|
},
|
||||||
|
set: { newValue in
|
||||||
|
scrubbedPlaybackTime = newValue
|
||||||
|
}
|
||||||
|
),
|
||||||
|
in: 0...max(playbackDuration, 1),
|
||||||
|
onEditingChanged: { isEditing in
|
||||||
|
if !isEditing {
|
||||||
|
let targetTime = scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
||||||
|
scrubbedPlaybackTime = nil
|
||||||
|
viewModel.seekPlayback(to: targetTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.disabled(viewModel.nowPlayingState.currentTrack == nil || playbackDuration <= 0)
|
||||||
|
|
||||||
|
Text(format(durationSeconds: playbackDuration))
|
||||||
|
.font(.caption)
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func statusRow(title: String, value: String) -> some View {
|
private func statusRow(title: String, value: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
import VelodyNetworking
|
import VelodyNetworking
|
||||||
|
import VelodyPlayback
|
||||||
import VelodyPersistence
|
import VelodyPersistence
|
||||||
import VelodyUtilities
|
import VelodyUtilities
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ final class MacLibraryViewModel {
|
|||||||
var scanStatus = "Choose a folder to begin local discovery."
|
var scanStatus = "Choose a folder to begin local discovery."
|
||||||
var discoveredTrackCount = 0
|
var discoveredTrackCount = 0
|
||||||
var isScanning = false
|
var isScanning = false
|
||||||
|
var nowPlayingState = NowPlayingState()
|
||||||
|
|
||||||
var serverURLString: String
|
var serverURLString: String
|
||||||
var deviceRegistrationStatus = "Not registered."
|
var deviceRegistrationStatus = "Not registered."
|
||||||
@ -28,6 +30,7 @@ final class MacLibraryViewModel {
|
|||||||
private let folderAccessService: any VelodyPersistence.FolderAccessService
|
private let folderAccessService: any VelodyPersistence.FolderAccessService
|
||||||
private let catalogService: any LocalCatalogService
|
private let catalogService: any LocalCatalogService
|
||||||
private let localMusicScanner: any LocalMusicScanner
|
private let localMusicScanner: any LocalMusicScanner
|
||||||
|
private let playbackController: PlaybackController
|
||||||
private let keychainService: any KeychainService
|
private let keychainService: any KeychainService
|
||||||
private let userDefaults: UserDefaults
|
private let userDefaults: UserDefaults
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
@ -41,14 +44,26 @@ final class MacLibraryViewModel {
|
|||||||
metadataReader: AVFoundationMetadataReader()
|
metadataReader: AVFoundationMetadataReader()
|
||||||
)
|
)
|
||||||
let repository = Self.makeTrackRepository()
|
let repository = Self.makeTrackRepository()
|
||||||
|
let playbackController = PlaybackController(
|
||||||
|
sessionStore: UserDefaultsPlaybackSessionStore(
|
||||||
|
userDefaults: userDefaults,
|
||||||
|
storageKey: Self.playbackSessionDefaultsKey
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.folderAccessService = folderAccessService
|
self.folderAccessService = folderAccessService
|
||||||
self.catalogService = DefaultLocalCatalogService(repository: repository)
|
self.catalogService = DefaultLocalCatalogService(repository: repository)
|
||||||
self.localMusicScanner = localMusicScanner
|
self.localMusicScanner = localMusicScanner
|
||||||
|
self.playbackController = playbackController
|
||||||
self.keychainService = keychainService
|
self.keychainService = keychainService
|
||||||
self.userDefaults = userDefaults
|
self.userDefaults = userDefaults
|
||||||
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
|
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
|
||||||
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
|
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
|
||||||
|
self.nowPlayingState = playbackController.nowPlayingState
|
||||||
|
|
||||||
|
playbackController.onStateChange = { [weak self] state in
|
||||||
|
self?.nowPlayingState = state
|
||||||
|
}
|
||||||
|
|
||||||
if let url = folderAccessService.storedFolderURL() {
|
if let url = folderAccessService.storedFolderURL() {
|
||||||
selectedFolderPath = url.path
|
selectedFolderPath = url.path
|
||||||
@ -66,6 +81,7 @@ final class MacLibraryViewModel {
|
|||||||
scanStatus = "Failed to load saved catalog: \(error.localizedDescription)"
|
scanStatus = "Failed to load saved catalog: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playbackController.setCatalogTracks(tracks)
|
||||||
await restoreDeviceIdentity()
|
await restoreDeviceIdentity()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +91,7 @@ final class MacLibraryViewModel {
|
|||||||
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
|
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
|
||||||
tracks = []
|
tracks = []
|
||||||
discoveredTrackCount = 0
|
discoveredTrackCount = 0
|
||||||
|
playbackController.setCatalogTracks([])
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await catalogService.resetLocalCatalog()
|
try await catalogService.resetLocalCatalog()
|
||||||
@ -106,6 +123,7 @@ final class MacLibraryViewModel {
|
|||||||
)
|
)
|
||||||
tracks = scanResult.tracks
|
tracks = scanResult.tracks
|
||||||
discoveredTrackCount = tracks.count
|
discoveredTrackCount = tracks.count
|
||||||
|
playbackController.setCatalogTracks(tracks)
|
||||||
scanStatus = Self.scanStatus(
|
scanStatus = Self.scanStatus(
|
||||||
for: scanResult,
|
for: scanResult,
|
||||||
activeTrackCount: discoveredTrackCount
|
activeTrackCount: discoveredTrackCount
|
||||||
@ -115,6 +133,75 @@ final class MacLibraryViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func togglePlayback(for track: LibraryTrack) {
|
||||||
|
if nowPlayingState.currentTrackID == track.id {
|
||||||
|
playbackController.playPause()
|
||||||
|
} else {
|
||||||
|
playbackController.play(trackID: track.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayPause() {
|
||||||
|
playbackController.playPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPlayback() {
|
||||||
|
playbackController.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekPlayback(to time: Double) {
|
||||||
|
playbackController.seek(to: time)
|
||||||
|
}
|
||||||
|
|
||||||
|
func playNextTrack() {
|
||||||
|
playbackController.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func playPreviousTrack() {
|
||||||
|
playbackController.previous()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleShuffle() {
|
||||||
|
playbackController.toggleShuffle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleRepeatMode() {
|
||||||
|
playbackController.cycleRepeatMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrentTrack(_ track: LibraryTrack) -> Bool {
|
||||||
|
nowPlayingState.currentTrackID == track.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlaying(_ track: LibraryTrack) -> Bool {
|
||||||
|
isCurrentTrack(track) && nowPlayingState.isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
func playbackButtonSymbol(for track: LibraryTrack) -> String {
|
||||||
|
if isPlaying(track) {
|
||||||
|
return "pause.circle.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCurrentTrack(track) {
|
||||||
|
return "play.circle.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "play.circle"
|
||||||
|
}
|
||||||
|
|
||||||
|
var repeatButtonSymbol: String {
|
||||||
|
switch nowPlayingState.repeatMode {
|
||||||
|
case .off, .all:
|
||||||
|
return "repeat"
|
||||||
|
case .one:
|
||||||
|
return "repeat.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var playbackErrorMessage: String? {
|
||||||
|
nowPlayingState.error?.errorDescription
|
||||||
|
}
|
||||||
|
|
||||||
func persistServerURLSelection() {
|
func persistServerURLSelection() {
|
||||||
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
|
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
|
||||||
return
|
return
|
||||||
@ -310,6 +397,7 @@ final class MacLibraryViewModel {
|
|||||||
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
|
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
|
||||||
private static let deviceIdKey = "velody.device-id"
|
private static let deviceIdKey = "velody.device-id"
|
||||||
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
||||||
|
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum BackendConnectionError: LocalizedError {
|
private enum BackendConnectionError: LocalizedError {
|
||||||
|
|||||||
@ -10,6 +10,8 @@ packages:
|
|||||||
path: ../../packages/apple/VelodyDomain
|
path: ../../packages/apple/VelodyDomain
|
||||||
VelodyNetworking:
|
VelodyNetworking:
|
||||||
path: ../../packages/apple/VelodyNetworking
|
path: ../../packages/apple/VelodyNetworking
|
||||||
|
VelodyPlayback:
|
||||||
|
path: ../../packages/apple/VelodyPlayback
|
||||||
VelodyPersistence:
|
VelodyPersistence:
|
||||||
path: ../../packages/apple/VelodyPersistence
|
path: ../../packages/apple/VelodyPersistence
|
||||||
VelodySync:
|
VelodySync:
|
||||||
@ -34,6 +36,8 @@ targets:
|
|||||||
product: VelodyDomain
|
product: VelodyDomain
|
||||||
- package: VelodyNetworking
|
- package: VelodyNetworking
|
||||||
product: VelodyNetworking
|
product: VelodyNetworking
|
||||||
|
- package: VelodyPlayback
|
||||||
|
product: VelodyPlayback
|
||||||
- package: VelodyPersistence
|
- package: VelodyPersistence
|
||||||
product: VelodyPersistence
|
product: VelodyPersistence
|
||||||
- package: VelodySync
|
- package: VelodySync
|
||||||
|
|||||||
32
packages/apple/VelodyPlayback/Package.swift
Normal file
32
packages/apple/VelodyPlayback/Package.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "VelodyPlayback",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v17),
|
||||||
|
.macOS(.v14),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "VelodyPlayback",
|
||||||
|
targets: ["VelodyPlayback"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../VelodyDomain"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "VelodyPlayback",
|
||||||
|
dependencies: ["VelodyDomain"]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "VelodyPlaybackTests",
|
||||||
|
dependencies: [
|
||||||
|
"VelodyDomain",
|
||||||
|
"VelodyPlayback",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import VelodyDomain
|
||||||
|
|
||||||
|
public struct NowPlayingState: Hashable, Sendable {
|
||||||
|
public var currentTrack: LibraryTrack?
|
||||||
|
public var queueTrackIDs: [String]
|
||||||
|
public var isPlaying: Bool
|
||||||
|
public var currentTime: Double
|
||||||
|
public var duration: Double
|
||||||
|
public var isShuffleEnabled: Bool
|
||||||
|
public var repeatMode: PlaybackRepeatMode
|
||||||
|
public var error: PlaybackError?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
currentTrack: LibraryTrack? = nil,
|
||||||
|
queueTrackIDs: [String] = [],
|
||||||
|
isPlaying: Bool = false,
|
||||||
|
currentTime: Double = 0,
|
||||||
|
duration: Double = 0,
|
||||||
|
isShuffleEnabled: Bool = false,
|
||||||
|
repeatMode: PlaybackRepeatMode = .off,
|
||||||
|
error: PlaybackError? = nil
|
||||||
|
) {
|
||||||
|
self.currentTrack = currentTrack
|
||||||
|
self.queueTrackIDs = queueTrackIDs
|
||||||
|
self.isPlaying = isPlaying
|
||||||
|
self.currentTime = currentTime
|
||||||
|
self.duration = duration
|
||||||
|
self.isShuffleEnabled = isShuffleEnabled
|
||||||
|
self.repeatMode = repeatMode
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
public var currentTrackID: String? {
|
||||||
|
currentTrack?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
public var progress: Double {
|
||||||
|
guard duration > 0 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return min(max(currentTime / duration, 0), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,430 @@
|
|||||||
|
import Foundation
|
||||||
|
import VelodyDomain
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class PlaybackController {
|
||||||
|
public var onStateChange: (@MainActor (NowPlayingState) -> Void)?
|
||||||
|
|
||||||
|
public private(set) var nowPlayingState = NowPlayingState()
|
||||||
|
|
||||||
|
private let engine: any PlaybackEngine
|
||||||
|
private let sessionStore: any PlaybackSessionStore
|
||||||
|
private var queue = PlaybackQueue()
|
||||||
|
private var catalogTracksByID: [String: LibraryTrack] = [:]
|
||||||
|
private var hasRestoredSession = false
|
||||||
|
private var loadedTrackID: String?
|
||||||
|
private var progressTimer: Timer?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
engine: any PlaybackEngine,
|
||||||
|
sessionStore: any PlaybackSessionStore = UserDefaultsPlaybackSessionStore()
|
||||||
|
) {
|
||||||
|
self.engine = engine
|
||||||
|
self.sessionStore = sessionStore
|
||||||
|
|
||||||
|
self.engine.onEvent = { [weak self] event in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .finishedPlaying:
|
||||||
|
self.handlePlaybackFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(
|
||||||
|
sessionStore: any PlaybackSessionStore = UserDefaultsPlaybackSessionStore()
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
engine: AVFoundationPlaybackEngine(),
|
||||||
|
sessionStore: sessionStore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setCatalogTracks(_ tracks: [LibraryTrack]) {
|
||||||
|
catalogTracksByID = Dictionary(
|
||||||
|
uniqueKeysWithValues: tracks.map { ($0.id, $0) }
|
||||||
|
)
|
||||||
|
|
||||||
|
let catalogTrackIDs = tracks.map(\.id)
|
||||||
|
|
||||||
|
if !hasRestoredSession {
|
||||||
|
let session = sessionStore.loadSession()
|
||||||
|
queue = PlaybackQueue(
|
||||||
|
trackIDs: catalogTrackIDs,
|
||||||
|
currentTrackID: session?.currentTrackID,
|
||||||
|
queuedTrackIDs: session?.queueTrackIDs,
|
||||||
|
isShuffleEnabled: session?.isShuffleEnabled ?? false,
|
||||||
|
repeatMode: session?.repeatMode ?? .off
|
||||||
|
)
|
||||||
|
hasRestoredSession = true
|
||||||
|
syncStateFromQueue()
|
||||||
|
|
||||||
|
if let currentTrack = currentTrack {
|
||||||
|
restoreTrack(currentTrack, position: session?.currentTime ?? 0)
|
||||||
|
} else {
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.replaceTrackIDs(
|
||||||
|
catalogTrackIDs,
|
||||||
|
currentTrackID: nowPlayingState.currentTrackID,
|
||||||
|
queuedTrackIDs: queue.queuedTrackIDs
|
||||||
|
)
|
||||||
|
syncStateFromQueue()
|
||||||
|
|
||||||
|
if let currentTrack = currentTrack {
|
||||||
|
nowPlayingState.currentTrack = currentTrack
|
||||||
|
if loadedTrackID == currentTrack.id {
|
||||||
|
nowPlayingState.duration = effectiveDuration(for: currentTrack)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadedTrackID = nil
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = nil
|
||||||
|
engine.stop()
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
nowPlayingState.currentTime = 0
|
||||||
|
nowPlayingState.duration = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play(trackID: String) {
|
||||||
|
guard catalogTracksByID[trackID] != nil else {
|
||||||
|
nowPlayingState.error = .noTrackSelected
|
||||||
|
publishState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.selectTrack(trackID)
|
||||||
|
syncStateFromQueue()
|
||||||
|
|
||||||
|
if loadedTrackID == trackID {
|
||||||
|
if nowPlayingState.isPlaying {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCurrentTrackFinished {
|
||||||
|
playCurrentTrackFromStart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try engine.play()
|
||||||
|
startProgressTimer()
|
||||||
|
nowPlayingState.isPlaying = true
|
||||||
|
nowPlayingState.error = nil
|
||||||
|
syncTimingFromEngine()
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
} catch {
|
||||||
|
applyPlaybackError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playCurrentTrackFromStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func playPause() {
|
||||||
|
if nowPlayingState.isPlaying {
|
||||||
|
pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentTrack {
|
||||||
|
if loadedTrackID != currentTrack.id {
|
||||||
|
playCurrentTrackFromStart(startTime: nowPlayingState.currentTime)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCurrentTrackFinished {
|
||||||
|
playCurrentTrackFromStart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try engine.play()
|
||||||
|
nowPlayingState.isPlaying = true
|
||||||
|
nowPlayingState.error = nil
|
||||||
|
startProgressTimer()
|
||||||
|
syncTimingFromEngine()
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
} catch {
|
||||||
|
applyPlaybackError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let firstTrackID = queue.queuedTrackIDs.first {
|
||||||
|
play(trackID: firstTrackID)
|
||||||
|
} else {
|
||||||
|
applyPlaybackError(PlaybackError.queueEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() {
|
||||||
|
engine.pause()
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = nil
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
syncTimingFromEngine()
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
engine.stop()
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = nil
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
nowPlayingState.currentTime = 0
|
||||||
|
if let currentTrack {
|
||||||
|
nowPlayingState.duration = effectiveDuration(for: currentTrack)
|
||||||
|
} else {
|
||||||
|
nowPlayingState.duration = 0
|
||||||
|
}
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func seek(to time: Double) {
|
||||||
|
do {
|
||||||
|
try engine.seek(to: time)
|
||||||
|
nowPlayingState.currentTime = min(max(time, 0), effectiveDuration(for: currentTrack))
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
} catch {
|
||||||
|
applyPlaybackError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func next() {
|
||||||
|
guard queue.advanceToNextTrack() != nil else {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStateFromQueue()
|
||||||
|
playCurrentTrackFromStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func previous() {
|
||||||
|
if nowPlayingState.currentTime > 5 {
|
||||||
|
seek(to: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard queue.moveToPreviousTrack() != nil else {
|
||||||
|
seek(to: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStateFromQueue()
|
||||||
|
playCurrentTrackFromStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func toggleShuffle() {
|
||||||
|
queue.toggleShuffle()
|
||||||
|
syncStateFromQueue()
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cycleRepeatMode() {
|
||||||
|
queue.cycleRepeatMode()
|
||||||
|
syncStateFromQueue()
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentTrack: LibraryTrack? {
|
||||||
|
guard let currentTrackID = queue.currentTrackID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalogTracksByID[currentTrackID]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCurrentTrackFinished: Bool {
|
||||||
|
let duration = effectiveDuration(for: currentTrack)
|
||||||
|
guard duration > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return nowPlayingState.currentTime >= max(duration - 0.25, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playCurrentTrackFromStart(startTime: Double = 0) {
|
||||||
|
guard let currentTrack else {
|
||||||
|
applyPlaybackError(PlaybackError.noTrackSelected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try engine.loadTrack(
|
||||||
|
at: URL(fileURLWithPath: currentTrack.localFilePath),
|
||||||
|
startTime: startTime
|
||||||
|
)
|
||||||
|
loadedTrackID = currentTrack.id
|
||||||
|
try engine.play()
|
||||||
|
|
||||||
|
nowPlayingState.currentTrack = currentTrack
|
||||||
|
nowPlayingState.isPlaying = true
|
||||||
|
nowPlayingState.error = nil
|
||||||
|
syncTimingFromEngine()
|
||||||
|
startProgressTimer()
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
} catch {
|
||||||
|
loadedTrackID = nil
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = nil
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
nowPlayingState.currentTrack = currentTrack
|
||||||
|
nowPlayingState.currentTime = startTime
|
||||||
|
nowPlayingState.duration = effectiveDuration(for: currentTrack)
|
||||||
|
applyPlaybackError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreTrack(_ track: LibraryTrack, position: Double) {
|
||||||
|
do {
|
||||||
|
try engine.loadTrack(
|
||||||
|
at: URL(fileURLWithPath: track.localFilePath),
|
||||||
|
startTime: position
|
||||||
|
)
|
||||||
|
loadedTrackID = track.id
|
||||||
|
nowPlayingState.currentTrack = track
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
nowPlayingState.error = nil
|
||||||
|
syncTimingFromEngine()
|
||||||
|
} catch {
|
||||||
|
loadedTrackID = nil
|
||||||
|
nowPlayingState.currentTrack = track
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
nowPlayingState.currentTime = position
|
||||||
|
nowPlayingState.duration = effectiveDuration(for: track)
|
||||||
|
applyPlaybackError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePlaybackFinished() {
|
||||||
|
if queue.repeatMode == .one {
|
||||||
|
playCurrentTrackFromStart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard queue.advanceToNextTrack() != nil else {
|
||||||
|
engine.stop()
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = nil
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
nowPlayingState.currentTime = nowPlayingState.duration
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStateFromQueue()
|
||||||
|
playCurrentTrackFromStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncStateFromQueue() {
|
||||||
|
nowPlayingState.currentTrack = currentTrack
|
||||||
|
nowPlayingState.queueTrackIDs = queue.queuedTrackIDs
|
||||||
|
nowPlayingState.isShuffleEnabled = queue.isShuffleEnabled
|
||||||
|
nowPlayingState.repeatMode = queue.repeatMode
|
||||||
|
|
||||||
|
if let currentTrack {
|
||||||
|
nowPlayingState.duration = effectiveDuration(for: currentTrack)
|
||||||
|
} else {
|
||||||
|
nowPlayingState.currentTime = 0
|
||||||
|
nowPlayingState.duration = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncTimingFromEngine() {
|
||||||
|
nowPlayingState.currentTime = engine.currentTime
|
||||||
|
if engine.duration > 0 {
|
||||||
|
nowPlayingState.duration = engine.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func effectiveDuration(for track: LibraryTrack?) -> Double {
|
||||||
|
if engine.duration > 0 {
|
||||||
|
return engine.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
return track?.durationSeconds ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startProgressTimer() {
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) {
|
||||||
|
[weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.syncTimingFromEngine()
|
||||||
|
self.persistSession()
|
||||||
|
self.publishState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let progressTimer {
|
||||||
|
RunLoop.main.add(progressTimer, forMode: .common)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistSession() {
|
||||||
|
sessionStore.saveSession(
|
||||||
|
PlaybackSessionSnapshot(
|
||||||
|
queueTrackIDs: queue.queuedTrackIDs,
|
||||||
|
currentTrackID: queue.currentTrackID,
|
||||||
|
currentTime: nowPlayingState.currentTime,
|
||||||
|
isShuffleEnabled: queue.isShuffleEnabled,
|
||||||
|
repeatMode: queue.repeatMode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPlaybackError(_ error: Error) {
|
||||||
|
if let playbackError = error as? PlaybackError {
|
||||||
|
nowPlayingState.error = playbackError
|
||||||
|
} else {
|
||||||
|
nowPlayingState.error = .failedToStartPlayback
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlayingState.isPlaying = false
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = nil
|
||||||
|
persistSession()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publishState() {
|
||||||
|
onStateChange?(nowPlayingState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum PlaybackEngineEvent: Hashable, Sendable {
|
||||||
|
case finishedPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public protocol PlaybackEngine: AnyObject {
|
||||||
|
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)? { get set }
|
||||||
|
var currentTime: Double { get }
|
||||||
|
var duration: Double { get }
|
||||||
|
var isPlaying: Bool { get }
|
||||||
|
|
||||||
|
func loadTrack(at fileURL: URL, startTime: Double) throws
|
||||||
|
func play() throws
|
||||||
|
func pause()
|
||||||
|
func stop()
|
||||||
|
func seek(to time: Double) throws
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class AVFoundationPlaybackEngine: NSObject, PlaybackEngine, AVAudioPlayerDelegate {
|
||||||
|
public var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?
|
||||||
|
|
||||||
|
private var audioPlayer: AVAudioPlayer?
|
||||||
|
private let fileManager: FileManager
|
||||||
|
|
||||||
|
public init(fileManager: FileManager = .default) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var currentTime: Double {
|
||||||
|
audioPlayer?.currentTime ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public var duration: Double {
|
||||||
|
audioPlayer?.duration ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isPlaying: Bool {
|
||||||
|
audioPlayer?.isPlaying ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadTrack(at fileURL: URL, startTime: Double) throws {
|
||||||
|
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||||
|
throw PlaybackError.missingLocalFile(path: fileURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
||||||
|
audioPlayer.delegate = self
|
||||||
|
audioPlayer.prepareToPlay()
|
||||||
|
audioPlayer.currentTime = min(max(startTime, 0), audioPlayer.duration)
|
||||||
|
self.audioPlayer = audioPlayer
|
||||||
|
} catch let error as PlaybackError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw PlaybackError.failedToLoadTrack(path: fileURL.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play() throws {
|
||||||
|
guard let audioPlayer else {
|
||||||
|
throw PlaybackError.noTrackLoaded
|
||||||
|
}
|
||||||
|
|
||||||
|
guard audioPlayer.play() else {
|
||||||
|
throw PlaybackError.failedToStartPlayback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
audioPlayer?.currentTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public func seek(to time: Double) throws {
|
||||||
|
guard let audioPlayer else {
|
||||||
|
throw PlaybackError.seekUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
audioPlayer.currentTime = min(max(time, 0), audioPlayer.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func audioPlayerDidFinishPlaying(
|
||||||
|
_ player: AVAudioPlayer,
|
||||||
|
successfully flag: Bool
|
||||||
|
) {
|
||||||
|
if flag {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.onEvent?(.finishedPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum PlaybackError: Error, LocalizedError, Equatable, Hashable, Sendable {
|
||||||
|
case queueEmpty
|
||||||
|
case noTrackSelected
|
||||||
|
case noTrackLoaded
|
||||||
|
case missingLocalFile(path: String)
|
||||||
|
case failedToLoadTrack(path: String)
|
||||||
|
case failedToStartPlayback
|
||||||
|
case seekUnavailable
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .queueEmpty:
|
||||||
|
return "No tracks are available in the playback queue."
|
||||||
|
case .noTrackSelected:
|
||||||
|
return "Choose a track before starting playback."
|
||||||
|
case .noTrackLoaded:
|
||||||
|
return "No audio track is currently loaded."
|
||||||
|
case .missingLocalFile(let path):
|
||||||
|
return "The local file could not be found: \(path)"
|
||||||
|
case .failedToLoadTrack(let path):
|
||||||
|
return "The audio file could not be opened: \(path)"
|
||||||
|
case .failedToStartPlayback:
|
||||||
|
return "Playback could not be started."
|
||||||
|
case .seekUnavailable:
|
||||||
|
return "Seeking is unavailable until a track is loaded."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,229 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct PlaybackQueue: Hashable, Sendable {
|
||||||
|
public private(set) var catalogTrackIDs: [String]
|
||||||
|
public private(set) var queuedTrackIDs: [String]
|
||||||
|
public private(set) var currentTrackID: String?
|
||||||
|
public private(set) var isShuffleEnabled: Bool
|
||||||
|
public private(set) var repeatMode: PlaybackRepeatMode
|
||||||
|
|
||||||
|
public init(
|
||||||
|
trackIDs: [String] = [],
|
||||||
|
currentTrackID: String? = nil,
|
||||||
|
queuedTrackIDs: [String]? = nil,
|
||||||
|
isShuffleEnabled: Bool = false,
|
||||||
|
repeatMode: PlaybackRepeatMode = .off
|
||||||
|
) {
|
||||||
|
self.catalogTrackIDs = []
|
||||||
|
self.queuedTrackIDs = []
|
||||||
|
self.currentTrackID = nil
|
||||||
|
self.isShuffleEnabled = isShuffleEnabled
|
||||||
|
self.repeatMode = repeatMode
|
||||||
|
|
||||||
|
replaceTrackIDs(
|
||||||
|
trackIDs,
|
||||||
|
currentTrackID: currentTrackID,
|
||||||
|
queuedTrackIDs: queuedTrackIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isEmpty: Bool {
|
||||||
|
queuedTrackIDs.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func replaceTrackIDs(
|
||||||
|
_ trackIDs: [String],
|
||||||
|
currentTrackID preferredCurrentTrackID: String? = nil,
|
||||||
|
queuedTrackIDs preferredQueuedTrackIDs: [String]? = nil
|
||||||
|
) {
|
||||||
|
let normalizedCatalogTrackIDs = Self.uniqueTrackIDs(trackIDs)
|
||||||
|
catalogTrackIDs = normalizedCatalogTrackIDs
|
||||||
|
|
||||||
|
if normalizedCatalogTrackIDs.isEmpty {
|
||||||
|
queuedTrackIDs = []
|
||||||
|
currentTrackID = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShuffleEnabled {
|
||||||
|
if let preferredQueuedTrackIDs {
|
||||||
|
let normalizedQueuedTrackIDs = Self.normalizedQueuedTrackIDs(
|
||||||
|
preferredQueuedTrackIDs,
|
||||||
|
validTrackIDs: normalizedCatalogTrackIDs
|
||||||
|
)
|
||||||
|
|
||||||
|
queuedTrackIDs = normalizedQueuedTrackIDs.isEmpty
|
||||||
|
? Self.makeShuffledTrackIDs(
|
||||||
|
from: normalizedCatalogTrackIDs,
|
||||||
|
currentTrackID: preferredCurrentTrackID ?? currentTrackID
|
||||||
|
)
|
||||||
|
: normalizedQueuedTrackIDs
|
||||||
|
} else {
|
||||||
|
let normalizedCurrentQueue = Self.normalizedQueuedTrackIDs(
|
||||||
|
queuedTrackIDs,
|
||||||
|
validTrackIDs: normalizedCatalogTrackIDs
|
||||||
|
)
|
||||||
|
|
||||||
|
queuedTrackIDs = normalizedCurrentQueue.isEmpty
|
||||||
|
? Self.makeShuffledTrackIDs(
|
||||||
|
from: normalizedCatalogTrackIDs,
|
||||||
|
currentTrackID: preferredCurrentTrackID ?? currentTrackID
|
||||||
|
)
|
||||||
|
: normalizedCurrentQueue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queuedTrackIDs = normalizedCatalogTrackIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
if let preferredCurrentTrackID,
|
||||||
|
queuedTrackIDs.contains(preferredCurrentTrackID)
|
||||||
|
{
|
||||||
|
currentTrackID = preferredCurrentTrackID
|
||||||
|
} else if let currentTrackID,
|
||||||
|
queuedTrackIDs.contains(currentTrackID)
|
||||||
|
{
|
||||||
|
self.currentTrackID = currentTrackID
|
||||||
|
} else {
|
||||||
|
currentTrackID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func selectTrack(_ trackID: String) {
|
||||||
|
guard queuedTrackIDs.contains(trackID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrackID = trackID
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func toggleShuffle() {
|
||||||
|
setShuffleEnabled(!isShuffleEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func setShuffleEnabled(
|
||||||
|
_ isEnabled: Bool,
|
||||||
|
queuedTrackIDs preferredQueuedTrackIDs: [String]? = nil
|
||||||
|
) {
|
||||||
|
isShuffleEnabled = isEnabled
|
||||||
|
replaceTrackIDs(
|
||||||
|
catalogTrackIDs,
|
||||||
|
currentTrackID: currentTrackID,
|
||||||
|
queuedTrackIDs: preferredQueuedTrackIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func cycleRepeatMode() {
|
||||||
|
repeatMode = repeatMode.nextMode
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func setRepeatMode(_ repeatMode: PlaybackRepeatMode) {
|
||||||
|
self.repeatMode = repeatMode
|
||||||
|
}
|
||||||
|
|
||||||
|
public func nextTrackID() -> String? {
|
||||||
|
guard let currentTrackID else {
|
||||||
|
return queuedTrackIDs.first
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let currentIndex = queuedTrackIDs.firstIndex(of: currentTrackID) else {
|
||||||
|
return queuedTrackIDs.first
|
||||||
|
}
|
||||||
|
|
||||||
|
if repeatMode == .one {
|
||||||
|
return currentTrackID
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = currentIndex + 1
|
||||||
|
if queuedTrackIDs.indices.contains(nextIndex) {
|
||||||
|
return queuedTrackIDs[nextIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
if repeatMode == .all {
|
||||||
|
return queuedTrackIDs.first
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func previousTrackID() -> String? {
|
||||||
|
guard let currentTrackID else {
|
||||||
|
return queuedTrackIDs.first
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let currentIndex = queuedTrackIDs.firstIndex(of: currentTrackID) else {
|
||||||
|
return queuedTrackIDs.first
|
||||||
|
}
|
||||||
|
|
||||||
|
if repeatMode == .one {
|
||||||
|
return currentTrackID
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousIndex = currentIndex - 1
|
||||||
|
if queuedTrackIDs.indices.contains(previousIndex) {
|
||||||
|
return queuedTrackIDs[previousIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
if repeatMode == .all {
|
||||||
|
return queuedTrackIDs.last
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func advanceToNextTrack() -> String? {
|
||||||
|
let nextTrackID = nextTrackID()
|
||||||
|
if let nextTrackID {
|
||||||
|
currentTrackID = nextTrackID
|
||||||
|
}
|
||||||
|
return nextTrackID
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func moveToPreviousTrack() -> String? {
|
||||||
|
let previousTrackID = previousTrackID()
|
||||||
|
if let previousTrackID {
|
||||||
|
currentTrackID = previousTrackID
|
||||||
|
}
|
||||||
|
return previousTrackID
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func uniqueTrackIDs(_ trackIDs: [String]) -> [String] {
|
||||||
|
var seenTrackIDs = Set<String>()
|
||||||
|
return trackIDs.filter { trackID in
|
||||||
|
seenTrackIDs.insert(trackID).inserted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedQueuedTrackIDs(
|
||||||
|
_ queuedTrackIDs: [String],
|
||||||
|
validTrackIDs: [String]
|
||||||
|
) -> [String] {
|
||||||
|
let validTrackIDSet = Set(validTrackIDs)
|
||||||
|
var seenTrackIDs = Set<String>()
|
||||||
|
|
||||||
|
let normalizedQueuedTrackIDs = queuedTrackIDs.filter { trackID in
|
||||||
|
validTrackIDSet.contains(trackID) && seenTrackIDs.insert(trackID).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
let missingTrackIDs = validTrackIDs.filter { !seenTrackIDs.contains($0) }
|
||||||
|
return normalizedQueuedTrackIDs + missingTrackIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeShuffledTrackIDs(
|
||||||
|
from trackIDs: [String],
|
||||||
|
currentTrackID: String?
|
||||||
|
) -> [String] {
|
||||||
|
guard !trackIDs.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingTrackIDs = trackIDs.filter { $0 != currentTrackID }.shuffled()
|
||||||
|
|
||||||
|
if let currentTrackID,
|
||||||
|
trackIDs.contains(currentTrackID)
|
||||||
|
{
|
||||||
|
return [currentTrackID] + remainingTrackIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackIDs.shuffled()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum PlaybackRepeatMode: String, Codable, CaseIterable, Hashable, Sendable {
|
||||||
|
case off
|
||||||
|
case all
|
||||||
|
case one
|
||||||
|
|
||||||
|
public var nextMode: PlaybackRepeatMode {
|
||||||
|
switch self {
|
||||||
|
case .off:
|
||||||
|
.all
|
||||||
|
case .all:
|
||||||
|
.one
|
||||||
|
case .one:
|
||||||
|
.off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct PlaybackSessionSnapshot: Codable, Hashable, Sendable {
|
||||||
|
public var queueTrackIDs: [String]
|
||||||
|
public var currentTrackID: String?
|
||||||
|
public var currentTime: Double
|
||||||
|
public var isShuffleEnabled: Bool
|
||||||
|
public var repeatMode: PlaybackRepeatMode
|
||||||
|
|
||||||
|
public init(
|
||||||
|
queueTrackIDs: [String] = [],
|
||||||
|
currentTrackID: String? = nil,
|
||||||
|
currentTime: Double = 0,
|
||||||
|
isShuffleEnabled: Bool = false,
|
||||||
|
repeatMode: PlaybackRepeatMode = .off
|
||||||
|
) {
|
||||||
|
self.queueTrackIDs = queueTrackIDs
|
||||||
|
self.currentTrackID = currentTrackID
|
||||||
|
self.currentTime = currentTime
|
||||||
|
self.isShuffleEnabled = isShuffleEnabled
|
||||||
|
self.repeatMode = repeatMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol PlaybackSessionStore: Sendable {
|
||||||
|
func loadSession() -> PlaybackSessionSnapshot?
|
||||||
|
func saveSession(_ session: PlaybackSessionSnapshot)
|
||||||
|
func clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UserDefaultsPlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
|
||||||
|
private let userDefaults: UserDefaults
|
||||||
|
private let storageKey: String
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
public init(
|
||||||
|
userDefaults: UserDefaults = .standard,
|
||||||
|
storageKey: String = "velody.playback.session"
|
||||||
|
) {
|
||||||
|
self.userDefaults = userDefaults
|
||||||
|
self.storageKey = storageKey
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadSession() -> PlaybackSessionSnapshot? {
|
||||||
|
guard let data = userDefaults.data(forKey: storageKey) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try? decoder.decode(PlaybackSessionSnapshot.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveSession(_ session: PlaybackSessionSnapshot) {
|
||||||
|
guard let data = try? encoder.encode(session) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userDefaults.set(data, forKey: storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearSession() {
|
||||||
|
userDefaults.removeObject(forKey: storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class InMemoryPlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
|
||||||
|
private var session: PlaybackSessionSnapshot?
|
||||||
|
|
||||||
|
public init(session: PlaybackSessionSnapshot? = nil) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadSession() -> PlaybackSessionSnapshot? {
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveSession(_ session: PlaybackSessionSnapshot) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearSession() {
|
||||||
|
session = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
import VelodyDomain
|
||||||
|
@testable import VelodyPlayback
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PlaybackControllerTests: XCTestCase {
|
||||||
|
func testPlayPauseRestartsTrackAfterPlaybackFinishesAtQueueEnd() {
|
||||||
|
let engine = FakePlaybackEngine()
|
||||||
|
let sessionStore = InMemoryPlaybackSessionStore()
|
||||||
|
let controller = PlaybackController(
|
||||||
|
engine: engine,
|
||||||
|
sessionStore: sessionStore
|
||||||
|
)
|
||||||
|
let track = LibraryTrack(
|
||||||
|
id: "track-1",
|
||||||
|
title: "Finished Track",
|
||||||
|
artist: "Tester",
|
||||||
|
durationSeconds: 120,
|
||||||
|
localFilePath: "/tmp/finished-track.mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
controller.setCatalogTracks([track])
|
||||||
|
controller.play(trackID: track.id)
|
||||||
|
|
||||||
|
XCTAssertEqual(engine.loadTrackCallCount, 1)
|
||||||
|
XCTAssertEqual(engine.playCallCount, 1)
|
||||||
|
XCTAssertTrue(controller.nowPlayingState.isPlaying)
|
||||||
|
|
||||||
|
engine.simulatePlaybackFinished()
|
||||||
|
|
||||||
|
XCTAssertFalse(controller.nowPlayingState.isPlaying)
|
||||||
|
XCTAssertEqual(controller.nowPlayingState.currentTime, 120)
|
||||||
|
|
||||||
|
controller.playPause()
|
||||||
|
|
||||||
|
XCTAssertEqual(engine.loadTrackCallCount, 2)
|
||||||
|
XCTAssertEqual(engine.playCallCount, 2)
|
||||||
|
XCTAssertEqual(engine.lastLoadedStartTime, 0)
|
||||||
|
XCTAssertTrue(controller.nowPlayingState.isPlaying)
|
||||||
|
XCTAssertEqual(controller.nowPlayingState.currentTime, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class FakePlaybackEngine: PlaybackEngine {
|
||||||
|
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?
|
||||||
|
var currentTime: Double = 0
|
||||||
|
var duration: Double = 120
|
||||||
|
var isPlaying = false
|
||||||
|
|
||||||
|
private(set) var loadTrackCallCount = 0
|
||||||
|
private(set) var playCallCount = 0
|
||||||
|
private(set) var lastLoadedStartTime: Double?
|
||||||
|
|
||||||
|
func loadTrack(at fileURL: URL, startTime: Double) throws {
|
||||||
|
loadTrackCallCount += 1
|
||||||
|
lastLoadedStartTime = startTime
|
||||||
|
currentTime = startTime
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() throws {
|
||||||
|
playCallCount += 1
|
||||||
|
isPlaying = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
isPlaying = false
|
||||||
|
currentTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to time: Double) throws {
|
||||||
|
currentTime = min(max(time, 0), duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulatePlaybackFinished() {
|
||||||
|
isPlaying = false
|
||||||
|
currentTime = duration
|
||||||
|
onEvent?(.finishedPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VelodyPlayback
|
||||||
|
|
||||||
|
final class PlaybackQueueTests: XCTestCase {
|
||||||
|
func testQueueStartsWithoutCurrentTrackSelection() {
|
||||||
|
let queue = PlaybackQueue(trackIDs: ["a", "b", "c"])
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.catalogTrackIDs, ["a", "b", "c"])
|
||||||
|
XCTAssertEqual(queue.queuedTrackIDs, ["a", "b", "c"])
|
||||||
|
XCTAssertNil(queue.currentTrackID)
|
||||||
|
XCTAssertEqual(queue.nextTrackID(), "a")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectingTrackAndAdvancingUsesQueueOrder() {
|
||||||
|
var queue = PlaybackQueue(trackIDs: ["a", "b", "c"])
|
||||||
|
|
||||||
|
queue.selectTrack("b")
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.currentTrackID, "b")
|
||||||
|
XCTAssertEqual(queue.nextTrackID(), "c")
|
||||||
|
XCTAssertEqual(queue.previousTrackID(), "a")
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.advanceToNextTrack(), "c")
|
||||||
|
XCTAssertEqual(queue.currentTrackID, "c")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShuffleCanUsePersistedQueueOrderWhileKeepingSameTrackSet() {
|
||||||
|
var queue = PlaybackQueue(trackIDs: ["a", "b", "c", "d"])
|
||||||
|
queue.selectTrack("c")
|
||||||
|
|
||||||
|
queue.setShuffleEnabled(true, queuedTrackIDs: ["c", "a", "d", "b"])
|
||||||
|
|
||||||
|
XCTAssertTrue(queue.isShuffleEnabled)
|
||||||
|
XCTAssertEqual(queue.currentTrackID, "c")
|
||||||
|
XCTAssertEqual(queue.queuedTrackIDs, ["c", "a", "d", "b"])
|
||||||
|
|
||||||
|
queue.setShuffleEnabled(false)
|
||||||
|
|
||||||
|
XCTAssertFalse(queue.isShuffleEnabled)
|
||||||
|
XCTAssertEqual(queue.queuedTrackIDs, ["a", "b", "c", "d"])
|
||||||
|
XCTAssertEqual(queue.currentTrackID, "c")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepeatAllWrapsAroundQueueBoundaries() {
|
||||||
|
var queue = PlaybackQueue(trackIDs: ["a", "b", "c"])
|
||||||
|
queue.selectTrack("c")
|
||||||
|
queue.setRepeatMode(.all)
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.nextTrackID(), "a")
|
||||||
|
XCTAssertEqual(queue.advanceToNextTrack(), "a")
|
||||||
|
XCTAssertEqual(queue.currentTrackID, "a")
|
||||||
|
XCTAssertEqual(queue.previousTrackID(), "c")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepeatOneKeepsCurrentTrackForNextAndPrevious() {
|
||||||
|
var queue = PlaybackQueue(trackIDs: ["a", "b", "c"])
|
||||||
|
queue.selectTrack("b")
|
||||||
|
queue.setRepeatMode(.one)
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.nextTrackID(), "b")
|
||||||
|
XCTAssertEqual(queue.previousTrackID(), "b")
|
||||||
|
XCTAssertEqual(queue.advanceToNextTrack(), "b")
|
||||||
|
XCTAssertEqual(queue.moveToPreviousTrack(), "b")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReplacingTrackIDsDropsRemovedTracksFromQueue() {
|
||||||
|
var queue = PlaybackQueue(
|
||||||
|
trackIDs: ["a", "b", "c"],
|
||||||
|
currentTrackID: "b",
|
||||||
|
queuedTrackIDs: ["b", "c", "a"],
|
||||||
|
isShuffleEnabled: true,
|
||||||
|
repeatMode: .all
|
||||||
|
)
|
||||||
|
|
||||||
|
queue.replaceTrackIDs(
|
||||||
|
["a", "c", "d"],
|
||||||
|
currentTrackID: queue.currentTrackID,
|
||||||
|
queuedTrackIDs: queue.queuedTrackIDs
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.catalogTrackIDs, ["a", "c", "d"])
|
||||||
|
XCTAssertEqual(queue.queuedTrackIDs, ["c", "a", "d"])
|
||||||
|
XCTAssertNil(queue.currentTrackID)
|
||||||
|
XCTAssertEqual(queue.nextTrackID(), "c")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user