From 4fa431e9bd2e512500deec140bf752829d13cf9e Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 26 May 2026 21:04:40 +0200 Subject: [PATCH] refactor: make PlayerViewModel single source of truth for all playback state --- Music/Services/AudioService.swift | 7 ++ Music/ViewModels/PlayerViewModel.swift | 155 +++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/Music/Services/AudioService.swift b/Music/Services/AudioService.swift index 6a00bee..cbecffe 100644 --- a/Music/Services/AudioService.swift +++ b/Music/Services/AudioService.swift @@ -25,6 +25,7 @@ final class AudioService { private var pendingSeekTime: Double? var onTrackFinished: (() -> Void)? + var onPlaybackStateChanged: (() -> Void)? func play(url: URL) { cleanup() @@ -42,6 +43,7 @@ final class AudioService { if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite { self.duration = dur.seconds } + self.onPlaybackStateChanged?() } endObserver = NotificationCenter.default.addObserver( @@ -51,21 +53,25 @@ final class AudioService { ) { [weak self] _ in self?.isPlaying = false self?.currentTime = 0 + self?.onPlaybackStateChanged?() self?.onTrackFinished?() } player?.play() isPlaying = true + onPlaybackStateChanged?() } func pause() { player?.pause() isPlaying = false + onPlaybackStateChanged?() } func resume() { player?.play() isPlaying = true + onPlaybackStateChanged?() } func togglePlayPause() { @@ -144,6 +150,7 @@ final class AudioService { isPlaying = false currentTime = 0 duration = 0 + onPlaybackStateChanged?() } private func cleanup() { diff --git a/Music/ViewModels/PlayerViewModel.swift b/Music/ViewModels/PlayerViewModel.swift index 55c8392..6e4d50e 100644 --- a/Music/ViewModels/PlayerViewModel.swift +++ b/Music/ViewModels/PlayerViewModel.swift @@ -1,11 +1,19 @@ import Foundation import Observation +protocol RemoteCommandSender: AnyObject { + func sendCommand(_ command: RemoteCommand) +} + @Observable final class PlayerViewModel { var currentTrack: Track? var currentIndex: Int? var isShuffled = false + var isPlaying = false + var currentTime: Double = 0 + var duration: Double = 0 + var volume: Float = 0.65 private(set) var queue: [Track] = [] private var originalQueue: [Track] = [] @@ -13,6 +21,11 @@ final class PlayerViewModel { private let db: DatabaseService? private var halfwayReported = false + private var remoteClient: RemoteCommandSender? + var trackResolver: ((Int64) -> Track?)? + + private var isRemote: Bool { remoteClient != nil } + init(audio: AudioService, db: DatabaseService?) { self.audio = audio self.db = db @@ -20,8 +33,26 @@ final class PlayerViewModel { audio.onTrackFinished = { [weak self] in self?.trackDidFinish() } + + audio.onPlaybackStateChanged = { [weak self] in + self?.syncFromAudio() + } } + // MARK: - Audio Sync + + private func syncFromAudio() { + guard !isRemote else { return } + isPlaying = audio.isPlaying + if !audio.isScrubbing { + currentTime = audio.currentTime + } + duration = audio.duration + checkHalfway() + } + + // MARK: - Queue Management + func setQueue(_ tracks: [Track]) { originalQueue = tracks if isShuffled { @@ -34,28 +65,81 @@ final class PlayerViewModel { } } + // MARK: - Playback Controls + func play(_ track: Track) { currentTrack = track currentIndex = queue.firstIndex(where: { $0.id == track.id }) halfwayReported = false + isPlaying = true + currentTime = 0 + + if let client = remoteClient { + guard let trackId = track.id else { return } + client.sendCommand(.play(trackId: trackId, queueIds: queue.compactMap(\.id))) + } else { + guard let url = URL(string: track.fileURL) else { return } + audio.play(url: url) + } + } + + func togglePlayPause() { + if isPlaying { pause() } else { resume() } + } + + func pause() { + isPlaying = false + if let client = remoteClient { client.sendCommand(.pause) } else { audio.pause() } + } + + func resume() { + isPlaying = true + if let client = remoteClient { client.sendCommand(.resume) } else { audio.resume() } + } + + func seek(to position: Double) { + currentTime = position + if let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.seek(to: position) } + } - guard let url = URL(string: track.fileURL) else { return } - audio.play(url: url) + func setVolume(_ level: Float) { + volume = level + if let client = remoteClient { client.sendCommand(.setVolume(level: level)) } else { audio.volume = level } + } + + func beginScrubbing() { + if !isRemote { audio.beginScrubbing() } + } + + func scrub(to position: Double) { + currentTime = position + if !isRemote { audio.scrub(to: position) } + } + + func endScrubbing(at position: Double) { + currentTime = position + if let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) } } func next() { + if let client = remoteClient { + client.sendCommand(.next) + return + } guard let idx = currentIndex else { return } let nextIdx = idx + 1 if nextIdx < queue.count { play(queue[nextIdx]) } else { - audio.stop() - currentTrack = nil - currentIndex = nil + stop() } } func previous() { + if let client = remoteClient { + client.sendCommand(.previous) + return + } guard let idx = currentIndex else { return } let prevIdx = max(0, idx - 1) play(queue[prevIdx]) @@ -63,6 +147,10 @@ final class PlayerViewModel { func toggleShuffle() { isShuffled.toggle() + if let client = remoteClient { + client.sendCommand(.toggleShuffle) + return + } if isShuffled { queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) } else { @@ -73,10 +161,62 @@ final class PlayerViewModel { } } + func stop() { + isPlaying = false + currentTime = 0 + duration = 0 + currentTrack = nil + currentIndex = nil + if !isRemote { audio.stop() } + } + + // MARK: - Remote Mode + + func enterRemoteMode(client: RemoteCommandSender) { + audio.stop() + remoteClient = client + currentTrack = nil + currentIndex = nil + isPlaying = false + currentTime = 0 + duration = 0 + queue = [] + originalQueue = [] + } + + func exitRemoteMode() { + remoteClient = nil + trackResolver = nil + currentTrack = nil + currentIndex = nil + isPlaying = false + currentTime = 0 + duration = 0 + queue = [] + originalQueue = [] + } + + func applyRemoteState(_ state: PlaybackStatePayload) { + guard isRemote else { return } + isPlaying = state.isPlaying + currentTime = state.currentTime + duration = state.duration + volume = state.volume + isShuffled = state.isShuffled + + if let trackId = state.trackId, currentTrack?.id != trackId { + currentTrack = trackResolver?(trackId) + } else if state.trackId == nil { + currentTrack = nil + } + } + + // MARK: - Internal + func checkHalfway() { guard !halfwayReported, - audio.duration > 0, - audio.currentTime >= audio.duration * 0.5, + duration > 0, + currentTime >= duration * 0.5, let track = currentTrack, let trackId = track.id else { return } @@ -86,6 +226,7 @@ final class PlayerViewModel { } private func trackDidFinish() { + guard !isRemote else { return } if let track = currentTrack, let trackId = track.id, !halfwayReported { let newCount = track.playCount + 1 try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())