- 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
145 lines
5.1 KiB
Swift
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
|
|
}
|
|
}
|