import SwiftUI import UIKit import Combine struct MainTabView: View { @EnvironmentObject var audioPlayer: AudioPlayer @ObservedObject private var debugLogger = DebugLogger.shared @State private var selectedTab = 0 @State private var navigateToPlaylistId: String? @State private var navigateToAlbumId: String? @State private var navigateToArtistId: String? @State var showNowPlaying = false // Dynamic Island morph @Namespace private var playerNamespace @State private var isDynamicIsland = false @State private var dragOffset: CGFloat = 0 @State private var isDragging = false // Debug panel (docked) @State private var debugPanelHeight: CGFloat = 250 @State private var debugDragOffset: CGFloat = 0 @State private var showFullConsole = false // Group toggles — same @AppStorage keys as DebugConsoleView so state is shared @AppStorage("dbg_show_iphone") private var dbgShowIPhone = true @AppStorage("dbg_show_watch") private var dbgShowWatch = true @AppStorage("dbg_show_companion") private var dbgShowCompanion = true @AppStorage("dbg_show_audio") private var dbgShowAudio = true // Debug PiP (floating) @State private var debugPipMode = true @State private var pipPosition: CGPoint = CGPoint(x: 16, y: 100) @State private var pipDragOffset: CGSize = .zero @State private var pipSize: CGSize = CGSize(width: 320, height: 220) @State private var pipCollapsed = false @State private var pipResizeStart: CGSize = .zero private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { GeometryReader { rootGeo in ZStack { // Main tab content ZStack(alignment: .bottom) { TabView(selection: $selectedTab) { MyMusicView( navigateToPlaylistId: $navigateToPlaylistId, navigateToAlbumId: $navigateToAlbumId, navigateToArtistId: $navigateToArtistId ) .tabItem { Image(systemName: "music.note") Text("My Music") } .tag(0) SearchView() .tabItem { Image(systemName: "magnifyingglass") Text("Search") } .tag(1) RadioView() .tabItem { Image(systemName: "antenna.radiowaves.left.and.right") Text("Radio") } .tag(2) DownloadsView() .tabItem { Image(systemName: "arrow.down.circle") Text("Downloads") } .tag(3) SettingsView() .tabItem { Image(systemName: "gear") Text("Settings") } .tag(4) } .tint(accentPink) .safeAreaInset(edge: .bottom) { // Reserve space for MiniPlayerBar so content never gets obscured if audioPlayer.currentSong != nil && !showNowPlaying { Color.clear.frame(height: 80) } } // Debug panel (docked) — only when enabled and NOT in PiP mode if debugLogger.isEnabled && !debugPipMode { debugPanel .transition(.move(edge: .bottom).combined(with: .opacity)) .zIndex(2) } if audioPlayer.currentSong != nil { let effectiveDebugHeight = max(debugPanelHeight - debugDragOffset, 120) let showDocked = debugLogger.isEnabled && !debugPipMode if isDynamicIsland { // Dynamic Island layout at top DynamicIslandView( namespace: playerNamespace, showNowPlaying: $showNowPlaying, isDynamicIsland: $isDynamicIsland ) .zIndex(3) } else { // Standard MiniPlayerBar at bottom with drag-to-morph MiniPlayerBar( showNowPlaying: $showNowPlaying, namespace: playerNamespace ) .padding(.bottom, showDocked ? 49 + effectiveDebugHeight : 49) .animation(.easeInOut(duration: 0.25), value: showDocked) .offset(y: dragOffset) .gesture( DragGesture(minimumDistance: 10) .onChanged { value in // Only allow upward drag let translation = min(value.translation.height, 0) dragOffset = translation isDragging = true } .onEnded { value in isDragging = false let screenH = rootGeo.size.height let velocity = value.predictedEndTranslation.height let dragPct = abs(value.translation.height) / screenH if dragPct > 0.75 || velocity < -1200 { // Morph to Dynamic Island withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { isDynamicIsland = true dragOffset = 0 } } else if value.translation.height < -20 { // Small upward swipe — open NowPlaying dragOffset = 0 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = true } } else { withAnimation(.spring(response: 0.3)) { dragOffset = 0 } } } ) .zIndex(3) } } } // Now Playing overlay — always rendered, positioned via offset // This avoids re-layout animation when appearing if audioPlayer.currentSong != nil { NowPlayingView(isPresented: $showNowPlaying) .zIndex(1) .offset(y: showNowPlaying ? 0 : 1500) .allowsHitTesting(showNowPlaying) } // Debug PiP (floating) — above everything except Now Playing if debugLogger.isEnabled && debugPipMode { debugPipView .zIndex(showNowPlaying ? 0 : 5) .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 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { navigateToPlaylistId = playlistId } } } .onReceive(NotificationCenter.default.publisher(for: .navigateToAlbum)) { notif in if let albumId = notif.userInfo?["albumId"] as? String { selectedTab = 0 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { navigateToAlbumId = albumId } } } .onReceive(NotificationCenter.default.publisher(for: .navigateToArtist)) { notif in if let artistId = notif.userInfo?["artistId"] as? String { selectedTab = 0 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { navigateToArtistId = artistId } } } } // GeometryReader } // MARK: - Debug Panel private var debugPanel: some View { VStack(spacing: 0) { // Drag handle + header VStack(spacing: 4) { Capsule() .fill(Color.white.opacity(0.4)) .frame(width: 36, height: 4) .padding(.top, 8) HStack { Image(systemName: "ladybug.fill").font(.system(size: 12)).foregroundColor(.red) Text("Debug Console").font(.system(size: 13, weight: .semibold)).foregroundColor(.white) Text("\(filteredDebugEntries.count)/\(DebugLogger.shared.entries.count)") .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundColor(.gray) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.white.opacity(0.1)).cornerRadius(8) Spacer() Button(action: { DebugLogger.shared.clear() }) { Text("Clear").font(.system(size: 12, weight: .medium)).foregroundColor(.red.opacity(0.8)) } Button(action: { withAnimation(.easeInOut(duration: 0.3)) { debugPipMode = true } }) { Image(systemName: "pip").font(.system(size: 14)).foregroundColor(.white.opacity(0.6)) } // Open full console with all controls Button(action: { showFullConsole = true }) { Image(systemName: "arrow.up.left.and.arrow.down.right") .font(.system(size: 13)).foregroundColor(.white.opacity(0.6)) } Button(action: { debugLogger.isEnabled = false }) { Image(systemName: "xmark.circle.fill").font(.system(size: 16)).foregroundColor(.gray) } } .padding(.horizontal, 14).padding(.bottom, 4) // ── Group toggles ────────────────────────────────────────── ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { dbgGroupToggle("iPhone", icon: "iphone", isOn: $dbgShowIPhone) dbgGroupToggle("Watch", icon: "applewatch", isOn: $dbgShowWatch) dbgGroupToggle("Companion", icon: "server.rack", isOn: $dbgShowCompanion) dbgGroupToggle("Audio", icon: "waveform", isOn: $dbgShowAudio) Divider().frame(height: 14).background(Color.white.opacity(0.15)) dbgBodiesToggle() } .padding(.horizontal, 14) } .padding(.bottom, 6) } .background(Color(white: 0.1)) Divider().background(Color.white.opacity(0.15)) // Log entries — filtered by group toggles ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { ForEach(filteredDebugEntries) { entry in debugLogRow(entry).id(entry.id) } } .padding(.horizontal, 10).padding(.vertical, 4) } .onChange(of: DebugLogger.shared.entries.count) { _, _ in if let last = filteredDebugEntries.last { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(last.id, anchor: .bottom) } } } } } .frame(height: max(debugPanelHeight - debugDragOffset, 120)) .background(Color(white: 0.06)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .shadow(color: .black.opacity(0.4), radius: 8, y: -2) .padding(.horizontal, 4) .padding(.bottom, 49) .sheet(isPresented: $showFullConsole) { DebugConsoleView() } .gesture( DragGesture() .onChanged { value in debugDragOffset = value.translation.height } .onEnded { value in let newHeight = debugPanelHeight - value.translation.height withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { debugDragOffset = 0 debugPanelHeight = min(max(newHeight, 120), 500) } } ) } // Filtered entries for the docked panel using the same group toggle AppStorage keys private var filteredDebugEntries: [DebugLogger.LogEntry] { let watchCats: Set = ["Watch", "WC", "WatchSync"] let companionCats: Set = ["Companion", "SmartDJ", "Crossfade", "Sync"] let audioCats: Set = ["Audio", "FFT", "Radio", "Prefetch"] return DebugLogger.shared.entries.filter { entry in if entry.isMarker { return true } // ViewBody entries only shown when tracker is active if entry.category == "ViewBody" { return DebugLogger.shared.trackViewBodies } let isWatch = watchCats.contains(entry.category) let isCompanion = companionCats.contains(entry.category) let isAudio = audioCats.contains(entry.category) let isIPhone = !isWatch && !isCompanion && !isAudio return (isIPhone && dbgShowIPhone) || (isWatch && dbgShowWatch) || (isCompanion && dbgShowCompanion) || (isAudio && dbgShowAudio) } } private func dbgGroupToggle(_ 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)) } .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) } } /// Compact Bodies toggle for both PiP and docked panel. /// Mirrors the ⚡ toggle in DebugConsoleView but accessible inline. @ViewBuilder private func dbgBodiesToggle() -> some View { Button(action: { DebugLogger.shared.trackViewBodies.toggle() }) { HStack(spacing: 4) { Image(systemName: DebugLogger.shared.trackViewBodies ? "bolt.fill" : "bolt.slash") .font(.system(size: 10)) Text("Bodies") .font(.system(size: 10, weight: .medium)) } .foregroundColor(DebugLogger.shared.trackViewBodies ? .orange : .gray.opacity(0.4)) .padding(.horizontal, 8).padding(.vertical, 4) .background(DebugLogger.shared.trackViewBodies ? Color.orange.opacity(0.2) : Color.white.opacity(0.05)) .cornerRadius(10) } } 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", "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 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) private var debugPipView: some View { let currentPos = CGPoint( x: pipPosition.x + pipDragOffset.width, y: pipPosition.y + pipDragOffset.height ) return VStack(spacing: 0) { // PiP header — always visible HStack(spacing: 8) { Image(systemName: "ladybug.fill") .font(.system(size: 10)) .foregroundColor(.red) Text("Debug") .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white) Text("\(filteredDebugEntries.count)/\(DebugLogger.shared.entries.count)") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundColor(.gray) Spacer() Button(action: { DebugLogger.shared.clear() }) { Image(systemName: "trash") .font(.system(size: 11)) .foregroundColor(.red.opacity(0.7)) } // Collapse/expand Button(action: { withAnimation(.easeInOut(duration: 0.2)) { pipCollapsed.toggle() } }) { Image(systemName: pipCollapsed ? "chevron.down" : "chevron.up") .font(.system(size: 11, weight: .semibold)) .foregroundColor(.white.opacity(0.6)) } // Dock back Button(action: { withAnimation(.easeInOut(duration: 0.3)) { debugPipMode = false } }) { Image(systemName: "dock.rectangle") .font(.system(size: 11)) .foregroundColor(.white.opacity(0.6)) } Button(action: { withAnimation(.easeInOut(duration: 0.2)) { debugLogger.isEnabled = false debugPipMode = false } }) { Image(systemName: "xmark") .font(.system(size: 10, weight: .bold)) .foregroundColor(.gray) } } .padding(.horizontal, 10) .padding(.vertical, 7) .background(Color(white: 0.12)) .gesture( DragGesture() .onChanged { value in pipDragOffset = value.translation } .onEnded { value in pipPosition = CGPoint( x: pipPosition.x + value.translation.width, y: pipPosition.y + value.translation.height ) pipDragOffset = .zero } ) // Log content — hidden when collapsed if !pipCollapsed { // ── Group toggles — same state as docked panel ─────────────── ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { dbgGroupToggle("iPhone", icon: "iphone", isOn: $dbgShowIPhone) dbgGroupToggle("Watch", icon: "applewatch", isOn: $dbgShowWatch) dbgGroupToggle("Companion", icon: "server.rack", isOn: $dbgShowCompanion) dbgGroupToggle("Audio", icon: "waveform", isOn: $dbgShowAudio) Divider().frame(height: 14).background(Color.white.opacity(0.15)) dbgBodiesToggle() } .padding(.horizontal, 10) } .padding(.vertical, 5) .background(Color(white: 0.10)) Divider().background(Color.white.opacity(0.1)) ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { ForEach(filteredDebugEntries) { entry in debugLogRow(entry) .id(entry.id) } } .padding(.horizontal, 8) .padding(.vertical, 4) } .onChange(of: DebugLogger.shared.entries.count) { _, _ in if let last = DebugLogger.shared.entries.last { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(last.id, anchor: .bottom) } } } } .frame(height: pipCollapsed ? 0 : pipSize.height - 32) // Resize handle HStack { Spacer() Image(systemName: "arrow.up.left.and.arrow.down.right") .font(.system(size: 9)) .foregroundColor(.white.opacity(0.3)) .padding(6) .gesture( DragGesture() .onChanged { value in if pipResizeStart == .zero { pipResizeStart = pipSize } let newW = max(200, pipResizeStart.width + value.translation.width) let newH = max(100, pipResizeStart.height + value.translation.height) pipSize = CGSize(width: min(newW, 500), height: min(newH, 500)) } .onEnded { _ in pipResizeStart = .zero } ) } .frame(height: 18) .background(Color(white: 0.08)) } } .frame(width: pipSize.width) .background(Color(white: 0.06).opacity(0.95)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.08), lineWidth: 0.5) ) .shadow(color: .black.opacity(0.5), radius: 12, y: 4) .position(x: currentPos.x + pipSize.width / 2, y: currentPos.y + (pipCollapsed ? 16 : pipSize.height / 2)) } } extension Notification.Name { static let navigateToPlaylist = Notification.Name("navigateToPlaylist") static let navigateToAlbum = Notification.Name("navigateToAlbum") static let navigateToArtist = Notification.Name("navigateToArtist") } // MARK: - Mini Player Bar with scrubbable progress struct MiniPlayerBar: View { @EnvironmentObject var audioPlayer: AudioPlayer @StateObject private var colorExtractor = AlbumColorExtractor.shared @Binding var showNowPlaying: Bool var namespace: Namespace.ID @State private var isScrubbing = false @State private var scrubPosition: Double = 0 private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniPlayerBar") : false VStack(spacing: 0) { // Progress bar extracted into its own view — only it re-evaluates at 10Hz // when audioPlayer.currentTime changes. The rest of MiniPlayerBar body // only re-evaluates on song change, play/pause, or color change. MiniProgressBar( colorExtractor: colorExtractor, isScrubbing: $isScrubbing, scrubPosition: $scrubPosition, accentPink: accentPink ) ZStack(alignment: .center) { // Visualizer behind controls — paused when full NowPlaying is open if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying { CompactVisualizerView( isPlaying: audioPlayer.isPlaying, isSongLoaded: audioPlayer.currentSong != nil, accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink, height: VisualizerSettings.shared.miniPlayerHeight ) .matchedGeometryEffect(id: "visWave", in: namespace) .offset(y: 10) .opacity(VisualizerSettings.shared.miniOpacity) .allowsHitTesting(false) } HStack(spacing: 12) { // Cover art Group { if let song = audioPlayer.currentSong, (song.album == "Radio" || audioPlayer.isRadioStream), let img = RadioCoverStore.shared.loadCover(for: song.id) { Image(uiImage: img) .resizable() .aspectRatio(contentMode: .fill) } else { AsyncCoverArt( coverArtId: audioPlayer.currentSong?.coverArt, size: 48 ) } } .frame(width: 44, height: 44) .cornerRadius(6) .shadow(radius: 3) .matchedGeometryEffect(id: "albumArt", in: namespace) VStack(alignment: .leading, spacing: 1) { Text(audioPlayer.currentSong?.title ?? "") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) Text(audioPlayer.currentSong?.artist ?? "") .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) .lineLimit(1) } Spacer() Button(action: { audioPlayer.previous() }) { Image(systemName: "backward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .frame(width: 36, height: 44) Button(action: { audioPlayer.togglePlayPause() }) { Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 22)) .foregroundColor(.white) } .frame(width: 44, height: 44) Button(action: { audioPlayer.next() }) { Image(systemName: "forward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .frame(width: 36, height: 44) } .padding(.horizontal, 12) .padding(.vertical, 8) } .frame(height: 60) .contentShape(Rectangle()) .onTapGesture { if !isScrubbing { dismissKeyboard() withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = true } } } } .background(.ultraThinMaterial) .background( colorExtractor.isLoaded ? colorExtractor.primaryColor.opacity(0.15) : Color.clear ) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .shadow(color: .black.opacity(0.3), radius: 8, y: 4) .padding(.horizontal, 8) } } // MARK: - Dynamic Island Layout struct DynamicIslandView: View { @EnvironmentObject var audioPlayer: AudioPlayer @StateObject private var colorExtractor = AlbumColorExtractor.shared var namespace: Namespace.ID @Binding var showNowPlaying: Bool @Binding var isDynamicIsland: Bool private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private var themeColor: Color { colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink } var body: some View { VStack(spacing: 0) { Spacer().frame(height: 54) // Below status bar + notch // The pill — only this is tappable for NowPlaying HStack(spacing: 0) { Group { if let song = audioPlayer.currentSong { AsyncCoverArt(coverArtId: song.coverArt, size: 48) } else { Color.gray.opacity(0.3) } } .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .matchedGeometryEffect(id: "albumArt", in: namespace) .padding(.leading, 10) VStack(alignment: .leading, spacing: 0) { Text(audioPlayer.currentSong?.title ?? "") .font(.system(size: 12, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) Text(audioPlayer.currentSong?.artist ?? "") .font(.system(size: 10)) .foregroundColor(.white.opacity(0.6)) .lineLimit(1) } .padding(.leading, 8) .frame(maxWidth: .infinity, alignment: .leading) if VisualizerSettings.shared.enabled { CompactVisualizerView( isPlaying: audioPlayer.isPlaying, isSongLoaded: audioPlayer.currentSong != nil, accentColor: themeColor, height: 32 ) .matchedGeometryEffect(id: "visWave", in: namespace) .frame(width: 64, height: 32) .clipShape(RoundedRectangle(cornerRadius: 10)) } else { Button(action: { audioPlayer.togglePlayPause() }) { Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 16)) .foregroundColor(.white) } .frame(width: 40, height: 32) } Spacer().frame(width: 10) } .frame(height: 48) .background( RoundedRectangle(cornerRadius: 26, style: .continuous) .fill( LinearGradient( colors: [Color.black, themeColor.opacity(0.25)], startPoint: .leading, endPoint: .trailing ) ) .shadow(color: themeColor.opacity(0.3), radius: 12, y: 2) ) .padding(.horizontal, 32) .contentShape(RoundedRectangle(cornerRadius: 26)) .onTapGesture { withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = true isDynamicIsland = false } } .gesture( DragGesture(minimumDistance: 8) .onEnded { value in if value.translation.height > 25 { withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { isDynamicIsland = false } } } ) Spacer() } // Status bar stays visible — no .statusBarHidden } } // MARK: - Mini Player Progress Bar (isolated re-evaluation) // // Extracted from MiniPlayerBar so only this tiny view re-evaluates when // audioPlayer.currentTime changes (10x/second from the periodic time observer). // Before this, the ENTIRE MiniPlayerBar body re-evaluated at 10Hz — including // the GeometryReader, CompactVisualizerView, all ZStack children, and album art — // causing sustained ~128% CPU even with the visualizer off. // // The key trick: this view declares its OWN @ObservedObject on audioPlayer so // SwiftUI's dependency tracking scopes the 10Hz invalidation to THIS view only. // MiniPlayerBar.body is now only re-evaluated on song change, play/pause, or // color change — not on every currentTime tick. private struct MiniProgressBar: View { // No @ObservedObject on AudioPlayer — currentTime is no longer @Published. // TimelineView drives re-evaluation at 10Hz independently so this view never // triggers objectWillChange on AudioPlayer or propagates updates to siblings. @ObservedObject var colorExtractor: AlbumColorExtractor @Binding var isScrubbing: Bool @Binding var scrubPosition: Double let accentPink: Color private var barColor: Color { colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink } var body: some View { let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniProgressBar") : false // TimelineView provides a fresh timestamp every 0.1s — the progress bar // reads AudioPlayer.shared.currentTime directly on each tick. // No @Published subscription means NowPlayingView/MiniPlayerBar are // completely unaffected by playback position changes. TimelineView(.periodic(from: .now, by: 0.1)) { _ in GeometryReader { geo in let player = AudioPlayer.shared let progress: Double = { if isScrubbing { return scrubPosition } guard player.duration > 0 else { return 0 } return min(player.currentTime / player.duration, 1.0) }() ZStack(alignment: .leading) { Rectangle() .fill(Color.white.opacity(0.1)) .frame(height: isScrubbing ? 8 : 3) Rectangle() .fill(barColor) .frame( width: geo.size.width * progress, height: isScrubbing ? 8 : 3 ) if isScrubbing { Circle() .fill(barColor) .frame(width: 16, height: 16) .offset(x: geo.size.width * progress - 8, y: -1) } } .frame(maxHeight: .infinity) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isScrubbing = true scrubPosition = min(max(value.location.x / geo.size.width, 0), 1) } .onEnded { value in let pct = min(max(value.location.x / geo.size.width, 0), 1) AudioPlayer.shared.seekToPercent(pct) scrubPosition = pct DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { isScrubbing = false } } ) } .frame(height: isScrubbing ? 20 : 14) .animation(.easeInOut(duration: 0.15), value: isScrubbing) } } } // MARK: - Keyboard Dismiss /// Dismiss keyboard from anywhere func dismissKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } /// UIKit tap recognizer that dismisses keyboard without blocking other touches. /// Added once to the key window — fires alongside all gestures. private class KeyboardDismissTapRecognizer: UITapGestureRecognizer, UIGestureRecognizerDelegate { override init(target: Any?, action: Selector?) { super.init(target: target, action: action) cancelsTouchesInView = false delegate = self } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { true // Never block other gestures } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { // Don't fire when tapping on text inputs themselves !(touch.view is UITextField || touch.view is UITextView) } } /// Helper target for the tap gesture — calls endEditing on the window private class KeyboardDismissTarget: NSObject { @objc func dismiss() { dismissKeyboard() } } /// View modifier that installs a one-time keyboard dismiss gesture on the window. struct KeyboardDismissible: ViewModifier { // Held as static so the target isn't deallocated private static let target = KeyboardDismissTarget() func body(content: Content) -> some View { content.onAppear { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first else { return } // Only add once let alreadyAdded = window.gestureRecognizers?.contains(where: { $0 is KeyboardDismissTapRecognizer }) ?? false if !alreadyAdded { let tap = KeyboardDismissTapRecognizer( target: KeyboardDismissible.target, action: #selector(KeyboardDismissTarget.dismiss) ) window.addGestureRecognizer(tap) } } } } extension View { func dismissKeyboardOnTap() -> some View { modifier(KeyboardDismissible()) } }