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>
This commit is contained in:
Ultradesu
2026-06-09 10:52:49 +01:00
parent ab352b9595
commit 88297cbc91
8 changed files with 149 additions and 6 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) }
}
+4 -2
View File
@@ -107,11 +107,13 @@ struct ContentView: View {
deviceManager.start(service: service, player: player)
let am = authManager
player.onTrackStarted = { trackId in
try? await am.catalogService?.lastfmNowPlaying(trackId: trackId)
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() }
+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
}
}
+1
View File
@@ -97,6 +97,7 @@ final class PlayerManager {
player?.play()
isPlaying = true
currentTrack = track
appLog("\(track.title)\(track.artistNames)")
currentTime = 0
duration = Double(track.durationSeconds)
trackStartedAt = Date()
+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)
+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)
}