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
+20 -6
View File
@@ -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 = "<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; };
@@ -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 */;
+174 -23
View File
@@ -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) {
@@ -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 {
+4
View File
@@ -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