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:
@@ -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;
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user