refactor: make PlayerViewModel single source of truth for all playback state

feat/music-streaming
Laurent 1 month ago
parent c3b97eb201
commit 4fa431e9bd
  1. 7
      Music/Services/AudioService.swift
  2. 151
      Music/ViewModels/PlayerViewModel.swift

@ -25,6 +25,7 @@ final class AudioService {
private var pendingSeekTime: Double? private var pendingSeekTime: Double?
var onTrackFinished: (() -> Void)? var onTrackFinished: (() -> Void)?
var onPlaybackStateChanged: (() -> Void)?
func play(url: URL) { func play(url: URL) {
cleanup() cleanup()
@ -42,6 +43,7 @@ final class AudioService {
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite { if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite {
self.duration = dur.seconds self.duration = dur.seconds
} }
self.onPlaybackStateChanged?()
} }
endObserver = NotificationCenter.default.addObserver( endObserver = NotificationCenter.default.addObserver(
@ -51,21 +53,25 @@ final class AudioService {
) { [weak self] _ in ) { [weak self] _ in
self?.isPlaying = false self?.isPlaying = false
self?.currentTime = 0 self?.currentTime = 0
self?.onPlaybackStateChanged?()
self?.onTrackFinished?() self?.onTrackFinished?()
} }
player?.play() player?.play()
isPlaying = true isPlaying = true
onPlaybackStateChanged?()
} }
func pause() { func pause() {
player?.pause() player?.pause()
isPlaying = false isPlaying = false
onPlaybackStateChanged?()
} }
func resume() { func resume() {
player?.play() player?.play()
isPlaying = true isPlaying = true
onPlaybackStateChanged?()
} }
func togglePlayPause() { func togglePlayPause() {
@ -144,6 +150,7 @@ final class AudioService {
isPlaying = false isPlaying = false
currentTime = 0 currentTime = 0
duration = 0 duration = 0
onPlaybackStateChanged?()
} }
private func cleanup() { private func cleanup() {

@ -1,11 +1,19 @@
import Foundation import Foundation
import Observation import Observation
protocol RemoteCommandSender: AnyObject {
func sendCommand(_ command: RemoteCommand)
}
@Observable @Observable
final class PlayerViewModel { final class PlayerViewModel {
var currentTrack: Track? var currentTrack: Track?
var currentIndex: Int? var currentIndex: Int?
var isShuffled = false 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(set) var queue: [Track] = []
private var originalQueue: [Track] = [] private var originalQueue: [Track] = []
@ -13,6 +21,11 @@ final class PlayerViewModel {
private let db: DatabaseService? private let db: DatabaseService?
private var halfwayReported = false private var halfwayReported = false
private var remoteClient: RemoteCommandSender?
var trackResolver: ((Int64) -> Track?)?
private var isRemote: Bool { remoteClient != nil }
init(audio: AudioService, db: DatabaseService?) { init(audio: AudioService, db: DatabaseService?) {
self.audio = audio self.audio = audio
self.db = db self.db = db
@ -20,7 +33,25 @@ final class PlayerViewModel {
audio.onTrackFinished = { [weak self] in audio.onTrackFinished = { [weak self] in
self?.trackDidFinish() 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]) { func setQueue(_ tracks: [Track]) {
originalQueue = tracks originalQueue = tracks
@ -34,28 +65,81 @@ final class PlayerViewModel {
} }
} }
// MARK: - Playback Controls
func play(_ track: Track) { func play(_ track: Track) {
currentTrack = track currentTrack = track
currentIndex = queue.firstIndex(where: { $0.id == track.id }) currentIndex = queue.firstIndex(where: { $0.id == track.id })
halfwayReported = false 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 } guard let url = URL(string: track.fileURL) else { return }
audio.play(url: url) 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) }
}
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() { func next() {
if let client = remoteClient {
client.sendCommand(.next)
return
}
guard let idx = currentIndex else { return } guard let idx = currentIndex else { return }
let nextIdx = idx + 1 let nextIdx = idx + 1
if nextIdx < queue.count { if nextIdx < queue.count {
play(queue[nextIdx]) play(queue[nextIdx])
} else { } else {
audio.stop() stop()
currentTrack = nil
currentIndex = nil
} }
} }
func previous() { func previous() {
if let client = remoteClient {
client.sendCommand(.previous)
return
}
guard let idx = currentIndex else { return } guard let idx = currentIndex else { return }
let prevIdx = max(0, idx - 1) let prevIdx = max(0, idx - 1)
play(queue[prevIdx]) play(queue[prevIdx])
@ -63,6 +147,10 @@ final class PlayerViewModel {
func toggleShuffle() { func toggleShuffle() {
isShuffled.toggle() isShuffled.toggle()
if let client = remoteClient {
client.sendCommand(.toggleShuffle)
return
}
if isShuffled { if isShuffled {
queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack)
} else { } 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() { func checkHalfway() {
guard !halfwayReported, guard !halfwayReported,
audio.duration > 0, duration > 0,
audio.currentTime >= audio.duration * 0.5, currentTime >= duration * 0.5,
let track = currentTrack, let track = currentTrack,
let trackId = track.id else { return } let trackId = track.id else { return }
@ -86,6 +226,7 @@ final class PlayerViewModel {
} }
private func trackDidFinish() { private func trackDidFinish() {
guard !isRemote else { return }
if let track = currentTrack, let trackId = track.id, !halfwayReported { if let track = currentTrack, let trackId = track.id, !halfwayReported {
let newCount = track.playCount + 1 let newCount = track.playCount + 1
try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())

Loading…
Cancel
Save