import Foundation import Combine /// Syncs the current play queue to the Subsonic server for cross-device resume. /// /// Flow: /// 1. AudioPlayer changes song → notifies via Combine publisher /// 2. PlayQueueSyncManager debounces (10s) and saves to server /// 3. On app launch, if local queue is empty, offers to restore from server /// /// The server queue is a single shared state — all devices read/write it. /// Last-write-wins, which matches user expectation (most recent device is active). @MainActor class PlayQueueSyncManager: ObservableObject { static let shared = PlayQueueSyncManager() @Published var isEnabled: Bool { didSet { UserDefaults.standard.set(isEnabled, forKey: "playqueue_sync_enabled") } } @Published var serverQueue: PlayQueue? @Published var hasPendingRestore = false private var cancellables = Set() private var saveTask: Task? private var lastSavedSongId: String? private init() { isEnabled = UserDefaults.standard.bool(forKey: "playqueue_sync_enabled") } /// Call once from app launch. Starts observing AudioPlayer for queue changes. func start() { guard isEnabled else { return } // Observe song changes — debounce 10s before saving to server AudioPlayer.shared.$currentSong .compactMap { $0 } .removeDuplicates { $0.id == $1.id } .debounce(for: .seconds(10), scheduler: DispatchQueue.main) .sink { [weak self] song in guard let self, self.isEnabled else { return } Task { await self.saveToServer() } } .store(in: &cancellables) } /// Stop observing — call when disabling sync or on logout. func stop() { cancellables.removeAll() saveTask?.cancel() saveTask = nil } // MARK: - Save to Server /// Save the current queue + position to the Subsonic server. /// Debounced — safe to call frequently. Skips if nothing changed. func saveToServer() async { let player = AudioPlayer.shared guard isEnabled, !player.queue.isEmpty, let current = player.currentSong else { return } // Skip if same song as last save (no actual change) if current.id == lastSavedSongId { return } let ids = player.queue.map { $0.id } let positionMs = Int64(player.currentTime * 1000) do { try await ServerManager.shared.client.savePlayQueue( songIds: ids, current: current.id, position: positionMs ) lastSavedSongId = current.id #if DEBUG print("[QueueSync] Saved: \(ids.count) songs, current=\(current.title), pos=\(positionMs)ms") #endif } catch { // Non-fatal — server queue sync is best-effort print("[QueueSync] Save failed: \(error.localizedDescription)") } } // MARK: - Restore from Server /// Check the server for a saved queue from another device. /// Sets `hasPendingRestore` if a valid queue is found and local queue is empty. func checkServerQueue() async { guard isEnabled else { return } let player = AudioPlayer.shared // Only offer restore if no local playback state guard player.currentSong == nil, player.queue.isEmpty else { return } do { guard let pq = try await ServerManager.shared.client.getPlayQueue(), let entries = pq.entry, !entries.isEmpty else { return } serverQueue = pq hasPendingRestore = true DebugLogger.shared.log( "Server queue found: \(entries.count) songs, current=\(pq.current ?? "?"), changed by \(pq.changedBy ?? "?")", category: "QueueSync" ) } catch { // Non-fatal — just means no server queue or server doesn't support it print("[QueueSync] Check failed: \(error.localizedDescription)") } } /// Restore playback from the server queue. Starts paused at the saved position. func restoreFromServer() { guard let pq = serverQueue, let entries = pq.entry, !entries.isEmpty else { return } let player = AudioPlayer.shared let currentId = pq.current ?? entries[0].id let positionSec = TimeInterval(pq.position ?? 0) / 1000.0 // Find the index of the current song let index = entries.firstIndex { $0.id == currentId } ?? 0 // Load queue without starting playback player.queue = entries player.queueIndex = index player.currentSong = entries[index] player.currentTime = positionSec DebugLogger.shared.log( "Restored server queue: \(entries.count) songs, index \(index), pos=\(Int(positionSec))s, from \(pq.changedBy ?? "unknown")", category: "QueueSync" ) hasPendingRestore = false serverQueue = nil } /// Dismiss the restore offer without restoring. func dismissRestore() { hasPendingRestore = false serverQueue = nil } }