diff --git a/iOS/Views/Common/DebugConsoleView.swift b/iOS/Views/Common/DebugConsoleView.swift index fd08007..f37aefe 100644 --- a/iOS/Views/Common/DebugConsoleView.swift +++ b/iOS/Views/Common/DebugConsoleView.swift @@ -1,183 +1,263 @@ import SwiftUI +import UIKit -// 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)" +// 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 } } - - @Published var entries: [LogEntry] = [] + 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") { - let entry = LogEntry(timestamp: Date(), category: category, message: message) + + 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) } } - // Also print to Xcode console - print("[\(category)] \(message)") + print("[\(level.rawValue)][\(category)] \(message)") } - - func clear() { - entries.removeAll() + + // 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 filterText = "" @State private var selectedCategory: String? = nil - @State private var autoScroll = true - @State private var showIPhone = true - @State private var showWatch = true - @State private var showCompanion = true - + @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 = ["Watch", "WC", "WatchSync"] + + private let watchCategories: Set = ["Watch", "WC", "WatchSync"] private let companionCategories: Set = ["Companion", "SmartDJ", "Crossfade", "Sync"] - - private var categories: [String] { - Array(Set(logger.entries.map { $0.category })).sorted() + private let audioCategories: Set = ["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] { - logger.entries.filter { entry in - let matchesCategory = selectedCategory == nil || entry.category == selectedCategory - let matchesText = filterText.isEmpty || - entry.message.localizedCaseInsensitiveContains(filterText) || - entry.category.localizedCaseInsensitiveContains(filterText) - - // Group filters - let isWatch = watchCategories.contains(entry.category) + activeEntries.filter { entry in + // Group filter + let isWatch = watchCategories.contains(entry.category) let isCompanion = companionCategories.contains(entry.category) - let isIPhone = !isWatch && !isCompanion - - let matchesGroup = (isIPhone && showIPhone) || (isWatch && showWatch) || (isCompanion && showCompanion) - - return matchesCategory && matchesText && matchesGroup + 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) { - // Filter bar + // ── Search bar ─────────────────────────────────────────────── HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - .font(.system(size: 13)) + Image(systemName: "magnifyingglass").foregroundColor(.gray).font(.system(size: 13)) TextField("Filter logs...", text: $filterText) - .font(.system(size: 13)) - .foregroundColor(.white) - + .font(.system(size: 13)).foregroundColor(.white) + .keyboardDoneButton() if !filterText.isEmpty { Button(action: { filterText = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.gray) - .font(.system(size: 13)) + 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 filter toggles - HStack(spacing: 8) { - groupToggle("iPhone", icon: "iphone", isOn: $showIPhone) - groupToggle("Watch", icon: "applewatch", isOn: $showWatch) - groupToggle("Companion", icon: "server.rack", isOn: $showCompanion) - Spacer() + .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(.horizontal, 12) .padding(.vertical, 4) - - // Category pills + + // ── Category pills ──────────────────────────────────────────── if !categories.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { - categoryPill("All", isSelected: selectedCategory == nil) { - selectedCategory = nil - } + categoryPill("All", isSelected: selectedCategory == nil) { selectedCategory = nil } ForEach(categories, id: \.self) { cat in - categoryPill(cat, isSelected: selectedCategory == cat) { - selectedCategory = cat - } + categoryPill(cat, isSelected: selectedCategory == cat) { selectedCategory = cat } } } .padding(.horizontal, 12) } - .padding(.vertical, 6) + .padding(.vertical, 4) } - + Divider().background(Color.white.opacity(0.1)) - - // Log entries + + // ── Log entries ─────────────────────────────────────────────── ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { - ForEach(filteredEntries) { entry in - logRow(entry) + 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) + .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) - } - } + guard !paused, autoScroll, let last = filteredEntries.last else { return } + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) } } } - - // Bottom bar - HStack { - Text("\(filteredEntries.count) entries") - .font(.system(size: 11)) - .foregroundColor(.gray) - + + // ── Bottom bar ──────────────────────────────────────────────── + HStack(spacing: 12) { + Text("\(filteredEntries.count) / \(logger.entries.count)") + .font(.system(size: 11)).foregroundColor(.gray) + Spacer() - - Toggle("Auto-scroll", isOn: $autoScroll) - .font(.system(size: 11)) - .foregroundColor(.gray) + + // 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) + .padding(.horizontal, 12).padding(.vertical, 6) .background(Color.black.opacity(0.3)) } .background(Color(white: 0.06)) @@ -187,86 +267,142 @@ struct DebugConsoleView: View { ToolbarItem(placement: .navigationBarLeading) { Button(action: { logger.clear() }) { Text("Clear").font(.system(size: 14)) - } - .foregroundColor(.red) + }.foregroundColor(.red) } - ToolbarItem(placement: .navigationBarTrailing) { + 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) + 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) + .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) -> 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)) + 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) + .padding(.horizontal, 8).padding(.vertical, 4) .background(isOn.wrappedValue ? accentPink.opacity(0.4) : Color.white.opacity(0.05)) .cornerRadius(10) } } - - private func logRow(_ entry: DebugLogger.LogEntry) -> some View { - let color: Color = { + + private func levelToggle(_ level: LogLevel, isOn: Binding) -> 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": 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 + 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 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) + + 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) } - - Text(entry.message) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.white.opacity(0.9)) - .textSelection(.enabled) - } - .padding(.vertical, 4) + .padding(.vertical, 4) + ) } - + private func exportLogs() -> String { filteredEntries.map { $0.formatted }.joined(separator: "\n") } diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index a46fdcb..cd781e9 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -166,6 +166,10 @@ struct MainTabView: View { .opacity(showNowPlaying ? 0 : 1) } } + .onChange(of: debugLogger.isPiP) { _, newVal in + // DebugConsoleView's PiP button sets logger.isPiP; sync to local state + if newVal { debugPipMode = true } + } .onReceive(NotificationCenter.default.publisher(for: .navigateToPlaylist)) { notif in if let playlistId = notif.userInfo?["playlistId"] as? String { selectedTab = 0 @@ -305,33 +309,44 @@ struct MainTabView: View { } private func debugLogRow(_ entry: DebugLogger.LogEntry) -> some View { + if entry.isMarker { + return AnyView( + Text(entry.message) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.2)) + .frame(maxWidth: .infinity).padding(.vertical, 4) + ) + } 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 + case "Watch", "WC", "WatchSync": return .cyan + case "Audio", "FFT": return .green + case "Network", "Radio": return .yellow + case "Error": return .red + case "Companion", "SmartDJ": return .purple + default: return .gray } }() - - return HStack(alignment: .top, spacing: 6) { - Text(entry.category) - .font(.system(size: 9, weight: .bold, design: .monospaced)) - .foregroundColor(color) - .padding(.horizontal, 3) - .padding(.vertical, 1) - .background(color.opacity(0.15)) - .cornerRadius(3) - - Text(entry.message) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.white.opacity(0.85)) - .lineLimit(3) - } - .padding(.vertical, 3) + return AnyView( + HStack(alignment: .top, spacing: 5) { + Circle() + .fill(entry.level.color) + .frame(width: 4, height: 4) + .padding(.top, 3) + .opacity(entry.level == .debug ? 0.4 : 1.0) + Text(entry.category) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundColor(color) + .padding(.horizontal, 3).padding(.vertical, 1) + .background(color.opacity(0.15)).cornerRadius(3) + Text(entry.message) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(entry.level == .error ? .red.opacity(0.9) : + entry.level == .warning ? .yellow.opacity(0.9) : .white.opacity(0.85)) + .lineLimit(3) + } + .padding(.vertical, 3) + ) } // MARK: - Debug PiP (Floating)