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:
Ultradesu
2026-06-08 18:31:01 +01:00
parent 2f8cff528c
commit bdab606360
7 changed files with 329 additions and 120 deletions
+2 -2
View File
@@ -409,7 +409,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos"; PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -442,7 +442,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos"; PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
+14
View File
@@ -190,6 +190,20 @@ struct CatalogService {
_ = try await post(path: "api/player/devices/command", body: body) _ = 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] { func playlists() async throws -> [PlaylistCard] {
let data = try await fetch(path: "api/player/playlists") let data = try await fetch(path: "api/player/playlists")
return try decode([PlaylistCardDTO].self, from: data).map { $0.toDomain() } return try decode([PlaylistCardDTO].self, from: data).map { $0.toDomain() }
+67 -27
View File
@@ -35,6 +35,7 @@ struct ContentView: View {
@State private var showingTrackInfo = false @State private var showingTrackInfo = false
@State private var showingQueue = false @State private var showingQueue = false
@State private var showingDevices = false @State private var showingDevices = false
@State private var remoteSeekOverride: Double? = nil // optimistic seek for client mode
// ViewModels bound to session (optional) // ViewModels bound to session (optional)
@State private var globalVM: GlobalArtistsViewModel? @State private var globalVM: GlobalArtistsViewModel?
@@ -63,6 +64,9 @@ struct ContentView: View {
.onChange(of: player.currentTrack?.id) { _, newId in .onChange(of: player.currentTrack?.id) { _, newId in
isCurrentTrackLiked = newId.map { authManager.likedTrackIds.contains($0) } ?? false isCurrentTrackLiked = newId.map { authManager.likedTrackIds.contains($0) } ?? false
} }
.onChange(of: deviceManager.remoteState?.updatedAtMs) { _, _ in
remoteSeekOverride = nil
}
.onChange(of: deviceManager.pendingTransferState != nil) { _, hasPending in .onChange(of: deviceManager.pendingTransferState != nil) { _, hasPending in
if hasPending, let state = deviceManager.pendingTransferState { if hasPending, let state = deviceManager.pendingTransferState {
applyTransferState(state) applyTransferState(state)
@@ -98,6 +102,11 @@ struct ContentView: View {
} }
Task { await authManager.loadLikedIds() } Task { await authManager.loadLikedIds() }
deviceManager.start(service: service, player: player) 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 let gvm = globalVM
Task { await gvm?.loadFirstPage() } Task { await gvm?.loadFirstPage() }
let pvm = playlistsVM let pvm = playlistsVM
@@ -187,6 +196,13 @@ struct ContentView: View {
nowPlayingBar nowPlayingBar
.padding(12) .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) // 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? { private var displayCoverUrl: String? {
deviceManager.isThisDeviceActive deviceManager.isThisDeviceActive
? player.currentTrack?.coverUrl ? player.currentTrack?.coverUrl
: deviceManager.remoteState?.track?.coverUrl : displayRemoteTrack?.coverUrl
} }
private var displayTitle: String { private var displayTitle: String {
deviceManager.isThisDeviceActive deviceManager.isThisDeviceActive
? (player.currentTrack?.title ?? "") ? (player.currentTrack?.title ?? "")
: (deviceManager.remoteState?.track?.displayTitle ?? "") : (displayRemoteTrack?.displayTitle ?? "")
} }
private var displayArtist: String { private var displayArtist: String {
deviceManager.isThisDeviceActive deviceManager.isThisDeviceActive
? (player.currentTrack?.artistNames ?? "") ? (player.currentTrack?.artistNames ?? "")
: (deviceManager.remoteState?.track?.displayArtists ?? "") : (displayRemoteTrack?.displayArtists ?? "")
} }
private var displayCurrentTime: Double { private var displayCurrentTime: Double {
deviceManager.isThisDeviceActive if deviceManager.isThisDeviceActive { return player.currentTime }
? player.currentTime if let override = remoteSeekOverride { return override }
: (deviceManager.remoteState?.estimatedPositionSeconds ?? 0) return deviceManager.remoteState?.estimatedPositionSeconds ?? 0
} }
private var displayDuration: Double { private var displayDuration: Double {
deviceManager.isThisDeviceActive deviceManager.isThisDeviceActive
@@ -409,7 +433,7 @@ struct ContentView: View {
: (deviceManager.remoteState.map { !$0.paused } ?? false) : (deviceManager.remoteState.map { !$0.paused } ?? false)
} }
private var displayHasTrack: Bool { 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 { private func formatBarTime(_ s: Double) -> String {
@@ -445,7 +469,7 @@ struct ContentView: View {
.font(.system(size: 12)) .font(.system(size: 12))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundColor(deviceManager.isThisDeviceActive ? .secondary : .accentColor) .foregroundColor(deviceManager.isThisDeviceActive ? .primary : .accentColor)
.popover(isPresented: $showingDevices, arrowEdge: .bottom) { .popover(isPresented: $showingDevices, arrowEdge: .bottom) {
DevicesView(deviceManager: deviceManager) DevicesView(deviceManager: deviceManager)
} }
@@ -464,7 +488,8 @@ struct ContentView: View {
player.seek(to: fraction) player.seek(to: fraction)
} else { } else {
let pos = fraction * displayDuration 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 { if deviceManager.isThisDeviceActive {
player.playPrevious() player.playPrevious()
} else { } else {
Task { await deviceManager.sendCommand("previous") } Task { await deviceManager.sendCommand("prev") }
} }
}) { }) {
Image(systemName: "backward.fill").font(.title3) Image(systemName: "backward.fill").font(.title3)
@@ -491,7 +516,7 @@ struct ContentView: View {
if deviceManager.isThisDeviceActive { if deviceManager.isThisDeviceActive {
player.togglePlayPause() player.togglePlayPause()
} else { } else {
Task { await deviceManager.sendCommand(displayIsPlaying ? "pause" : "play") } Task { await deviceManager.sendCommand(displayIsPlaying ? "pause" : "resume") }
} }
}) { }) {
Image(systemName: displayIsPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: displayIsPlaying ? "pause.circle.fill" : "play.circle.fill")
@@ -547,9 +572,10 @@ struct ContentView: View {
Image(systemName: "list.bullet").font(.system(size: 14)) Image(systemName: "list.bullet").font(.system(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundColor((player.canGoNext || player.canGoPrevious) ? .accentColor : .secondary) .foregroundColor((player.canGoNext || player.canGoPrevious || !deviceManager.isThisDeviceActive) ? .accentColor : .secondary)
.popover(isPresented: $showingQueue, arrowEdge: .bottom) { .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 { if deviceManager.isThisDeviceActive {
player.volume = Float(newVol) player.volume = Float(newVol)
} else { } 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) .frame(width: 64).tint(.secondary).controlSize(.mini)
} }
} }
} }
} }
@@ -592,11 +619,7 @@ struct ContentView: View {
guard let auth = authManager.session?.tokens.authorizationHeader else { return } guard let auth = authManager.session?.tokens.authorizationHeader else { return }
player.play(track: track, authHeader: auth) player.play(track: track, authHeader: auth)
} else { } else {
Task { Task { await deviceManager.sendTrackCommand("play_track", track: track) }
// Clear remote queue then play
await deviceManager.sendTrackCommand("queue_add_next", track: track)
await deviceManager.sendCommand("next")
}
} }
} }
@@ -618,12 +641,27 @@ struct ContentView: View {
private func applyTransferState(_ state: DevicePlaybackState) { private func applyTransferState(_ state: DevicePlaybackState) {
guard let baseUrl = authManager.session?.serverBaseUrl, guard let baseUrl = authManager.session?.serverBaseUrl,
let auth = authManager.session?.tokens.authorizationHeader, let auth = authManager.session?.tokens.authorizationHeader else { return }
let trackInfo = state.track,
let track = trackInfo.toTrackCard(baseUrl: baseUrl) else { return } let allTracks = state.tracks.compactMap { $0.toTrackCard(baseUrl: baseUrl) }
player.play(track: track, authHeader: auth) let idx = state.index
let queueTracks = state.tracks.compactMap { $0.toTrackCard(baseUrl: baseUrl) }
player.setQueue(queueTracks) // 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 { if state.positionSeconds > 1, state.durationSeconds > 0 {
let fraction = state.positionSeconds / state.durationSeconds let fraction = state.positionSeconds / state.durationSeconds
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
@@ -655,9 +693,11 @@ struct ContentView: View {
} }
private func shareCurrentTrack() { 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.clearContents()
NSPasteboard.general.setString("\(track.title)\(track.artistNames)", forType: .string) NSPasteboard.general.setString(url, forType: .string)
} }
} }
+63 -12
View File
@@ -32,6 +32,7 @@ final class DeviceManager {
private var service: CatalogService? private var service: CatalogService?
private var pollTask: Task<Void, Never>? private var pollTask: Task<Void, Never>?
private weak var playerRef: PlayerManager?
private let decoder: JSONDecoder = { private let decoder: JSONDecoder = {
let d = JSONDecoder() let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase d.keyDecodingStrategy = .convertFromSnakeCase
@@ -54,6 +55,7 @@ final class DeviceManager {
func start(service: CatalogService, player: PlayerManager) { func start(service: CatalogService, player: PlayerManager) {
self.service = service self.service = service
self.playerRef = player
stopPolling() stopPolling()
pollTask = Task { [weak self] in pollTask = Task { [weak self] in
while !Task.isCancelled { while !Task.isCancelled {
@@ -66,6 +68,7 @@ final class DeviceManager {
func stop() { func stop() {
stopPolling() stopPolling()
service = nil service = nil
playerRef = nil
devices = [] devices = []
activeDeviceId = nil activeDeviceId = nil
remoteState = nil remoteState = nil
@@ -97,12 +100,17 @@ final class DeviceManager {
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) { private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
isConnected = true isConnected = true
devices = response.devices.map { $0.toDomain() } devices = response.devices.map { $0.toDomain() }
let wasActive = isThisDeviceActive
activeDeviceId = response.activeDeviceId activeDeviceId = response.activeDeviceId
if isThisDeviceActive { if isThisDeviceActive {
remoteState = nil remoteState = nil
} else { } else {
remoteState = response.playbackState remoteState = response.playbackState
// Pause local playback when this device is no longer active
if wasActive {
player.pauseIfNeeded()
}
} }
for cmd in response.commands { for cmd in response.commands {
@@ -115,8 +123,7 @@ final class DeviceManager {
private func executeCommand(_ cmd: PlayerDeviceCommandDTO, player: PlayerManager) { private func executeCommand(_ cmd: PlayerDeviceCommandDTO, player: PlayerManager) {
let p = cmd.payload let p = cmd.payload
switch cmd.command { switch cmd.command {
case "transfer_state": case "transfer_state", "play_track":
// Server set us as active and sent state to restore
pendingTransferState = DevicePlaybackState( pendingTransferState = DevicePlaybackState(
track: p.track, track: p.track,
tracks: p.tracks ?? [], tracks: p.tracks ?? [],
@@ -129,22 +136,43 @@ final class DeviceManager {
volume: p.volume ?? Double(player.volume), volume: p.volume ?? Double(player.volume),
updatedAtMs: p.updatedAtMs ?? 0 updatedAtMs: p.updatedAtMs ?? 0
) )
case "play": case "resume", "play":
if !player.isPlaying { player.togglePlayPause() } player.resumeIfNeeded()
case "pause": case "pause":
if player.isPlaying { player.togglePlayPause() } player.pauseIfNeeded()
case "next": case "next":
if player.canGoNext { player.playNext() } if player.canGoNext { player.playNext() }
case "previous": case "prev", "previous":
if player.canGoPrevious { player.playPrevious() } if player.canGoPrevious { player.playPrevious() }
case "seek": 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) player.seek(to: pos / player.duration)
} }
case "volume": case "set_volume", "volume":
if let vol = p.volume { if let vol = p.volume {
player.volume = Float(vol) 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 default: break
} }
} }
@@ -162,7 +190,11 @@ final class DeviceManager {
let response = try decoder.decode(DevicesResponseDTO.self, from: data) let response = try decoder.decode(DevicesResponseDTO.self, from: data)
devices = response.devices.map { $0.toDomain() } devices = response.devices.map { $0.toDomain() }
activeDeviceId = response.activeDeviceId activeDeviceId = response.activeDeviceId
if isThisDeviceActive { remoteState = nil } if isThisDeviceActive {
remoteState = nil
} else {
playerRef?.pauseIfNeeded()
}
} catch { } } catch { }
} }
@@ -173,18 +205,37 @@ final class DeviceManager {
} }
func sendTrackCommand(_ command: String, track: TrackCard) async { 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, "id": track.id,
"title": track.title, "title": track.title,
"duration_seconds": Double(track.durationSeconds), "duration_seconds": Double(track.durationSeconds),
"stream_url": track.streamUrl, "stream_url": track.streamUrl,
"cover_url": track.coverUrl as Any,
"release_id": track.releaseId, "release_id": track.releaseId,
"release_title": track.releaseTitle, "release_title": track.releaseTitle,
"artists": track.artistNames.split(separator: ",") "artists": track.artistNames.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) } .map { $0.trimmingCharacters(in: .whitespaces) }
.map { ["id": 0, "name": $0] as [String: Any] } .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
} }
} }
+5 -3
View File
@@ -41,7 +41,7 @@ struct DeviceTrackInfo: Codable {
discNumber: nil, discNumber: nil,
durationSeconds: Int((durationSeconds ?? 0).rounded()), durationSeconds: Int((durationSeconds ?? 0).rounded()),
artistNames: artists?.map(\.name).joined(separator: ", ") ?? "", artistNames: artists?.map(\.name).joined(separator: ", ") ?? "",
coverUrl: coverUrl, coverUrl: coverUrl.map { URLResolver.absolute(from: $0, baseUrl: baseUrl) },
streamUrl: URLResolver.absolute(from: streamUrl, baseUrl: baseUrl), streamUrl: URLResolver.absolute(from: streamUrl, baseUrl: baseUrl),
releaseId: releaseId ?? 0, releaseId: releaseId ?? 0,
releaseTitle: releaseTitle ?? "", releaseTitle: releaseTitle ?? "",
@@ -85,8 +85,8 @@ struct DevicePlaybackState: Codable {
guard let track = player.currentTrack else { return nil } guard let track = player.currentTrack else { return nil }
return DevicePlaybackState( return DevicePlaybackState(
track: DeviceTrackInfo(from: track), track: DeviceTrackInfo(from: track),
tracks: player.queue.map { DeviceTrackInfo(from: $0) }, tracks: player.tracks.map { DeviceTrackInfo(from: $0) },
index: 0, index: max(0, player.currentIndex),
positionSeconds: player.currentTime, positionSeconds: player.currentTime,
durationSeconds: player.duration, durationSeconds: player.duration,
paused: !player.isPlaying, paused: !player.isPlaying,
@@ -112,6 +112,8 @@ struct CommandPayload: Decodable {
var shuffle: Bool? var shuffle: Bool?
var repeatMode: String? var repeatMode: String?
var updatedAtMs: Int64? var updatedAtMs: Int64?
// seek uses "time" (web client convention)
var time: Double?
} }
// MARK: - DTOs from server // MARK: - DTOs from server
+80 -41
View File
@@ -16,8 +16,9 @@ final class PlayerManager {
private(set) var isPlaying = false private(set) var isPlaying = false
private(set) var currentTime: Double = 0 private(set) var currentTime: Double = 0
private(set) var duration: Double = 0 private(set) var duration: Double = 0
private(set) var queue: [TrackCard] = [] /// Full unified queue: played (0..<currentIndex) + current (currentIndex) + upcoming (currentIndex+1...)
private(set) var history: [TrackCard] = [] private(set) var tracks: [TrackCard] = []
private(set) var currentIndex: Int = -1
var volume: Float = { var volume: Float = {
let v = UserDefaults.standard.float(forKey: "player.volume") let v = UserDefaults.standard.float(forKey: "player.volume")
@@ -32,13 +33,16 @@ final class PlayerManager {
var progress: Double { var progress: Double {
duration > 0 ? min(currentTime / duration, 1) : 0 duration > 0 ? min(currentTime / duration, 1) : 0
} }
var canGoNext: Bool { !queue.isEmpty } var canGoNext: Bool { currentIndex < tracks.count - 1 }
var canGoPrevious: Bool { !history.isEmpty } var canGoPrevious: Bool { currentIndex > 0 }
var formattedCurrentTime: String { formatTime(currentTime) } var formattedCurrentTime: String { formatTime(currentTime) }
var onTrackFinished: ((Int64, Int64?, Int32, Bool) async -> Void)?
private var player: AVPlayer? private var player: AVPlayer?
private var timeObserver: Any? private var timeObserver: Any?
private var lastAuthHeader = "" private(set) var lastAuthHeader = ""
private var trackStartedAt: Date?
init() { init() {
setupRemoteCommands() setupRemoteCommands()
@@ -46,7 +50,16 @@ final class PlayerManager {
// MARK: - Playback // 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) { private func playCore(track: TrackCard, authHeader: String) {
guard let url = URL(string: track.streamUrl) else { return } guard let url = URL(string: track.streamUrl) else { return }
lastAuthHeader = authHeader lastAuthHeader = authHeader
@@ -64,7 +77,8 @@ final class PlayerManager {
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
if !self.queue.isEmpty { self.recordCurrentTrackEnd(completed: true)
if self.canGoNext {
self.playNext() self.playNext()
} else { } else {
self.isPlaying = false self.isPlaying = false
@@ -84,19 +98,46 @@ final class PlayerManager {
currentTrack = track currentTrack = track
currentTime = 0 currentTime = 0
duration = Double(track.durationSeconds) duration = Double(track.durationSeconds)
trackStartedAt = Date()
addObserver() addObserver()
updateNowPlayingInfo(track: track) 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) { func play(track: TrackCard, authHeader: String) {
if let current = currentTrack { recordCurrentTrackEnd(completed: false)
history.append(current) if let idx = tracks.firstIndex(where: { $0.id == track.id }) {
if history.count > 50 { history.removeFirst() } currentIndex = idx
} else {
let keep = max(0, currentIndex + 1)
tracks = Array(tracks.prefix(keep)) + [track]
currentIndex = tracks.count - 1
} }
playCore(track: track, authHeader: authHeader) 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() { func togglePlayPause() {
guard let player else { return } guard let player else { return }
if isPlaying { if isPlaying {
@@ -109,48 +150,47 @@ final class PlayerManager {
updateNowPlayingPlaybackState() 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() { func playNext() {
guard !queue.isEmpty else { return } guard canGoNext else { return }
if let current = currentTrack { recordCurrentTrackEnd(completed: false)
history.append(current) currentIndex += 1
if history.count > 50 { history.removeFirst() } playCore(track: tracks[currentIndex], authHeader: lastAuthHeader)
}
let next = queue.removeFirst()
playCore(track: next, authHeader: lastAuthHeader)
} }
func playPrevious() { func playPrevious() {
guard !history.isEmpty else { return } guard canGoPrevious else { return }
if let current = currentTrack { queue.insert(current, at: 0) } recordCurrentTrackEnd(completed: false)
let prev = history.removeLast() currentIndex -= 1
playCore(track: prev, authHeader: lastAuthHeader) playCore(track: tracks[currentIndex], 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
} }
func addToQueue(_ track: TrackCard) { func addToQueue(_ track: TrackCard) {
queue.append(track) tracks.append(track)
} }
func addToQueueNext(_ track: TrackCard) { 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) { func removeTrack(at index: Int) {
queue.remove(atOffsets: offsets) guard tracks.indices.contains(index), index != currentIndex else { return }
tracks.remove(at: index)
if index < currentIndex { currentIndex -= 1 }
} }
func seek(to fraction: Double) { func seek(to fraction: Double) {
@@ -217,7 +257,6 @@ final class PlayerManager {
] ]
MPNowPlayingInfoCenter.default().nowPlayingInfo = info MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// Load cover art asynchronously
if let urlString = track.coverUrl, let url = URL(string: urlString) { if let urlString = track.coverUrl, let url = URL(string: urlString) {
Task { Task {
guard let (data, _) = try? await URLSession.shared.data(from: url), guard let (data, _) = try? await URLSession.shared.data(from: url),
+98 -35
View File
@@ -7,6 +7,8 @@ import SwiftUI
struct QueueView: View { struct QueueView: View {
let player: PlayerManager let player: PlayerManager
let deviceManager: DeviceManager
@Environment(AuthManager.self) private var authManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -15,9 +17,9 @@ struct QueueView: View {
Text("Queue") Text("Queue")
.font(.headline) .font(.headline)
Spacer() Spacer()
let total = player.history.count + player.queue.count let count = displayTracks.count
if total > 0 { if count > 0 {
Text("\(total) tracks") Text("\(count) tracks")
.font(.caption).foregroundColor(.secondary) .font(.caption).foregroundColor(.secondary)
} }
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
@@ -32,68 +34,129 @@ struct QueueView: View {
Divider() Divider()
if player.history.isEmpty && player.queue.isEmpty { let tracks = displayTracks
let currentIdx = displayCurrentIndex
if tracks.isEmpty {
Spacer() Spacer()
Text("Queue is empty") Text("Queue is empty")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.font(.subheadline) .font(.subheadline)
Spacer() Spacer()
} else { } else {
List { ScrollViewReader { proxy in
if !player.history.isEmpty { ScrollView {
Section("Previously Played") { LazyVStack(spacing: 0) {
ForEach(player.history.indices.reversed(), id: \.self) { idx in ForEach(Array(tracks.enumerated()), id: \.offset) { idx, track in
let track = player.history[idx] QueueTrackRow(track: track, index: idx, currentIndex: currentIdx)
QueueTrackRow(track: track, label: nil, dimmed: true) .contentShape(Rectangle())
.onTapGesture { player.playFromHistory(at: idx) } .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 { .onAppear {
Section("Up Next") { if currentIdx >= 0 {
ForEach(Array(player.queue.enumerated()), id: \.element.id) { idx, track in proxy.scrollTo(currentIdx, anchor: .center)
QueueTrackRow(track: track, label: "\(idx + 1)", dimmed: false)
}
.onDelete { player.removeFromQueue(at: $0) }
} }
} }
} }
.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 { private struct QueueTrackRow: View {
let track: TrackCard let track: TrackCard
let label: String? let index: Int
let dimmed: Bool 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 { var body: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
if let label { Group {
Text(label) switch rowState {
.font(.caption).foregroundColor(.secondary) case .played:
.frame(width: 20, alignment: .trailing) Image(systemName: "checkmark")
} else { .font(.system(size: 10))
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .foregroundColor(.secondary)
.font(.caption).foregroundColor(.secondary) case .current:
.frame(width: 20) 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") 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) { VStack(alignment: .leading, spacing: 2) {
Text(track.title).font(.subheadline).lineLimit(1) Text(track.title)
.foregroundColor(dimmed ? .secondary : .primary) .font(.subheadline)
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1) .fontWeight(rowState == .current ? .medium : .regular)
.foregroundColor(rowState == .played ? .secondary : .primary)
.lineLimit(1)
Text(track.artistNames)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
} }
Spacer() Spacer()
Text(formatDuration(track.durationSeconds)) Text(formatDuration(track.durationSeconds))
.font(.caption).foregroundColor(.secondary) .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 { private func formatDuration(_ s: Int) -> String {