NavidromeApp/iOS/Views/Common/DebugConsoleView.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

233 lines
8.6 KiB
Swift

import SwiftUI
// MARK: - Debug Logger
/// Singleton that captures log messages for the in-app debug console
class DebugLogger: ObservableObject {
static let shared = DebugLogger()
struct LogEntry: Identifiable {
let id = UUID()
let timestamp: Date
let category: String
let message: String
var formatted: String {
let df = DateFormatter()
df.dateFormat = "HH:mm:ss.SSS"
return "[\(df.string(from: timestamp))] [\(category)] \(message)"
}
}
@Published var entries: [LogEntry] = []
@Published var isEnabled: Bool {
didSet { UserDefaults.standard.set(isEnabled, forKey: "debug_console_enabled") }
}
private let maxEntries = 500
private init() {
isEnabled = UserDefaults.standard.bool(forKey: "debug_console_enabled")
}
func log(_ message: String, category: String = "General") {
let entry = LogEntry(timestamp: Date(), category: category, message: message)
DispatchQueue.main.async {
self.entries.append(entry)
if self.entries.count > self.maxEntries {
self.entries.removeFirst(self.entries.count - self.maxEntries)
}
}
// Also print to Xcode console
print("[\(category)] \(message)")
}
func clear() {
entries.removeAll()
}
}
// MARK: - Debug Console View
struct DebugConsoleView: View {
@ObservedObject private var logger = DebugLogger.shared
@State private var filterText = ""
@State private var selectedCategory: String? = nil
@State private var autoScroll = true
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var categories: [String] {
Array(Set(logger.entries.map { $0.category })).sorted()
}
private var filteredEntries: [DebugLogger.LogEntry] {
logger.entries.filter { entry in
let matchesCategory = selectedCategory == nil || entry.category == selectedCategory
let matchesText = filterText.isEmpty ||
entry.message.localizedCaseInsensitiveContains(filterText) ||
entry.category.localizedCaseInsensitiveContains(filterText)
return matchesCategory && matchesText
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Filter bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.font(.system(size: 13))
TextField("Filter logs...", text: $filterText)
.font(.system(size: 13))
.foregroundColor(.white)
if !filterText.isEmpty {
Button(action: { filterText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.system(size: 13))
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.08))
.cornerRadius(8)
.padding(.horizontal, 12)
.padding(.top, 8)
// Category pills
if !categories.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
categoryPill("All", isSelected: selectedCategory == nil) {
selectedCategory = nil
}
ForEach(categories, id: \.self) { cat in
categoryPill(cat, isSelected: selectedCategory == cat) {
selectedCategory = cat
}
}
}
.padding(.horizontal, 12)
}
.padding(.vertical, 6)
}
Divider().background(Color.white.opacity(0.1))
// Log entries
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(filteredEntries) { entry in
logRow(entry)
.id(entry.id)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
.onChange(of: logger.entries.count) { _, _ in
if autoScroll, let last = filteredEntries.last {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
// Bottom bar
HStack {
Text("\(filteredEntries.count) entries")
.font(.system(size: 11))
.foregroundColor(.gray)
Spacer()
Toggle("Auto-scroll", isOn: $autoScroll)
.font(.system(size: 11))
.foregroundColor(.gray)
.toggleStyle(.switch)
.tint(accentPink)
.fixedSize()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.black.opacity(0.3))
}
.background(Color(white: 0.06))
.navigationTitle("Debug Console")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { logger.clear() }) {
Text("Clear").font(.system(size: 14))
}
.foregroundColor(.red)
}
ToolbarItem(placement: .navigationBarTrailing) {
ShareLink(item: exportLogs()) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 14))
}
.foregroundColor(accentPink)
}
}
}
}
private func categoryPill(_ title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundColor(isSelected ? .white : .gray)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isSelected ? accentPink.opacity(0.6) : Color.white.opacity(0.08))
.cornerRadius(12)
}
}
private func logRow(_ entry: DebugLogger.LogEntry) -> some View {
let color: Color = {
switch entry.category {
case "Watch": return .cyan
case "Audio": return .green
case "FFT": return .orange
case "Network": return .yellow
case "Error": return .red
case "Radio": return .purple
default: return .gray
}
}()
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(entry.category)
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(color)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(color.opacity(0.15))
.cornerRadius(3)
let df = DateFormatter()
let _ = df.dateFormat = "HH:mm:ss.SSS"
Text(df.string(from: entry.timestamp))
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
Text(entry.message)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
private func exportLogs() -> String {
filteredEntries.map { $0.formatted }.joined(separator: "\n")
}
}