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