From 799c07b06866aceb448a269be68c9c20331bcc75 Mon Sep 17 00:00:00 2001 From: diyaa Date: Thu, 28 May 2026 14:05:15 +0200 Subject: [PATCH] Add macOS local playback engine --- apps/apple/Velody.xcodeproj/project.pbxproj | 26 +- .../VelodyMac/Sources/MacLibraryView.swift | 197 +++++++- .../Sources/MacLibraryViewModel.swift | 88 ++++ apps/apple/project.yml | 4 + packages/apple/VelodyPlayback/Package.swift | 32 ++ .../VelodyPlayback/NowPlayingState.swift | 45 ++ .../VelodyPlayback/PlaybackController.swift | 430 ++++++++++++++++++ .../VelodyPlayback/PlaybackEngine.swift | 101 ++++ .../VelodyPlayback/PlaybackError.swift | 30 ++ .../VelodyPlayback/PlaybackQueue.swift | 229 ++++++++++ .../VelodyPlayback/PlaybackRepeatMode.swift | 18 + .../VelodyPlayback/PlaybackSessionStore.swift | 84 ++++ .../PlaybackControllerTests.swift | 86 ++++ .../PlaybackQueueTests.swift | 86 ++++ 14 files changed, 1427 insertions(+), 29 deletions(-) create mode 100644 packages/apple/VelodyPlayback/Package.swift create mode 100644 packages/apple/VelodyPlayback/Sources/VelodyPlayback/NowPlayingState.swift create mode 100644 packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift create mode 100644 packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift create mode 100644 packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift create mode 100644 packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift create mode 100644 packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift create mode 100644 packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackSessionStore.swift create mode 100644 packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift create mode 100644 packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift diff --git a/apps/apple/Velody.xcodeproj/project.pbxproj b/apps/apple/Velody.xcodeproj/project.pbxproj index e9c0544..80cd0c9 100644 --- a/apps/apple/Velody.xcodeproj/project.pbxproj +++ b/apps/apple/Velody.xcodeproj/project.pbxproj @@ -9,14 +9,14 @@ /* Begin PBXBuildFile section */ 0AF7078E8BD078ECE18B6C0A /* VelodyiPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */; }; 151772779307EFC3B3A17477 /* MacLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */; }; - 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 */; }; 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 */; }; 5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.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 */; }; 93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; }; A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; }; @@ -24,12 +24,14 @@ D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; }; D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; }; EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; }; + FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; }; FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 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; }; + 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 = ""; }; 5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = ""; }; 6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; }; @@ -52,9 +54,10 @@ files = ( 3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */, EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */, - 2E9DD262BF65832378F37DD4 /* VelodyPersistence in Frameworks */, - 783EBF82108F7A04B6DD33B4 /* VelodySync in Frameworks */, - 3D22DE55C4A27A4DE68E6359 /* VelodyUtilities in Frameworks */, + 2E9DD262BF65832378F37DD4 /* VelodyPlayback in Frameworks */, + 783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */, + 3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */, + FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,6 +93,7 @@ F7EF830CE1ABB841E624A660 /* VelodyDomain */, 0F6993844F6FD7E86D52EC25 /* VelodyNetworking */, 6E7BCB85A35B8286E4472822 /* VelodyPersistence */, + 15A17C02F8CBB0A492A82C14 /* VelodyPlayback */, 07508485E10C6E2942FE29AB /* VelodySync */, 89FE56E825FC42D026EAC784 /* VelodyUtilities */, ); @@ -146,6 +150,7 @@ packageProductDependencies = ( 3C910108376E6ECEF152DCE1 /* VelodyDomain */, 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */, + EA45DE5B08D71D666009BB5E /* VelodyPlayback */, 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */, DA5EBADF45BC0977F73F241C /* VelodySync */, AAD3F903A475AA0B0159C79E /* VelodyUtilities */, @@ -200,6 +205,7 @@ 43AF8C7D2C6F46AB2748AAD9 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyDomain" */, 5DC2C05FD0EC7BD980C0BF69 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyNetworking" */, E9AE6C97FDD54CE81E584DDC /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPersistence" */, + 2FCEB9E0A092D6C2F4B66645 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPlayback" */, 60A72563C2E87FC2BA78729A /* XCLocalSwiftPackageReference "../../packages/apple/VelodySync" */, 4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */, ); @@ -457,6 +463,10 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ + 2FCEB9E0A092D6C2F4B66645 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPlayback" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../packages/apple/VelodyPlayback; + }; 4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../../packages/apple/VelodyUtilities; @@ -520,6 +530,10 @@ isa = XCSwiftPackageProductDependency; productName = VelodySync; }; + EA45DE5B08D71D666009BB5E /* VelodyPlayback */ = { + isa = XCSwiftPackageProductDependency; + productName = VelodyPlayback; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D14070EEF27F35DD4F5F547B /* Project object */; diff --git a/apps/apple/VelodyMac/Sources/MacLibraryView.swift b/apps/apple/VelodyMac/Sources/MacLibraryView.swift index eaff576..45fd4fc 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryView.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryView.swift @@ -4,6 +4,7 @@ import VelodyDomain struct MacLibraryView: View { @State private var viewModel = MacLibraryViewModel() + @State private var scrubbedPlaybackTime: Double? var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -94,31 +95,54 @@ struct MacLibraryView: View { Text(viewModel.scanStatus) .foregroundStyle(.secondary) - List(viewModel.tracks) { track in - VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) + List { + ForEach(viewModel.tracks, id: \.id) { track in + HStack(alignment: .top, spacing: 12) { + Button { + viewModel.togglePlayback(for: track) + } label: { + Image(systemName: viewModel.playbackButtonSymbol(for: track)) + .font(.title2) + } + .buttonStyle(.borderless) - if let album = track.album { - Text(album) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(.headline) + 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()) + } } - - 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) } - .padding(.vertical, 4) } .overlay { if viewModel.tracks.isEmpty && !viewModel.isScanning { @@ -130,7 +154,7 @@ struct MacLibraryView: View { } } - Spacer(minLength: 0) + miniPlayer } .padding(24) .task { @@ -145,6 +169,133 @@ struct MacLibraryView: View { 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 private func statusRow(title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 2) { diff --git a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift index 06a138e..c4e9c24 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift @@ -2,6 +2,7 @@ import Foundation import Observation import VelodyDomain import VelodyNetworking +import VelodyPlayback import VelodyPersistence import VelodyUtilities @@ -13,6 +14,7 @@ final class MacLibraryViewModel { var scanStatus = "Choose a folder to begin local discovery." var discoveredTrackCount = 0 var isScanning = false + var nowPlayingState = NowPlayingState() var serverURLString: String var deviceRegistrationStatus = "Not registered." @@ -28,6 +30,7 @@ final class MacLibraryViewModel { private let folderAccessService: any VelodyPersistence.FolderAccessService private let catalogService: any LocalCatalogService private let localMusicScanner: any LocalMusicScanner + private let playbackController: PlaybackController private let keychainService: any KeychainService private let userDefaults: UserDefaults private var hasLoaded = false @@ -41,14 +44,26 @@ final class MacLibraryViewModel { metadataReader: AVFoundationMetadataReader() ) let repository = Self.makeTrackRepository() + let playbackController = PlaybackController( + sessionStore: UserDefaultsPlaybackSessionStore( + userDefaults: userDefaults, + storageKey: Self.playbackSessionDefaultsKey + ) + ) self.folderAccessService = folderAccessService self.catalogService = DefaultLocalCatalogService(repository: repository) self.localMusicScanner = localMusicScanner + self.playbackController = playbackController self.keychainService = keychainService self.userDefaults = userDefaults self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey) ?? ServerEnvironment.defaultLocalBaseURL.absoluteString + self.nowPlayingState = playbackController.nowPlayingState + + playbackController.onStateChange = { [weak self] state in + self?.nowPlayingState = state + } if let url = folderAccessService.storedFolderURL() { selectedFolderPath = url.path @@ -66,6 +81,7 @@ final class MacLibraryViewModel { scanStatus = "Failed to load saved catalog: \(error.localizedDescription)" } + playbackController.setCatalogTracks(tracks) await restoreDeviceIdentity() } @@ -75,6 +91,7 @@ final class MacLibraryViewModel { scanStatus = "Folder selected. Run a manual scan to discover MP3 files." tracks = [] discoveredTrackCount = 0 + playbackController.setCatalogTracks([]) Task { do { try await catalogService.resetLocalCatalog() @@ -106,6 +123,7 @@ final class MacLibraryViewModel { ) tracks = scanResult.tracks discoveredTrackCount = tracks.count + playbackController.setCatalogTracks(tracks) scanStatus = Self.scanStatus( for: scanResult, 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() { guard let serverURL = Self.normalizedServerURL(from: serverURLString) else { return @@ -310,6 +397,7 @@ final class MacLibraryViewModel { private static let serverURLDefaultsKey = "velody.server-environment.base-url" private static let deviceIdKey = "velody.device-id" private static let bootstrapTokenKey = "velody.bootstrap-token" + private static let playbackSessionDefaultsKey = "velody.playback.session" } private enum BackendConnectionError: LocalizedError { diff --git a/apps/apple/project.yml b/apps/apple/project.yml index abe8afc..1bd0592 100644 --- a/apps/apple/project.yml +++ b/apps/apple/project.yml @@ -10,6 +10,8 @@ packages: path: ../../packages/apple/VelodyDomain VelodyNetworking: path: ../../packages/apple/VelodyNetworking + VelodyPlayback: + path: ../../packages/apple/VelodyPlayback VelodyPersistence: path: ../../packages/apple/VelodyPersistence VelodySync: @@ -34,6 +36,8 @@ targets: product: VelodyDomain - package: VelodyNetworking product: VelodyNetworking + - package: VelodyPlayback + product: VelodyPlayback - package: VelodyPersistence product: VelodyPersistence - package: VelodySync diff --git a/packages/apple/VelodyPlayback/Package.swift b/packages/apple/VelodyPlayback/Package.swift new file mode 100644 index 0000000..e012a42 --- /dev/null +++ b/packages/apple/VelodyPlayback/Package.swift @@ -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", + ] + ), + ] +) diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/NowPlayingState.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/NowPlayingState.swift new file mode 100644 index 0000000..6e7b4a9 --- /dev/null +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/NowPlayingState.swift @@ -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) + } +} diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift new file mode 100644 index 0000000..625407c --- /dev/null +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift @@ -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) + } +} diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift new file mode 100644 index 0000000..769dcd7 --- /dev/null +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift @@ -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) + } + } + } +} diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift new file mode 100644 index 0000000..657720d --- /dev/null +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift @@ -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." + } + } +} diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift new file mode 100644 index 0000000..ef603c6 --- /dev/null +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift @@ -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() + 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() + + 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() + } +} diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift new file mode 100644 index 0000000..c737dc0 --- /dev/null +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift @@ -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 + } + } +} diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackSessionStore.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackSessionStore.swift new file mode 100644 index 0000000..58afa65 --- /dev/null +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackSessionStore.swift @@ -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 + } +} diff --git a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift new file mode 100644 index 0000000..cff53e0 --- /dev/null +++ b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift @@ -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) + } +} diff --git a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift new file mode 100644 index 0000000..a026079 --- /dev/null +++ b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift @@ -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") + } +}