diff --git a/furumi_macos.xcodeproj/project.pbxproj b/furumi_macos.xcodeproj/project.pbxproj index 34f1166..cfc8e2f 100644 --- a/furumi_macos.xcodeproj/project.pbxproj +++ b/furumi_macos.xcodeproj/project.pbxproj @@ -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; diff --git a/furumi_macos/CatalogService.swift b/furumi_macos/CatalogService.swift index fd0424f..161c963 100644 --- a/furumi_macos/CatalogService.swift +++ b/furumi_macos/CatalogService.swift @@ -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() } diff --git a/furumi_macos/ContentView.swift b/furumi_macos/ContentView.swift index 77d4e40..640d437 100644 --- a/furumi_macos/ContentView.swift +++ b/furumi_macos/ContentView.swift @@ -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) } } diff --git a/furumi_macos/DeviceManager.swift b/furumi_macos/DeviceManager.swift index 84626ac..b359c82 100644 --- a/furumi_macos/DeviceManager.swift +++ b/furumi_macos/DeviceManager.swift @@ -32,6 +32,7 @@ final class DeviceManager { private var service: CatalogService? private var pollTask: Task? + 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 } } diff --git a/furumi_macos/DeviceModels.swift b/furumi_macos/DeviceModels.swift index d735c34..b013fe7 100644 --- a/furumi_macos/DeviceModels.swift +++ b/furumi_macos/DeviceModels.swift @@ -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 diff --git a/furumi_macos/PlayerManager.swift b/furumi_macos/PlayerManager.swift index 6da2d17..a6a013c 100644 --- a/furumi_macos/PlayerManager.swift +++ b/furumi_macos/PlayerManager.swift @@ -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.. 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[.. 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 {