feat: connected devices sync, unified queue, scrobbling, v1.2
- Unified queue model: replace history/queue split with tracks[]+currentIndex - Connected devices: fix Mac not pausing when another device becomes active - Fix applyFullState playing wrong track (stale currentTrack reference) - Fix seek in client mode (use `time` field per web client convention) - Fix seek UI snapping back with optimistic seek override - Add play count scrobbling via POST /api/player/history - Playlists tab auto-loads on switch (no retry button needed) - Fix share button to copy proper server-based track URL - Device icon color: primary when active, accent when remote - Full queue transfer between devices via transfer_state command - Bump version to 1.2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -409,7 +409,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -442,7 +442,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -190,6 +190,20 @@ struct CatalogService {
|
||||
_ = try await post(path: "api/player/devices/command", body: body)
|
||||
}
|
||||
|
||||
func recordHistory(trackId: Int64, startedAt: Int64?, durationListened: Int32, completed: Bool) async throws {
|
||||
struct Body: Encodable {
|
||||
let trackId: Int64
|
||||
let startedAt: Int64?
|
||||
let durationListened: Int32
|
||||
let completed: Bool
|
||||
}
|
||||
let body = try Self.deviceEncoder.encode(Body(
|
||||
trackId: trackId, startedAt: startedAt,
|
||||
durationListened: durationListened, completed: completed
|
||||
))
|
||||
_ = try await post(path: "api/player/history", body: body)
|
||||
}
|
||||
|
||||
func playlists() async throws -> [PlaylistCard] {
|
||||
let data = try await fetch(path: "api/player/playlists")
|
||||
return try decode([PlaylistCardDTO].self, from: data).map { $0.toDomain() }
|
||||
|
||||
@@ -35,6 +35,7 @@ struct ContentView: View {
|
||||
@State private var showingTrackInfo = false
|
||||
@State private var showingQueue = false
|
||||
@State private var showingDevices = false
|
||||
@State private var remoteSeekOverride: Double? = nil // optimistic seek for client mode
|
||||
|
||||
// ViewModels bound to session (optional)
|
||||
@State private var globalVM: GlobalArtistsViewModel?
|
||||
@@ -63,6 +64,9 @@ struct ContentView: View {
|
||||
.onChange(of: player.currentTrack?.id) { _, newId in
|
||||
isCurrentTrackLiked = newId.map { authManager.likedTrackIds.contains($0) } ?? false
|
||||
}
|
||||
.onChange(of: deviceManager.remoteState?.updatedAtMs) { _, _ in
|
||||
remoteSeekOverride = nil
|
||||
}
|
||||
.onChange(of: deviceManager.pendingTransferState != nil) { _, hasPending in
|
||||
if hasPending, let state = deviceManager.pendingTransferState {
|
||||
applyTransferState(state)
|
||||
@@ -98,6 +102,11 @@ struct ContentView: View {
|
||||
}
|
||||
Task { await authManager.loadLikedIds() }
|
||||
deviceManager.start(service: service, player: player)
|
||||
let am = authManager
|
||||
player.onTrackFinished = { trackId, startedAt, durationListened, completed in
|
||||
guard let svc = am.catalogService else { return }
|
||||
try? await svc.recordHistory(trackId: trackId, startedAt: startedAt, durationListened: durationListened, completed: completed)
|
||||
}
|
||||
let gvm = globalVM
|
||||
Task { await gvm?.loadFirstPage() }
|
||||
let pvm = playlistsVM
|
||||
@@ -187,6 +196,13 @@ struct ContentView: View {
|
||||
|
||||
nowPlayingBar
|
||||
.padding(12)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "")")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,25 +389,33 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Display helpers (local or remote state)
|
||||
|
||||
// Current track from remote state: prefer .track field, fall back to tracks[index]
|
||||
private var displayRemoteTrack: DeviceTrackInfo? {
|
||||
guard let rs = deviceManager.remoteState else { return nil }
|
||||
if let t = rs.track { return t }
|
||||
if rs.tracks.indices.contains(rs.index) { return rs.tracks[rs.index] }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var displayCoverUrl: String? {
|
||||
deviceManager.isThisDeviceActive
|
||||
? player.currentTrack?.coverUrl
|
||||
: deviceManager.remoteState?.track?.coverUrl
|
||||
: displayRemoteTrack?.coverUrl
|
||||
}
|
||||
private var displayTitle: String {
|
||||
deviceManager.isThisDeviceActive
|
||||
? (player.currentTrack?.title ?? "—")
|
||||
: (deviceManager.remoteState?.track?.displayTitle ?? "—")
|
||||
: (displayRemoteTrack?.displayTitle ?? "—")
|
||||
}
|
||||
private var displayArtist: String {
|
||||
deviceManager.isThisDeviceActive
|
||||
? (player.currentTrack?.artistNames ?? "—")
|
||||
: (deviceManager.remoteState?.track?.displayArtists ?? "—")
|
||||
: (displayRemoteTrack?.displayArtists ?? "—")
|
||||
}
|
||||
private var displayCurrentTime: Double {
|
||||
deviceManager.isThisDeviceActive
|
||||
? player.currentTime
|
||||
: (deviceManager.remoteState?.estimatedPositionSeconds ?? 0)
|
||||
if deviceManager.isThisDeviceActive { return player.currentTime }
|
||||
if let override = remoteSeekOverride { return override }
|
||||
return deviceManager.remoteState?.estimatedPositionSeconds ?? 0
|
||||
}
|
||||
private var displayDuration: Double {
|
||||
deviceManager.isThisDeviceActive
|
||||
@@ -409,7 +433,7 @@ struct ContentView: View {
|
||||
: (deviceManager.remoteState.map { !$0.paused } ?? false)
|
||||
}
|
||||
private var displayHasTrack: Bool {
|
||||
deviceManager.isThisDeviceActive ? player.currentTrack != nil : deviceManager.remoteState?.track != nil
|
||||
deviceManager.isThisDeviceActive ? player.currentTrack != nil : displayRemoteTrack != nil
|
||||
}
|
||||
|
||||
private func formatBarTime(_ s: Double) -> String {
|
||||
@@ -445,7 +469,7 @@ struct ContentView: View {
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(deviceManager.isThisDeviceActive ? .secondary : .accentColor)
|
||||
.foregroundColor(deviceManager.isThisDeviceActive ? .primary : .accentColor)
|
||||
.popover(isPresented: $showingDevices, arrowEdge: .bottom) {
|
||||
DevicesView(deviceManager: deviceManager)
|
||||
}
|
||||
@@ -464,7 +488,8 @@ struct ContentView: View {
|
||||
player.seek(to: fraction)
|
||||
} else {
|
||||
let pos = fraction * displayDuration
|
||||
Task { await deviceManager.sendCommand("seek", payload: ["position_seconds": pos]) }
|
||||
remoteSeekOverride = pos
|
||||
Task { await deviceManager.sendCommand("seek", payload: ["time": pos]) }
|
||||
}
|
||||
}
|
||||
))
|
||||
@@ -478,7 +503,7 @@ struct ContentView: View {
|
||||
if deviceManager.isThisDeviceActive {
|
||||
player.playPrevious()
|
||||
} else {
|
||||
Task { await deviceManager.sendCommand("previous") }
|
||||
Task { await deviceManager.sendCommand("prev") }
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "backward.fill").font(.title3)
|
||||
@@ -491,7 +516,7 @@ struct ContentView: View {
|
||||
if deviceManager.isThisDeviceActive {
|
||||
player.togglePlayPause()
|
||||
} else {
|
||||
Task { await deviceManager.sendCommand(displayIsPlaying ? "pause" : "play") }
|
||||
Task { await deviceManager.sendCommand(displayIsPlaying ? "pause" : "resume") }
|
||||
}
|
||||
}) {
|
||||
Image(systemName: displayIsPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
@@ -547,9 +572,10 @@ struct ContentView: View {
|
||||
Image(systemName: "list.bullet").font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor((player.canGoNext || player.canGoPrevious) ? .accentColor : .secondary)
|
||||
.foregroundColor((player.canGoNext || player.canGoPrevious || !deviceManager.isThisDeviceActive) ? .accentColor : .secondary)
|
||||
.popover(isPresented: $showingQueue, arrowEdge: .bottom) {
|
||||
QueueView(player: player)
|
||||
QueueView(player: player, deviceManager: deviceManager)
|
||||
.environment(authManager)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,7 +595,7 @@ struct ContentView: View {
|
||||
if deviceManager.isThisDeviceActive {
|
||||
player.volume = Float(newVol)
|
||||
} else {
|
||||
Task { await deviceManager.sendCommand("volume", payload: ["volume": newVol]) }
|
||||
Task { await deviceManager.sendCommand("set_volume", payload: ["volume": newVol]) }
|
||||
}
|
||||
}
|
||||
),
|
||||
@@ -578,6 +604,7 @@ struct ContentView: View {
|
||||
.frame(width: 64).tint(.secondary).controlSize(.mini)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,11 +619,7 @@ struct ContentView: View {
|
||||
guard let auth = authManager.session?.tokens.authorizationHeader else { return }
|
||||
player.play(track: track, authHeader: auth)
|
||||
} else {
|
||||
Task {
|
||||
// Clear remote queue then play
|
||||
await deviceManager.sendTrackCommand("queue_add_next", track: track)
|
||||
await deviceManager.sendCommand("next")
|
||||
}
|
||||
Task { await deviceManager.sendTrackCommand("play_track", track: track) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,12 +641,27 @@ struct ContentView: View {
|
||||
|
||||
private func applyTransferState(_ state: DevicePlaybackState) {
|
||||
guard let baseUrl = authManager.session?.serverBaseUrl,
|
||||
let auth = authManager.session?.tokens.authorizationHeader,
|
||||
let trackInfo = state.track,
|
||||
let track = trackInfo.toTrackCard(baseUrl: baseUrl) else { return }
|
||||
player.play(track: track, authHeader: auth)
|
||||
let queueTracks = state.tracks.compactMap { $0.toTrackCard(baseUrl: baseUrl) }
|
||||
player.setQueue(queueTracks)
|
||||
let auth = authManager.session?.tokens.authorizationHeader else { return }
|
||||
|
||||
let allTracks = state.tracks.compactMap { $0.toTrackCard(baseUrl: baseUrl) }
|
||||
let idx = state.index
|
||||
|
||||
// Resolve current track: prefer explicit .track field, fall back to tracks[index]
|
||||
let currentTrack: TrackCard?
|
||||
if let info = state.track {
|
||||
currentTrack = info.toTrackCard(baseUrl: baseUrl)
|
||||
} else if allTracks.indices.contains(idx) {
|
||||
currentTrack = allTracks[idx]
|
||||
} else {
|
||||
return
|
||||
}
|
||||
guard let track = currentTrack else { return }
|
||||
|
||||
let finalTracks = allTracks.isEmpty ? [track] : allTracks
|
||||
let finalIdx = allTracks.isEmpty ? 0 : idx
|
||||
|
||||
player.applyFullState(tracks: finalTracks, index: finalIdx, authHeader: auth)
|
||||
|
||||
if state.positionSeconds > 1, state.durationSeconds > 0 {
|
||||
let fraction = state.positionSeconds / state.durationSeconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
@@ -655,9 +693,11 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func shareCurrentTrack() {
|
||||
guard let track = player.currentTrack else { return }
|
||||
guard let track = player.currentTrack,
|
||||
let baseUrl = authManager.session?.serverBaseUrl else { return }
|
||||
let url = "\(baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/share/track/\(track.id)"
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString("\(track.title) — \(track.artistNames)", forType: .string)
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ final class DeviceManager {
|
||||
|
||||
private var service: CatalogService?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private weak var playerRef: PlayerManager?
|
||||
private let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.keyDecodingStrategy = .convertFromSnakeCase
|
||||
@@ -54,6 +55,7 @@ final class DeviceManager {
|
||||
|
||||
func start(service: CatalogService, player: PlayerManager) {
|
||||
self.service = service
|
||||
self.playerRef = player
|
||||
stopPolling()
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
@@ -66,6 +68,7 @@ final class DeviceManager {
|
||||
func stop() {
|
||||
stopPolling()
|
||||
service = nil
|
||||
playerRef = nil
|
||||
devices = []
|
||||
activeDeviceId = nil
|
||||
remoteState = nil
|
||||
@@ -97,12 +100,17 @@ final class DeviceManager {
|
||||
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
|
||||
isConnected = true
|
||||
devices = response.devices.map { $0.toDomain() }
|
||||
let wasActive = isThisDeviceActive
|
||||
activeDeviceId = response.activeDeviceId
|
||||
|
||||
if isThisDeviceActive {
|
||||
remoteState = nil
|
||||
} else {
|
||||
remoteState = response.playbackState
|
||||
// Pause local playback when this device is no longer active
|
||||
if wasActive {
|
||||
player.pauseIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
for cmd in response.commands {
|
||||
@@ -115,8 +123,7 @@ final class DeviceManager {
|
||||
private func executeCommand(_ cmd: PlayerDeviceCommandDTO, player: PlayerManager) {
|
||||
let p = cmd.payload
|
||||
switch cmd.command {
|
||||
case "transfer_state":
|
||||
// Server set us as active and sent state to restore
|
||||
case "transfer_state", "play_track":
|
||||
pendingTransferState = DevicePlaybackState(
|
||||
track: p.track,
|
||||
tracks: p.tracks ?? [],
|
||||
@@ -129,22 +136,43 @@ final class DeviceManager {
|
||||
volume: p.volume ?? Double(player.volume),
|
||||
updatedAtMs: p.updatedAtMs ?? 0
|
||||
)
|
||||
case "play":
|
||||
if !player.isPlaying { player.togglePlayPause() }
|
||||
case "resume", "play":
|
||||
player.resumeIfNeeded()
|
||||
case "pause":
|
||||
if player.isPlaying { player.togglePlayPause() }
|
||||
player.pauseIfNeeded()
|
||||
case "next":
|
||||
if player.canGoNext { player.playNext() }
|
||||
case "previous":
|
||||
case "prev", "previous":
|
||||
if player.canGoPrevious { player.playPrevious() }
|
||||
case "seek":
|
||||
if let pos = p.positionSeconds, player.duration > 0 {
|
||||
if let pos = p.time ?? p.positionSeconds, player.duration > 0 {
|
||||
player.seek(to: pos / player.duration)
|
||||
}
|
||||
case "volume":
|
||||
case "set_volume", "volume":
|
||||
if let vol = p.volume {
|
||||
player.volume = Float(vol)
|
||||
}
|
||||
case "play_from_index":
|
||||
if let idx = p.index {
|
||||
if let infos = p.tracks, !infos.isEmpty, let baseUrl = service?.baseUrl {
|
||||
let cards = infos.compactMap { $0.toTrackCard(baseUrl: baseUrl) }
|
||||
if !cards.isEmpty {
|
||||
player.applyFullState(tracks: cards, index: idx, authHeader: player.lastAuthHeader)
|
||||
return
|
||||
}
|
||||
}
|
||||
player.playFromIndex(idx)
|
||||
}
|
||||
case "queue_add_end":
|
||||
if let info = p.track, let baseUrl = service?.baseUrl,
|
||||
let card = info.toTrackCard(baseUrl: baseUrl) {
|
||||
player.addToQueue(card)
|
||||
}
|
||||
case "queue_add_next":
|
||||
if let info = p.track, let baseUrl = service?.baseUrl,
|
||||
let card = info.toTrackCard(baseUrl: baseUrl) {
|
||||
player.addToQueueNext(card)
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
@@ -162,7 +190,11 @@ final class DeviceManager {
|
||||
let response = try decoder.decode(DevicesResponseDTO.self, from: data)
|
||||
devices = response.devices.map { $0.toDomain() }
|
||||
activeDeviceId = response.activeDeviceId
|
||||
if isThisDeviceActive { remoteState = nil }
|
||||
if isThisDeviceActive {
|
||||
remoteState = nil
|
||||
} else {
|
||||
playerRef?.pauseIfNeeded()
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
@@ -173,18 +205,37 @@ final class DeviceManager {
|
||||
}
|
||||
|
||||
func sendTrackCommand(_ command: String, track: TrackCard) async {
|
||||
let trackDict: [String: Any] = [
|
||||
let d = buildTrackDict(track)
|
||||
await sendCommand(command, payload: ["track": d, "tracks": [d], "index": 0])
|
||||
}
|
||||
|
||||
func sendPlayFromIndex(tracks: [TrackCard], index: Int) async {
|
||||
let tracksPayload = tracks.map { buildTrackDict($0) }
|
||||
var payload: [String: Any] = [
|
||||
"tracks": tracksPayload,
|
||||
"index": index,
|
||||
"position_seconds": 0.0,
|
||||
"paused": false
|
||||
]
|
||||
if tracks.indices.contains(index) {
|
||||
payload["track"] = buildTrackDict(tracks[index])
|
||||
}
|
||||
await sendCommand("play_from_index", payload: payload)
|
||||
}
|
||||
|
||||
private func buildTrackDict(_ track: TrackCard) -> [String: Any] {
|
||||
var d: [String: Any] = [
|
||||
"id": track.id,
|
||||
"title": track.title,
|
||||
"duration_seconds": Double(track.durationSeconds),
|
||||
"stream_url": track.streamUrl,
|
||||
"cover_url": track.coverUrl as Any,
|
||||
"release_id": track.releaseId,
|
||||
"release_title": track.releaseTitle,
|
||||
"artists": track.artistNames.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.map { ["id": 0, "name": $0] as [String: Any] }
|
||||
]
|
||||
await sendCommand(command, payload: ["tracks": [trackDict]])
|
||||
if let coverUrl = track.coverUrl { d["cover_url"] = coverUrl }
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ struct DeviceTrackInfo: Codable {
|
||||
discNumber: nil,
|
||||
durationSeconds: Int((durationSeconds ?? 0).rounded()),
|
||||
artistNames: artists?.map(\.name).joined(separator: ", ") ?? "",
|
||||
coverUrl: coverUrl,
|
||||
coverUrl: coverUrl.map { URLResolver.absolute(from: $0, baseUrl: baseUrl) },
|
||||
streamUrl: URLResolver.absolute(from: streamUrl, baseUrl: baseUrl),
|
||||
releaseId: releaseId ?? 0,
|
||||
releaseTitle: releaseTitle ?? "",
|
||||
@@ -85,8 +85,8 @@ struct DevicePlaybackState: Codable {
|
||||
guard let track = player.currentTrack else { return nil }
|
||||
return DevicePlaybackState(
|
||||
track: DeviceTrackInfo(from: track),
|
||||
tracks: player.queue.map { DeviceTrackInfo(from: $0) },
|
||||
index: 0,
|
||||
tracks: player.tracks.map { DeviceTrackInfo(from: $0) },
|
||||
index: max(0, player.currentIndex),
|
||||
positionSeconds: player.currentTime,
|
||||
durationSeconds: player.duration,
|
||||
paused: !player.isPlaying,
|
||||
@@ -112,6 +112,8 @@ struct CommandPayload: Decodable {
|
||||
var shuffle: Bool?
|
||||
var repeatMode: String?
|
||||
var updatedAtMs: Int64?
|
||||
// seek uses "time" (web client convention)
|
||||
var time: Double?
|
||||
}
|
||||
|
||||
// MARK: - DTOs from server
|
||||
|
||||
@@ -16,8 +16,9 @@ final class PlayerManager {
|
||||
private(set) var isPlaying = false
|
||||
private(set) var currentTime: Double = 0
|
||||
private(set) var duration: Double = 0
|
||||
private(set) var queue: [TrackCard] = []
|
||||
private(set) var history: [TrackCard] = []
|
||||
/// Full unified queue: played (0..<currentIndex) + current (currentIndex) + upcoming (currentIndex+1...)
|
||||
private(set) var tracks: [TrackCard] = []
|
||||
private(set) var currentIndex: Int = -1
|
||||
|
||||
var volume: Float = {
|
||||
let v = UserDefaults.standard.float(forKey: "player.volume")
|
||||
@@ -32,13 +33,16 @@ final class PlayerManager {
|
||||
var progress: Double {
|
||||
duration > 0 ? min(currentTime / duration, 1) : 0
|
||||
}
|
||||
var canGoNext: Bool { !queue.isEmpty }
|
||||
var canGoPrevious: Bool { !history.isEmpty }
|
||||
var canGoNext: Bool { currentIndex < tracks.count - 1 }
|
||||
var canGoPrevious: Bool { currentIndex > 0 }
|
||||
var formattedCurrentTime: String { formatTime(currentTime) }
|
||||
|
||||
var onTrackFinished: ((Int64, Int64?, Int32, Bool) async -> Void)?
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var timeObserver: Any?
|
||||
private var lastAuthHeader = ""
|
||||
private(set) var lastAuthHeader = ""
|
||||
private var trackStartedAt: Date?
|
||||
|
||||
init() {
|
||||
setupRemoteCommands()
|
||||
@@ -46,7 +50,16 @@ final class PlayerManager {
|
||||
|
||||
// MARK: - Playback
|
||||
|
||||
// Core playback — does not modify history or queue
|
||||
private func recordCurrentTrackEnd(completed: Bool) {
|
||||
guard let track = currentTrack, let startedAt = trackStartedAt else { return }
|
||||
trackStartedAt = nil
|
||||
let listened = Int32(max(0, min(currentTime, duration).rounded()))
|
||||
let startedAtUnix = Int64(startedAt.timeIntervalSince1970)
|
||||
let cb = onTrackFinished
|
||||
Task { await cb?(track.id, startedAtUnix, listened, completed) }
|
||||
}
|
||||
|
||||
// Core playback — does not modify tracks or currentIndex
|
||||
private func playCore(track: TrackCard, authHeader: String) {
|
||||
guard let url = URL(string: track.streamUrl) else { return }
|
||||
lastAuthHeader = authHeader
|
||||
@@ -64,7 +77,8 @@ final class PlayerManager {
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if !self.queue.isEmpty {
|
||||
self.recordCurrentTrackEnd(completed: true)
|
||||
if self.canGoNext {
|
||||
self.playNext()
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
@@ -84,19 +98,46 @@ final class PlayerManager {
|
||||
currentTrack = track
|
||||
currentTime = 0
|
||||
duration = Double(track.durationSeconds)
|
||||
trackStartedAt = Date()
|
||||
|
||||
addObserver()
|
||||
updateNowPlayingInfo(track: track)
|
||||
}
|
||||
|
||||
// MARK: - Public playback
|
||||
|
||||
/// Play a track. If it exists in `tracks`, jumps to its index.
|
||||
/// Otherwise appends it after the current position (truncating the future queue).
|
||||
func play(track: TrackCard, authHeader: String) {
|
||||
if let current = currentTrack {
|
||||
history.append(current)
|
||||
if history.count > 50 { history.removeFirst() }
|
||||
recordCurrentTrackEnd(completed: false)
|
||||
if let idx = tracks.firstIndex(where: { $0.id == track.id }) {
|
||||
currentIndex = idx
|
||||
} else {
|
||||
let keep = max(0, currentIndex + 1)
|
||||
tracks = Array(tracks.prefix(keep)) + [track]
|
||||
currentIndex = tracks.count - 1
|
||||
}
|
||||
playCore(track: track, authHeader: authHeader)
|
||||
}
|
||||
|
||||
/// Jump to an arbitrary index in the unified tracks array and start playing.
|
||||
func playFromIndex(_ index: Int) {
|
||||
guard tracks.indices.contains(index) else { return }
|
||||
recordCurrentTrackEnd(completed: false)
|
||||
currentIndex = index
|
||||
playCore(track: tracks[index], authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
/// Replace the full tracks array and start playing from `index` (used for transfer_state).
|
||||
func applyFullState(tracks newTracks: [TrackCard], index: Int, authHeader: String) {
|
||||
recordCurrentTrackEnd(completed: false)
|
||||
tracks = newTracks
|
||||
currentIndex = newTracks.indices.contains(index) ? index : 0
|
||||
lastAuthHeader = authHeader
|
||||
guard newTracks.indices.contains(currentIndex) else { return }
|
||||
playCore(track: newTracks[currentIndex], authHeader: authHeader)
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let player else { return }
|
||||
if isPlaying {
|
||||
@@ -109,48 +150,47 @@ final class PlayerManager {
|
||||
updateNowPlayingPlaybackState()
|
||||
}
|
||||
|
||||
func resumeIfNeeded() {
|
||||
guard currentTrack != nil, !isPlaying else { return }
|
||||
player?.play()
|
||||
isPlaying = true
|
||||
updateNowPlayingPlaybackState()
|
||||
}
|
||||
|
||||
func pauseIfNeeded() {
|
||||
guard isPlaying else { return }
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
updateNowPlayingPlaybackState()
|
||||
}
|
||||
|
||||
func playNext() {
|
||||
guard !queue.isEmpty else { return }
|
||||
if let current = currentTrack {
|
||||
history.append(current)
|
||||
if history.count > 50 { history.removeFirst() }
|
||||
}
|
||||
let next = queue.removeFirst()
|
||||
playCore(track: next, authHeader: lastAuthHeader)
|
||||
guard canGoNext else { return }
|
||||
recordCurrentTrackEnd(completed: false)
|
||||
currentIndex += 1
|
||||
playCore(track: tracks[currentIndex], authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
func playPrevious() {
|
||||
guard !history.isEmpty else { return }
|
||||
if let current = currentTrack { queue.insert(current, at: 0) }
|
||||
let prev = history.removeLast()
|
||||
playCore(track: prev, authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
func playFromHistory(at index: Int) {
|
||||
guard index < history.count else { return }
|
||||
let track = history[index]
|
||||
var restored = Array(history[(index + 1)...])
|
||||
if let current = currentTrack { restored.append(current) }
|
||||
restored.append(contentsOf: queue)
|
||||
history = Array(history[..<index])
|
||||
queue = restored
|
||||
playCore(track: track, authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
func setQueue(_ tracks: [TrackCard]) {
|
||||
queue = tracks
|
||||
guard canGoPrevious else { return }
|
||||
recordCurrentTrackEnd(completed: false)
|
||||
currentIndex -= 1
|
||||
playCore(track: tracks[currentIndex], authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
func addToQueue(_ track: TrackCard) {
|
||||
queue.append(track)
|
||||
tracks.append(track)
|
||||
}
|
||||
|
||||
func addToQueueNext(_ track: TrackCard) {
|
||||
queue.insert(track, at: 0)
|
||||
let insertAt = min(currentIndex + 1, tracks.count)
|
||||
tracks.insert(track, at: insertAt)
|
||||
}
|
||||
|
||||
func removeFromQueue(at offsets: IndexSet) {
|
||||
queue.remove(atOffsets: offsets)
|
||||
func removeTrack(at index: Int) {
|
||||
guard tracks.indices.contains(index), index != currentIndex else { return }
|
||||
tracks.remove(at: index)
|
||||
if index < currentIndex { currentIndex -= 1 }
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
@@ -217,7 +257,6 @@ final class PlayerManager {
|
||||
]
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
|
||||
// Load cover art asynchronously
|
||||
if let urlString = track.coverUrl, let url = URL(string: urlString) {
|
||||
Task {
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
|
||||
@@ -7,6 +7,8 @@ import SwiftUI
|
||||
|
||||
struct QueueView: View {
|
||||
let player: PlayerManager
|
||||
let deviceManager: DeviceManager
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -15,9 +17,9 @@ struct QueueView: View {
|
||||
Text("Queue")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
let total = player.history.count + player.queue.count
|
||||
if total > 0 {
|
||||
Text("\(total) tracks")
|
||||
let count = displayTracks.count
|
||||
if count > 0 {
|
||||
Text("\(count) tracks")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
Button(action: { dismiss() }) {
|
||||
@@ -32,68 +34,129 @@ struct QueueView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
if player.history.isEmpty && player.queue.isEmpty {
|
||||
let tracks = displayTracks
|
||||
let currentIdx = displayCurrentIndex
|
||||
|
||||
if tracks.isEmpty {
|
||||
Spacer()
|
||||
Text("Queue is empty")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
if !player.history.isEmpty {
|
||||
Section("Previously Played") {
|
||||
ForEach(player.history.indices.reversed(), id: \.self) { idx in
|
||||
let track = player.history[idx]
|
||||
QueueTrackRow(track: track, label: nil, dimmed: true)
|
||||
.onTapGesture { player.playFromHistory(at: idx) }
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(tracks.enumerated()), id: \.offset) { idx, track in
|
||||
QueueTrackRow(track: track, index: idx, currentIndex: currentIdx)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { handleTap(index: idx, tracks: tracks) }
|
||||
.swipeActions(edge: .trailing) {
|
||||
if idx != currentIdx && deviceManager.isThisDeviceActive {
|
||||
Button(role: .destructive) {
|
||||
player.removeTrack(at: idx)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.id(idx)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
if !player.queue.isEmpty {
|
||||
Section("Up Next") {
|
||||
ForEach(Array(player.queue.enumerated()), id: \.element.id) { idx, track in
|
||||
QueueTrackRow(track: track, label: "\(idx + 1)", dimmed: false)
|
||||
}
|
||||
.onDelete { player.removeFromQueue(at: $0) }
|
||||
.onAppear {
|
||||
if currentIdx >= 0 {
|
||||
proxy.scrollTo(currentIdx, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 360)
|
||||
.frame(width: 300, height: 380)
|
||||
}
|
||||
|
||||
private func handleTap(index: Int, tracks: [TrackCard]) {
|
||||
if deviceManager.isThisDeviceActive {
|
||||
player.playFromIndex(index)
|
||||
} else {
|
||||
Task { await deviceManager.sendPlayFromIndex(tracks: tracks, index: index) }
|
||||
}
|
||||
}
|
||||
|
||||
private var displayTracks: [TrackCard] {
|
||||
if deviceManager.isThisDeviceActive {
|
||||
return player.tracks
|
||||
} else if let rs = deviceManager.remoteState,
|
||||
let baseUrl = authManager.session?.serverBaseUrl {
|
||||
return rs.tracks.compactMap { $0.toTrackCard(baseUrl: baseUrl) }
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private var displayCurrentIndex: Int {
|
||||
if deviceManager.isThisDeviceActive {
|
||||
return player.currentIndex
|
||||
}
|
||||
return deviceManager.remoteState?.index ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private struct QueueTrackRow: View {
|
||||
let track: TrackCard
|
||||
let label: String?
|
||||
let dimmed: Bool
|
||||
let index: Int
|
||||
let currentIndex: Int
|
||||
|
||||
private enum RowState { case played, current, upcoming }
|
||||
private var rowState: RowState {
|
||||
if index < currentIndex { return .played }
|
||||
if index == currentIndex { return .current }
|
||||
return .upcoming
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
.frame(width: 20, alignment: .trailing)
|
||||
} else {
|
||||
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
.frame(width: 20)
|
||||
Group {
|
||||
switch rowState {
|
||||
case .played:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
case .current:
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.accentColor)
|
||||
case .upcoming:
|
||||
Text("\(index + 1)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 18)
|
||||
|
||||
ImageView(urlString: track.coverUrl, width: 34, height: 34, systemPlaceholder: "music.note")
|
||||
.frame(width: 34, height: 34)
|
||||
.opacity(dimmed ? 0.6 : 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(track.title).font(.subheadline).lineLimit(1)
|
||||
.foregroundColor(dimmed ? .secondary : .primary)
|
||||
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1)
|
||||
Text(track.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(rowState == .current ? .medium : .regular)
|
||||
.foregroundColor(rowState == .played ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
Text(track.artistNames)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(formatDuration(track.durationSeconds))
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(rowState == .current ? Color.accentColor.opacity(0.08) : Color.clear)
|
||||
.opacity(rowState == .played ? 0.55 : 1.0)
|
||||
}
|
||||
|
||||
private func formatDuration(_ s: Int) -> String {
|
||||
|
||||
Reference in New Issue
Block a user