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)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
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.2;
|
MARKETING_VERSION = 1.3;
|
||||||
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;
|
||||||
|
|||||||
@@ -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)
|
deviceManager.start(service: service, player: player)
|
||||||
let am = authManager
|
let am = authManager
|
||||||
player.onTrackStarted = { trackId in
|
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
|
player.onTrackFinished = { trackId, startedAt, durationListened, completed in
|
||||||
guard let svc = am.catalogService else { return }
|
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
|
let gvm = globalVM
|
||||||
Task { await gvm?.loadFirstPage() }
|
Task { await gvm?.loadFirstPage() }
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ final class DeviceManager {
|
|||||||
)
|
)
|
||||||
let response = try decoder.decode(DevicePollResponseDTO.self, from: data)
|
let response = try decoder.decode(DevicePollResponseDTO.self, from: data)
|
||||||
applyPollResponse(response, player: player)
|
applyPollResponse(response, player: player)
|
||||||
} catch { }
|
} catch {
|
||||||
|
appLog("❌ Device poll error: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
|
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
|
||||||
@@ -104,10 +106,11 @@ final class DeviceManager {
|
|||||||
activeDeviceId = response.activeDeviceId
|
activeDeviceId = response.activeDeviceId
|
||||||
|
|
||||||
if isThisDeviceActive {
|
if isThisDeviceActive {
|
||||||
|
if !wasActive { appLog("📱 This device became active") }
|
||||||
remoteState = nil
|
remoteState = nil
|
||||||
} else {
|
} else {
|
||||||
|
if wasActive { appLog("📱 This device became inactive, active: \(response.activeDeviceId ?? "none")") }
|
||||||
remoteState = response.playbackState
|
remoteState = response.playbackState
|
||||||
// Pause local playback when this device is no longer active
|
|
||||||
if wasActive {
|
if wasActive {
|
||||||
player.pauseIfNeeded()
|
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()
|
player?.play()
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
currentTrack = track
|
currentTrack = track
|
||||||
|
appLog("▶ \(track.title) — \(track.artistNames)")
|
||||||
currentTime = 0
|
currentTime = 0
|
||||||
duration = Double(track.durationSeconds)
|
duration = Double(track.durationSeconds)
|
||||||
trackStartedAt = Date()
|
trackStartedAt = Date()
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ struct SettingsView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
authSection
|
authSection
|
||||||
Spacer()
|
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)
|
.padding(24)
|
||||||
.frame(width: 360)
|
.frame(width: 360)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
var statusItem: NSStatusItem!
|
var statusItem: NSStatusItem!
|
||||||
var popover: NSPopover!
|
var popover: NSPopover!
|
||||||
var settingsWindow: NSWindow?
|
var settingsWindow: NSWindow?
|
||||||
|
var logsWindow: NSWindow?
|
||||||
let authManager = AuthManager()
|
let authManager = AuthManager()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
@@ -108,6 +109,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
NSApp.activate(ignoringOtherApps: true)
|
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() {
|
@objc func quit() {
|
||||||
NSApplication.shared.terminate(nil)
|
NSApplication.shared.terminate(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user