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
This commit is contained in:
Dallas Groot 2026-04-10 18:50:31 -07:00
parent 9a613ff2ab
commit ea50bd4537
2 changed files with 336 additions and 185 deletions

View file

@ -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<String> = ["Watch", "WC", "WatchSync"]
private let watchCategories: Set<String> = ["Watch", "WC", "WatchSync"]
private let companionCategories: Set<String> = ["Companion", "SmartDJ", "Crossfade", "Sync"]
private var categories: [String] {
Array(Set(logger.entries.map { $0.category })).sorted()
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] {
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<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))
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<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": 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")
}

View file

@ -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)