Add macOS local playback engine

This commit is contained in:
diyaa 2026-05-28 14:05:15 +02:00
parent 183d5db4a9
commit 799c07b068
14 changed files with 1427 additions and 29 deletions

View File

@ -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 */;

View File

@ -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) {

View File

@ -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 {

View File

@ -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

View 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",
]
),
]
)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}

View File

@ -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."
}
}
}

View File

@ -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()
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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")
}
}