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:
parent
9a613ff2ab
commit
ea50bd4537
2 changed files with 336 additions and 185 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue