4 Commits

Author SHA1 Message Date
Ultradesu 80aa04f625 fix: enrich player track info from API + share button feedback
PlayerTrackInfoView auto-fetches full release data when current track
is missing audio/lastfm fields (e.g. after device transfer). Share
button turns green with checkmark for 1.5s after copying URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:00:51 +01:00
Ultradesu 88297cbc91 feat: real-time logs window + v1.3
Add AppLogger singleton (ring buffer, 500 lines) and LogsView with
auto-scroll and color-coded output. Button in Settings opens a resizable
logs window. Log track start, device active changes, and network errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:52:49 +01:00
Ultradesu ab352b9595 fix: send Last.fm now-playing notification when track starts
Add POST /api/player/lastfm/now-playing call on every track start via
onTrackStarted callback in PlayerManager, matching other clients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:50:08 +01:00
Ultradesu 37a6ccaf0c fix: liked state not showing until track row disappears/reappears
likedTrackIds loads async after views appear, so isLiked was always
false on initial render. Add onChange(of: likedTrackIds) to all track
row types and the player bar to re-sync when the set is populated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:49:05 +01:00
14 changed files with 196 additions and 8 deletions
+2 -2
View File
@@ -409,7 +409,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
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.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+36
View File
@@ -0,0 +1,36 @@
//
// AppLogger.swift
// furumi_macos
//
import Foundation
import Observation
@MainActor
@Observable
final class AppLogger {
static let shared = AppLogger()
private(set) var lines: [String] = []
private let maxLines = 500
private init() {}
func log(_ message: String) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
let timestamp = formatter.string(from: Date())
lines.append("[\(timestamp)] \(message)")
if lines.count > maxLines {
lines.removeFirst(lines.count - maxLines)
}
}
func clear() {
lines.removeAll()
}
}
func appLog(_ message: String) {
Task { @MainActor in AppLogger.shared.log(message) }
}
+1
View File
@@ -203,6 +203,7 @@ private struct ArtistTrackRow: View {
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
+6
View File
@@ -190,6 +190,12 @@ struct CatalogService {
_ = try await post(path: "api/player/devices/command", body: body)
}
func lastfmNowPlaying(trackId: Int64) async throws {
struct Body: Encodable { let trackId: Int64 }
let body = try Self.deviceEncoder.encode(Body(trackId: trackId))
_ = try await post(path: "api/player/lastfm/now-playing", body: body)
}
func recordHistory(trackId: Int64, startedAt: Int64?, durationListened: Int32, completed: Bool) async throws {
struct Body: Encodable {
let trackId: Int64
+20 -4
View File
@@ -36,6 +36,7 @@ struct ContentView: View {
@State private var showingQueue = false
@State private var showingDevices = false
@State private var remoteSeekOverride: Double? = nil // optimistic seek for client mode
@State private var shareCopied = false
// ViewModels bound to session (optional)
@State private var globalVM: GlobalArtistsViewModel?
@@ -64,6 +65,9 @@ struct ContentView: View {
.onChange(of: player.currentTrack?.id) { _, newId in
isCurrentTrackLiked = newId.map { authManager.likedTrackIds.contains($0) } ?? false
}
.onChange(of: authManager.likedTrackIds) { _, ids in
isCurrentTrackLiked = player.currentTrack.map { ids.contains($0.id) } ?? false
}
.onChange(of: deviceManager.remoteState?.updatedAtMs) { _, _ in
remoteSeekOverride = nil
}
@@ -103,9 +107,14 @@ struct ContentView: View {
Task { await authManager.loadLikedIds() }
deviceManager.start(service: service, player: player)
let am = authManager
player.onTrackStarted = { trackId in
do { try await am.catalogService?.lastfmNowPlaying(trackId: trackId) }
catch { appLog("❌ lastfm now-playing error: \(error)") }
}
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)
do { try await svc.recordHistory(trackId: trackId, startedAt: startedAt, durationListened: durationListened, completed: completed) }
catch { appLog("❌ history record error: \(error)") }
}
let gvm = globalVM
Task { await gvm?.loadFirstPage() }
@@ -552,9 +561,11 @@ struct ContentView: View {
.disabled(player.currentTrack == nil)
Button(action: shareCurrentTrack) {
Image(systemName: "square.and.arrow.up").font(.system(size: 14))
Image(systemName: shareCopied ? "checkmark" : "square.and.arrow.up")
.font(.system(size: 14))
}
.buttonStyle(.plain).foregroundColor(.secondary)
.buttonStyle(.plain)
.foregroundColor(shareCopied ? .green : .secondary)
.disabled(player.currentTrack == nil)
Button(action: { showingTrackInfo.toggle() }) {
@@ -564,7 +575,7 @@ struct ContentView: View {
.disabled(player.currentTrack == nil)
.popover(isPresented: $showingTrackInfo, arrowEdge: .bottom) {
if let track = player.currentTrack {
TrackInfoView(track: track)
PlayerTrackInfoView(track: track, service: authManager.catalogService)
}
}
@@ -698,6 +709,11 @@ struct ContentView: View {
let url = "\(baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/share/track/\(track.id)"
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
withAnimation(.easeIn(duration: 0.1)) { shareCopied = true }
Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
withAnimation(.easeOut(duration: 0.4)) { shareCopied = false }
}
}
}
+5 -2
View File
@@ -94,7 +94,9 @@ final class DeviceManager {
)
let response = try decoder.decode(DevicePollResponseDTO.self, from: data)
applyPollResponse(response, player: player)
} catch { }
} catch {
appLog("❌ Device poll error: \(error)")
}
}
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
@@ -104,10 +106,11 @@ final class DeviceManager {
activeDeviceId = response.activeDeviceId
if isThisDeviceActive {
if !wasActive { appLog("📱 This device became active") }
remoteState = nil
} else {
if wasActive { appLog("📱 This device became inactive, active: \(response.activeDeviceId ?? "none")") }
remoteState = response.playbackState
// Pause local playback when this device is no longer active
if wasActive {
player.pauseIfNeeded()
}
+66
View File
@@ -0,0 +1,66 @@
//
// LogsView.swift
// furumi_macos
//
import SwiftUI
struct LogsView: View {
@State private var logger = AppLogger.shared
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Logs")
.font(.headline)
Spacer()
Text("\(logger.lines.count) lines")
.font(.caption)
.foregroundColor(.secondary)
Button("Clear") { logger.clear() }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(logger.lines.enumerated()), id: \.offset) { idx, line in
Text(line)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(color(for: line))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 1)
.id(idx)
}
}
.padding(.vertical, 4)
}
.onChange(of: logger.lines.count) { _, _ in
if let last = logger.lines.indices.last {
proxy.scrollTo(last, anchor: .bottom)
}
}
.onAppear {
if let last = logger.lines.indices.last {
proxy.scrollTo(last, anchor: .bottom)
}
}
}
}
.frame(width: 680, height: 420)
}
private func color(for line: String) -> Color {
if line.contains("error") || line.contains("Error") || line.contains("") { return .red }
if line.contains("warn") || line.contains("Warn") || line.contains("⚠️") { return .orange }
if line.contains("") { return .accentColor }
return .primary
}
}
+4
View File
@@ -37,6 +37,7 @@ final class PlayerManager {
var canGoPrevious: Bool { currentIndex > 0 }
var formattedCurrentTime: String { formatTime(currentTime) }
var onTrackStarted: ((Int64) async -> Void)?
var onTrackFinished: ((Int64, Int64?, Int32, Bool) async -> Void)?
private var player: AVPlayer?
@@ -96,12 +97,15 @@ final class PlayerManager {
player?.play()
isPlaying = true
currentTrack = track
appLog("\(track.title)\(track.artistNames)")
currentTime = 0
duration = Double(track.durationSeconds)
trackStartedAt = Date()
addObserver()
updateNowPlayingInfo(track: track)
let cb = onTrackStarted
Task { await cb?(track.id) }
}
// MARK: - Public playback
+1
View File
@@ -172,6 +172,7 @@ private struct PlaylistTrackRow: View {
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
+1
View File
@@ -170,6 +170,7 @@ private struct ReleaseTrackRow: View {
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
+1
View File
@@ -137,6 +137,7 @@ private struct TrackRowSearch: View {
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
+11
View File
@@ -18,6 +18,17 @@ struct SettingsView: View {
Divider()
authSection
Spacer()
Divider()
HStack {
Spacer()
Button {
NSApp.sendAction(#selector(AppDelegate.openLogs), to: nil, from: nil)
} label: {
Label("View Logs", systemImage: "doc.text.magnifyingglass")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(24)
.frame(width: 360)
+18
View File
@@ -5,6 +5,24 @@
import SwiftUI
/// Wrapper used by the player bar enriches track data from the API if fields are missing
struct PlayerTrackInfoView: View {
let track: TrackCard
let service: CatalogService?
@State private var enriched: TrackCard?
var body: some View {
TrackInfoView(track: enriched ?? track)
.task(id: track.id) {
guard track.audioFormat == nil, track.releaseId > 0, let service else { return }
guard let detail = try? await service.releaseDetail(id: track.releaseId),
let full = detail.tracks.first(where: { $0.id == track.id }) else { return }
enriched = full
}
}
}
struct TrackInfoView: View {
let track: TrackCard
+24
View File
@@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem!
var popover: NSPopover!
var settingsWindow: NSWindow?
var logsWindow: NSWindow?
let authManager = AuthManager()
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -108,6 +109,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
}
@objc func openLogs() {
if let window = logsWindow {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 680, height: 420),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Logs"
window.contentViewController = NSHostingController(rootView: LogsView())
window.center()
window.isReleasedWhenClosed = false
logsWindow = window
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc func quit() {
NSApplication.shared.terminate(nil)
}