| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657 |
- import Foundation
- import os
- import SwiftData
- import SwiftUI
- private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "PlayerVM")
- /// ViewModel wrapping the AudioEngine with additional UI state.
- @MainActor
- @Observable
- final class PlayerViewModel {
- let audioEngine = AudioEngine()
- let streamingPlayer = StreamingPlayer()
- // MARK: - UI State
- var showingWaveform = true
- var waveformSamples: [WaveformGenerator.WaveformSample] = []
- var isLoadingWaveform = false
- var showNowPlaying = false
- var showQueue = false
- /// ID of the currently playing playlist entry.
- var currentPlayingEntryID: UUID?
- /// The playlist currently being played through.
- var currentPlaylist: Playlist?
- /// Shuffle mode.
- var shuffleEnabled: Bool = false {
- didSet {
- if shuffleEnabled {
- upNext.shuffle()
- } else {
- rebuildUpNextFromSource()
- }
- }
- }
- /// True when playing a cloud track via StreamingPlayer (vs local via AudioEngine).
- var isCloudPlayback = false
- /// The cloud track info when streaming (non-SwiftData, transient).
- var currentCloudTrack: ChadTrack?
- var isBuffering: Bool { streamingPlayer.isBuffering }
- /// Repeat mode.
- enum RepeatMode: String, CaseIterable {
- case off = "Off"
- case all = "Repeat All"
- case one = "Repeat One"
- var icon: String {
- switch self {
- case .off: return "repeat"
- case .all: return "repeat"
- case .one: return "repeat.1"
- }
- }
- }
- var repeatMode: RepeatMode = .off
- // MARK: - Queue
- var nowPlayingEntry: QueueEntry?
- var userQueue: [QueueEntry] = []
- var upNext: [QueueEntry] = []
- var history: [QueueEntry] = []
- /// Undo state for queue replacement
- var showUndoToast = false
- var undoMessage = ""
- @ObservationIgnored private var previousQueueSnapshot: QueueSnapshot?
- @ObservationIgnored private var undoTimer: Timer?
- private struct QueueSnapshot {
- let nowPlaying: QueueEntry?
- let userQueue: [QueueEntry]
- let upNext: [QueueEntry]
- }
- /// ModelContext for resolving SwiftData track IDs — set from the view layer
- @ObservationIgnored var modelContext: ModelContext?
- // MARK: - Synced State
- var isPlaying: Bool = false
- var currentTime: TimeInterval = 0
- var duration: TimeInterval = 0
- var currentTrack: Track?
- var volume: Float {
- get { audioEngine.volume }
- set { audioEngine.volume = newValue }
- }
- var progress: Double {
- guard duration > 0 else { return 0 }
- return currentTime / duration
- }
- var currentTimeFormatted: String {
- formatTime(currentTime)
- }
- var durationFormatted: String {
- formatTime(duration)
- }
- var remainingTimeFormatted: String {
- "-" + formatTime(duration - currentTime)
- }
- @ObservationIgnored private var syncTimer: Timer?
- @ObservationIgnored private var stateSaveCounter = 0
- @ObservationIgnored private var nowPlayingCounter = 0
- init() {
- restoreQueue()
- startSyncTimer()
- audioEngine.onPlaybackFinished = { [weak self] in
- self?.playNext()
- }
- streamingPlayer.onPlaybackFinished = { [weak self] in
- self?.playNext()
- }
- }
- private func startSyncTimer() {
- syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
- Task { @MainActor in
- self?.syncFromEngine()
- }
- }
- }
- private func syncFromEngine() {
- if isCloudPlayback {
- let sp = streamingPlayer
- if isPlaying != sp.isPlaying { isPlaying = sp.isPlaying }
- if abs(currentTime - sp.currentTime) > 0.01 { currentTime = sp.currentTime }
- if duration != sp.duration { duration = sp.duration }
- } else {
- let engine = audioEngine
- engine.updateCurrentTime()
- if isPlaying != engine.isPlaying { isPlaying = engine.isPlaying }
- if abs(currentTime - engine.currentTime) > 0.01 { currentTime = engine.currentTime }
- if duration != engine.duration { duration = engine.duration }
- if currentTrack !== engine.currentTrack { currentTrack = engine.currentTrack }
- }
- stateSaveCounter += 1
- if stateSaveCounter >= 60 {
- stateSaveCounter = 0
- savePlaybackState()
- }
- nowPlayingCounter += 1
- if nowPlayingCounter >= 30 {
- nowPlayingCounter = 0
- updateNowPlaying()
- }
- }
- private func savePlaybackState() {
- AppState.savePlaybackState(
- playlistID: currentPlaylist?.id,
- entryID: currentPlayingEntryID,
- trackFilePath: currentTrack?.filePath,
- playbackTime: currentTime
- )
- }
- private func updateNowPlaying() {
- MediaKeyHandler.shared.updateNowPlaying(
- track: currentTrack,
- isPlaying: isPlaying,
- currentTime: currentTime,
- duration: duration
- )
- }
- // MARK: - Track Loading & Playback
- func loadAndPlay(_ track: Track, entryID: UUID? = nil, playlist: Playlist? = nil) {
- // Cloud track — route to StreamingPlayer
- if track.isCloud, let streamPath = track.cloudStreamPath {
- let client = ChadMusicAPIClient.shared
- guard let url = client.streamURL(for: streamPath) else {
- logger.error("loadAndPlay: Failed to build stream URL for cloud track")
- return
- }
- // Stop local playback
- audioEngine.stop()
- waveformSamples = []
- isCloudPlayback = true
- currentCloudTrack = nil
- currentTrack = track
- currentPlayingEntryID = entryID
- if let playlist { currentPlaylist = playlist }
- streamingPlayer.loadAndPlay(
- track: ChadTrack(
- id: track.cloudTrackId ?? "",
- title: track.title,
- artist: track.artist,
- albumArtist: nil,
- album: track.album,
- duration: track.duration,
- no: nil,
- url: streamPath,
- bitRate: nil,
- year: track.year,
- cover: nil
- ),
- streamURL: url,
- authHeaders: client.authHeaders
- )
- syncFromEngine()
- return
- }
- // Local track — use AudioEngine
- if isCloudPlayback {
- streamingPlayer.stop()
- isCloudPlayback = false
- currentCloudTrack = nil
- }
- do {
- logger.notice("loadAndPlay: \(track.title) path=\(track.filePath)")
- logger.notice("loadAndPlay: fileURL=\(track.fileURL.path)")
- logger.notice("loadAndPlay: exists=\(FileManager.default.fileExists(atPath: track.fileURL.path))")
- try audioEngine.loadTrack(track)
- audioEngine.play()
- currentPlayingEntryID = entryID
- if let playlist { currentPlaylist = playlist }
- syncFromEngine()
- savePlaybackState()
- loadWaveform(for: track)
- resetSkipCounter()
- logger.notice("loadAndPlay: success, playing")
- } catch {
- logger.error("loadAndPlay: FAILED: \(error)")
- print("PlayerViewModel: Failed to load track: \(error)")
- }
- }
- /// Play a cloud track directly via StreamingPlayer (from CloudBrowserView).
- func loadAndPlayCloud(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
- // Stop local playback first
- audioEngine.stop()
- currentTrack = nil
- currentPlayingEntryID = nil
- currentPlaylist = nil
- waveformSamples = []
- isCloudPlayback = true
- currentCloudTrack = track
- streamingPlayer.loadAndPlay(track: track, streamURL: streamURL, authHeaders: authHeaders)
- resetSkipCounter()
- syncFromEngine()
- }
- func togglePlayPause() {
- if isCloudPlayback {
- streamingPlayer.togglePlayPause()
- } else {
- audioEngine.togglePlayPause()
- }
- syncFromEngine()
- }
- func stop() {
- if isCloudPlayback {
- streamingPlayer.stop()
- isCloudPlayback = false
- currentCloudTrack = nil
- }
- audioEngine.stop()
- waveformSamples = []
- currentPlayingEntryID = nil
- syncFromEngine()
- }
- // MARK: - Playlist Navigation (Queue-based)
- func playNext() {
- // Repeat One: replay current
- if repeatMode == .one, let current = nowPlayingEntry {
- playQueueEntry(current)
- return
- }
- // Push current to history
- if let current = nowPlayingEntry {
- history.insert(current, at: 0)
- if history.count > 50 { history = Array(history.prefix(50)) }
- }
- // Pop from userQueue first, then upNext
- if !userQueue.isEmpty {
- let next = userQueue.removeFirst()
- nowPlayingEntry = next
- playQueueEntry(next)
- persistQueue()
- return
- }
- if !upNext.isEmpty {
- let next = upNext.removeFirst()
- nowPlayingEntry = next
- playQueueEntry(next)
- persistQueue()
- return
- }
- // Both empty — check repeat all
- if repeatMode == .all, let playlist = currentPlaylist {
- rebuildUpNextFromPlaylist(playlist, afterEntryID: nil)
- if !upNext.isEmpty {
- let next = upNext.removeFirst()
- nowPlayingEntry = next
- playQueueEntry(next)
- persistQueue()
- return
- }
- }
- // Nothing left
- nowPlayingEntry = nil
- stop()
- persistQueue()
- }
- func playPrevious() {
- if currentTime > 3 {
- seek(to: 0)
- return
- }
- guard !history.isEmpty else { return }
- let prev = history.removeFirst()
- // Push current nowPlaying to front of upNext
- if let current = nowPlayingEntry {
- upNext.insert(current, at: 0)
- }
- nowPlayingEntry = prev
- playQueueEntry(prev)
- persistQueue()
- }
- // MARK: - Queue Actions
- /// Add entry to end of user queue.
- func addToQueue(_ entry: QueueEntry) {
- userQueue.append(entry)
- persistQueue()
- // Auto-play if nothing is currently playing
- if !isPlaying {
- playNext()
- }
- }
- /// Insert entry at front of user queue ("Play Next").
- func playNextInQueue(_ entry: QueueEntry) {
- userQueue.insert(entry, at: 0)
- persistQueue()
- }
- /// Play a track from playlist context — sets as nowPlaying, fills upNext with remainder.
- func playFromPlaylist(track: Track, entryID: UUID, playlist: Playlist) {
- let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty
- if hadQueue {
- previousQueueSnapshot = QueueSnapshot(
- nowPlaying: nowPlayingEntry,
- userQueue: userQueue,
- upNext: upNext
- )
- }
- let entry = QueueEntry.from(track: track)
- nowPlayingEntry = entry
- userQueue = []
- history = []
- rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID)
- currentPlaylist = playlist
- currentPlayingEntryID = entryID
- playQueueEntry(entry)
- persistQueue()
- if hadQueue {
- showUndoToastBriefly("Queue replaced")
- }
- }
- /// Play a cloud track directly — sets as nowPlaying, clears upNext.
- func playCloudTrackDirectly(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
- let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty
- if hadQueue {
- previousQueueSnapshot = QueueSnapshot(
- nowPlaying: nowPlayingEntry,
- userQueue: userQueue,
- upNext: upNext
- )
- }
- let entry = QueueEntry.from(cloudTrack: track)
- nowPlayingEntry = entry
- userQueue = []
- upNext = []
- history = []
- currentPlaylist = nil
- currentPlayingEntryID = nil
- loadAndPlayCloud(track, streamURL: streamURL, authHeaders: authHeaders)
- persistQueue()
- if hadQueue {
- showUndoToastBriefly("Queue replaced")
- }
- }
- func removeFromQueue(entry: QueueEntry) {
- userQueue.removeAll { $0.id == entry.id }
- upNext.removeAll { $0.id == entry.id }
- persistQueue()
- }
- func clearQueue() {
- userQueue.removeAll()
- upNext.removeAll()
- persistQueue()
- }
- func moveUserQueueEntry(from source: IndexSet, to destination: Int) {
- userQueue.move(fromOffsets: source, toOffset: destination)
- persistQueue()
- }
- func moveUpNextEntry(from source: IndexSet, to destination: Int) {
- upNext.move(fromOffsets: source, toOffset: destination)
- persistQueue()
- }
- func undoQueueReplacement() {
- guard let snapshot = previousQueueSnapshot else { return }
- nowPlayingEntry = snapshot.nowPlaying
- userQueue = snapshot.userQueue
- upNext = snapshot.upNext
- previousQueueSnapshot = nil
- showUndoToast = false
- undoTimer?.invalidate()
- if let entry = nowPlayingEntry {
- playQueueEntry(entry)
- }
- persistQueue()
- }
- // MARK: - Queue Entry Playback
- private func playQueueEntry(_ entry: QueueEntry) {
- switch entry.source {
- case .swiftDataTrack(let trackPersistentID, let isCloud, let cloudStreamPath):
- guard let ctx = modelContext else {
- logger.error("playQueueEntry: no modelContext set")
- return
- }
- // Fetch track by UUID string
- guard let trackID = UUID(uuidString: trackPersistentID) else {
- logger.error("playQueueEntry: invalid UUID string: \(trackPersistentID)")
- skipBrokenEntry()
- return
- }
- let descriptor = FetchDescriptor<Track>(
- predicate: #Predicate<Track> { $0.id == trackID }
- )
- guard let track = (try? ctx.fetch(descriptor))?.first else {
- logger.error("playQueueEntry: track not found for ID \(trackPersistentID)")
- skipBrokenEntry()
- return
- }
- loadAndPlay(track, entryID: currentPlayingEntryID, playlist: currentPlaylist)
- case .cloudDirect(_, let streamPath):
- let client = ChadMusicAPIClient.shared
- guard let url = client.streamURL(for: streamPath) else {
- logger.error("playQueueEntry: failed to build stream URL for \(streamPath)")
- skipBrokenEntry()
- return
- }
- let chadTrack = ChadTrack(
- id: entry.id.uuidString,
- title: entry.title,
- artist: entry.artist,
- albumArtist: nil,
- album: nil,
- duration: entry.duration,
- no: nil,
- url: streamPath,
- bitRate: nil,
- year: nil,
- cover: nil
- )
- loadAndPlayCloud(chadTrack, streamURL: url, authHeaders: client.authHeaders)
- }
- }
- @ObservationIgnored private var skipCount = 0
- private static let maxSkips = 20
- private func skipBrokenEntry() {
- skipCount += 1
- guard skipCount <= Self.maxSkips else {
- logger.error("skipBrokenEntry: exceeded \(Self.maxSkips) skips, stopping to prevent infinite loop")
- skipCount = 0
- userQueue.removeAll()
- upNext.removeAll()
- stop()
- return
- }
- playNext()
- }
- /// Reset skip counter on successful playback start
- private func resetSkipCounter() {
- skipCount = 0
- }
- // MARK: - Queue Helpers
- private func rebuildUpNextFromPlaylist(_ playlist: Playlist, afterEntryID: UUID?) {
- let entries = playlist.sortedEntries
- var startIndex = 0
- if let afterID = afterEntryID,
- let idx = entries.firstIndex(where: { $0.id == afterID }) {
- startIndex = idx + 1
- }
- upNext = entries[startIndex...].compactMap { entry -> QueueEntry? in
- guard let track = entry.track else { return nil }
- return QueueEntry.from(track: track)
- }
- if shuffleEnabled {
- upNext.shuffle()
- }
- }
- private func rebuildUpNextFromSource() {
- guard let playlist = currentPlaylist else { return }
- rebuildUpNextFromPlaylist(playlist, afterEntryID: currentPlayingEntryID)
- }
- private func showUndoToastBriefly(_ message: String) {
- undoMessage = message
- showUndoToast = true
- undoTimer?.invalidate()
- undoTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { [weak self] _ in
- Task { @MainActor in
- self?.showUndoToast = false
- self?.previousQueueSnapshot = nil
- }
- }
- }
- // MARK: - Queue Persistence
- private static let queueKey = "mixboard.queueState"
- private struct PersistedQueue: Codable {
- let nowPlaying: QueueEntry?
- let userQueue: [QueueEntry]
- let upNext: [QueueEntry]
- }
- private func persistQueue() {
- let state = PersistedQueue(nowPlaying: nowPlayingEntry, userQueue: userQueue, upNext: upNext)
- if let data = try? JSONEncoder().encode(state) {
- UserDefaults.standard.set(data, forKey: Self.queueKey)
- }
- }
- private func restoreQueue() {
- guard let data = UserDefaults.standard.data(forKey: Self.queueKey),
- let state = try? JSONDecoder().decode(PersistedQueue.self, from: data) else { return }
- nowPlayingEntry = state.nowPlaying
- userQueue = state.userQueue
- upNext = state.upNext
- }
- func seek(to time: TimeInterval) {
- if isCloudPlayback {
- streamingPlayer.seek(to: time)
- } else {
- audioEngine.seek(to: time)
- }
- }
- func seekToProgress(_ progress: Double) {
- let time = progress * duration
- seek(to: time)
- }
- func skipForward(_ seconds: TimeInterval = 10) {
- if isCloudPlayback {
- streamingPlayer.seek(by: seconds)
- } else {
- audioEngine.seek(by: seconds)
- }
- }
- func skipBackward(_ seconds: TimeInterval = 10) {
- if isCloudPlayback {
- streamingPlayer.seek(by: -seconds)
- } else {
- audioEngine.seek(by: -seconds)
- }
- }
- // MARK: - Waveform
- func loadWaveform(for track: Track) {
- if let cached = track.waveformData,
- let decoded = WaveformGenerator.decodeCachedWaveform(from: cached) {
- waveformSamples = decoded
- return
- }
- isLoadingWaveform = true
- Task {
- do {
- let samples = try await WaveformGenerator.generateWaveform(for: track)
- waveformSamples = samples
- } catch {
- print("PlayerViewModel: Waveform generation failed: \(error)")
- waveformSamples = []
- }
- isLoadingWaveform = false
- }
- }
- // MARK: - Helpers
- private func formatTime(_ time: TimeInterval) -> String {
- let total = max(0, Int(time))
- let minutes = total / 60
- let seconds = total % 60
- return String(format: "%d:%02d", minutes, seconds)
- }
- }
|