NavidromeApp/iOS/Data/PlayQueueSyncManager.swift
Dallas Groot 53b15aac27 Add edit history, discover, queue sync, share links
- Edit History: full timeline UI with revert capability, accessible from Issues & Conflicts
- Batch Undo: inline undo button in BatchAlbumEditorSheet + MultiAlbumEditorSheet
- Companion API: 7 bug fixes (audio.delete data loss, path-key backup, AIFF support,
  restructure backup, manifest context, double-undo prevention, single-track history)
- Recently Played: new tab between Recently Added and Favourites
- Discover: random albums scroll, shuffle all, genre quick-play chips on home tab
- Play Queue Sync: auto-saves to server, cross-device resume banner on launch
- Share Links: create + copy + share via sheet in Get Info
- Models: PlayQueue, ShareItem, EditHistoryEntry, UndoResult
- SubsonicClient: savePlayQueue, getPlayQueue, createShare endpoints
2026-04-14 11:35:52 -07:00

145 lines
5.1 KiB
Swift

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<AnyCancellable>()
private var saveTask: Task<Void, Never>?
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
}
}