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
233 lines
8.6 KiB
Swift
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")
|
|
}
|
|
}
|