NavidromeApp/iOS/Views/Common/DebugConsoleView.swift
Dallas Groot ea50bd4537 pip fixes and improvements
•	Group toggles — iPhone / Watch / Companion / Audio, all persistent
via @AppStorage so your filter state survives app restarts
	•	Level toggles — ERROR / WARN / INFO / DEBUG in a single scrollable
row next to the group toggles, color coded red/yellow/cyan/gray
	•	Delta timestamps — each row shows +42ms between it and the previous
entry, so you can see timing without mental math
	•	Pause/Resume — bottom bar button snapshots the current log so you
can read it without it scrolling, while still capturing in background
	•	Auto background/foreground markers — ── Background ── / ──
Foreground ── lines are auto-inserted by NotificationCenter, making
crash correlation much easier
	•	PiP button in toolbar sets logger.isPiP = true, which MainTabView’s
.onChange picks up and activates the existing floating PiP window
MainTabView.swift — minimal changes:
	•	Added .onChange(of: debugLogger.isPiP) to sync the console’s PiP
button to the existing debugPipMode state
	•	Updated debugLogRow to show level dot + marker support, consistent
with the full console
2026-04-10 18:50:31 -07:00

409 lines
17 KiB
Swift

import SwiftUI
import UIKit
// MARK: - Log Level
enum LogLevel: String, CaseIterable {
case error = "ERROR"
case warning = "WARN"
case info = "INFO"
case debug = "DEBUG"
var color: Color {
switch self {
case .error: return .red
case .warning: return .yellow
case .info: return .cyan
case .debug: return .gray
}
}
var icon: String {
switch self {
case .error: return "exclamationmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .info: return "info.circle.fill"
case .debug: return "ant.fill"
}
}
}
// MARK: - Debug Logger
class DebugLogger: ObservableObject {
static let shared = DebugLogger()
struct LogEntry: Identifiable {
let id = UUID()
let timestamp: Date
let category: String
let message: String
let level: LogLevel
var isMarker: Bool = false // background/foreground separator
var formatted: String {
let df = DateFormatter(); df.dateFormat = "HH:mm:ss.SSS"
return "[\(df.string(from: timestamp))] [\(level.rawValue)] [\(category)] \(message)"
}
}
@Published var entries: [LogEntry] = []
@Published var isPiP: Bool = false
@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")
// Auto-insert app state markers
NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification,
object: nil, queue: .main) { [weak self] _ in self?.insertMarker("── Background ──") }
NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification,
object: nil, queue: .main) { [weak self] _ in self?.insertMarker("── Foreground ──") }
}
func log(_ message: String, category: String = "General", level: LogLevel = .debug) {
guard isEnabled else { return }
let entry = LogEntry(timestamp: Date(), category: category, message: message, level: level)
DispatchQueue.main.async {
self.entries.append(entry)
if self.entries.count > self.maxEntries {
self.entries.removeFirst(self.entries.count - self.maxEntries)
}
}
print("[\(level.rawValue)][\(category)] \(message)")
}
// Convenience level shortcuts
func error(_ message: String, category: String = "General") {
log(message, category: category, level: .error)
}
func warn(_ message: String, category: String = "General") {
log(message, category: category, level: .warning)
}
func info(_ message: String, category: String = "General") {
log(message, category: category, level: .info)
}
private func insertMarker(_ text: String) {
guard isEnabled else { return }
var e = LogEntry(timestamp: Date(), category: "System", message: text, level: .info)
e.isMarker = true
entries.append(e)
}
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
@State private var paused = false
// Group toggles persisted
@AppStorage("dbg_show_iphone") private var showIPhone = true
@AppStorage("dbg_show_watch") private var showWatch = true
@AppStorage("dbg_show_companion") private var showCompanion = true
@AppStorage("dbg_show_audio") private var showAudio = true
// Level toggles
@State private var showError = true
@State private var showWarning = true
@State private var showInfo = true
@State private var showDebug = true
// Snapshot for pause mode
@State private var pausedSnapshot: [DebugLogger.LogEntry] = []
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let watchCategories: Set<String> = ["Watch", "WC", "WatchSync"]
private let companionCategories: Set<String> = ["Companion", "SmartDJ", "Crossfade", "Sync"]
private let audioCategories: Set<String> = ["Audio", "FFT", "Radio", "Prefetch"]
private var activeEntries: [DebugLogger.LogEntry] {
paused ? pausedSnapshot : logger.entries
}
private var categories: [String] {
Array(Set(activeEntries.map { $0.category })).sorted()
}
private var filteredEntries: [DebugLogger.LogEntry] {
activeEntries.filter { entry in
// Group filter
let isWatch = watchCategories.contains(entry.category)
let isCompanion = companionCategories.contains(entry.category)
let isAudio = audioCategories.contains(entry.category)
let isIPhone = !isWatch && !isCompanion && !isAudio
guard (isIPhone && showIPhone) || (isWatch && showWatch) ||
(isCompanion && showCompanion) || (isAudio && showAudio) || entry.isMarker
else { return false }
// Level filter
let level = entry.level
guard (level == .error && showError) || (level == .warning && showWarning) ||
(level == .info && showInfo) || (level == .debug && showDebug) || entry.isMarker
else { return false }
// Category pill
if let cat = selectedCategory, entry.category != cat { return false }
// Text search
if !filterText.isEmpty {
return entry.message.localizedCaseInsensitiveContains(filterText) ||
entry.category.localizedCaseInsensitiveContains(filterText)
}
return true
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundColor(.gray).font(.system(size: 13))
TextField("Filter logs...", text: $filterText)
.font(.system(size: 13)).foregroundColor(.white)
.keyboardDoneButton()
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)
// Group toggles
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
groupToggle("iPhone", icon: "iphone", isOn: $showIPhone)
groupToggle("Watch", icon: "applewatch", isOn: $showWatch)
groupToggle("Companion", icon: "server.rack", isOn: $showCompanion)
groupToggle("Audio", icon: "waveform", isOn: $showAudio)
Divider().frame(height: 16).background(Color.white.opacity(0.2))
levelToggle(.error, isOn: $showError)
levelToggle(.warning, isOn: $showWarning)
levelToggle(.info, isOn: $showInfo)
levelToggle(.debug, isOn: $showDebug)
}
.padding(.horizontal, 12)
}
.padding(.vertical, 4)
// 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, 4)
}
Divider().background(Color.white.opacity(0.1))
// Log entries
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(Array(filteredEntries.enumerated()), id: \.element.id) { idx, entry in
let prev = idx > 0 ? filteredEntries[idx - 1] : nil
logRow(entry, prev: prev)
.id(entry.id)
}
}
.padding(.horizontal, 12).padding(.vertical, 4)
}
.onChange(of: logger.entries.count) { _, _ in
guard !paused, autoScroll, let last = filteredEntries.last else { return }
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
// Bottom bar
HStack(spacing: 12) {
Text("\(filteredEntries.count) / \(logger.entries.count)")
.font(.system(size: 11)).foregroundColor(.gray)
Spacer()
// Pause / Resume
Button(action: togglePause) {
HStack(spacing: 4) {
Image(systemName: paused ? "play.fill" : "pause.fill").font(.system(size: 10))
Text(paused ? "Resume" : "Pause").font(.system(size: 11))
}
.foregroundColor(paused ? accentPink : .gray)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(paused ? accentPink.opacity(0.15) : Color.white.opacity(0.06))
.cornerRadius(8)
}
Toggle("", isOn: $autoScroll)
.labelsHidden()
.toggleStyle(.switch)
.tint(accentPink)
.fixedSize()
Text("Scroll").font(.system(size: 11)).foregroundColor(.gray)
}
.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)
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
// PiP toggle uses existing MainTabView floating window
Button(action: { logger.isPiP = true }) {
Image(systemName: "pip.enter").font(.system(size: 14))
}.foregroundColor(accentPink)
ShareLink(item: exportLogs()) {
Image(systemName: "square.and.arrow.up").font(.system(size: 14))
}.foregroundColor(accentPink)
}
}
}
}
// MARK: - Helpers
private func togglePause() {
if paused {
paused = false
pausedSnapshot = []
} else {
pausedSnapshot = logger.entries
paused = true
}
}
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 groupToggle(_ title: String, icon: String, isOn: Binding<Bool>) -> some View {
Button(action: { isOn.wrappedValue.toggle() }) {
HStack(spacing: 4) {
Image(systemName: icon).font(.system(size: 10))
Text(title).font(.system(size: 10, weight: .medium))
}
.foregroundColor(isOn.wrappedValue ? .white : .gray.opacity(0.4))
.padding(.horizontal, 8).padding(.vertical, 4)
.background(isOn.wrappedValue ? accentPink.opacity(0.4) : Color.white.opacity(0.05))
.cornerRadius(10)
}
}
private func levelToggle(_ level: LogLevel, isOn: Binding<Bool>) -> some View {
Button(action: { isOn.wrappedValue.toggle() }) {
Text(level.rawValue)
.font(.system(size: 9, weight: .bold))
.foregroundColor(isOn.wrappedValue ? level.color : .gray.opacity(0.3))
.padding(.horizontal, 6).padding(.vertical, 4)
.background(isOn.wrappedValue ? level.color.opacity(0.15) : Color.white.opacity(0.04))
.cornerRadius(8)
}
}
private func logRow(_ entry: DebugLogger.LogEntry, prev: DebugLogger.LogEntry?) -> some View {
// Marker (background/foreground separator)
if entry.isMarker {
return AnyView(
Text(entry.message)
.font(.system(size: 10, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.25))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
)
}
// Delta time since previous entry
let delta: String? = {
guard let p = prev, !p.isMarker else { return nil }
let d = entry.timestamp.timeIntervalSince(p.timestamp)
if d < 0.001 { return nil }
return d < 1 ? String(format: "+%.0fms", d * 1000) : String(format: "+%.1fs", d)
}()
let catColor: Color = {
switch entry.category {
case "Watch", "WC", "WatchSync": return .cyan
case "Audio": return .green
case "FFT": return .orange
case "Network", "Radio": return .yellow
case "Error": return .red
case "Companion", "SmartDJ": return .purple
default: return .gray
}
}()
return AnyView(
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
// Level indicator
Circle()
.fill(entry.level.color)
.frame(width: 5, height: 5)
.opacity(entry.level == .debug ? 0.4 : 1.0)
// Category badge
Text(entry.category)
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(catColor)
.padding(.horizontal, 4).padding(.vertical, 1)
.background(catColor.opacity(0.12))
.cornerRadius(3)
// Timestamp
let df = DateFormatter(); let _ = df.dateFormat = "HH:mm:ss.SSS"
Text(df.string(from: entry.timestamp))
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray.opacity(0.6))
// Delta
if let d = delta {
Text(d)
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.white.opacity(0.25))
}
}
Text(entry.message)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(entry.level == .error ? .red.opacity(0.9) :
entry.level == .warning ? .yellow.opacity(0.9) : .white.opacity(0.85))
.textSelection(.enabled)
}
.padding(.vertical, 4)
)
}
private func exportLogs() -> String {
filteredEntries.map { $0.formatted }.joined(separator: "\n")
}
}