|
|
|
|
@ -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()) |
|
|
|
|
|