diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index e106c4e..f0be70e 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -22,7 +22,7 @@ struct MainTabView: View { @State private var debugDragOffset: CGFloat = 0 @State private var showFullConsole = false - // Group toggles — same @AppStorage keys as DebugConsoleView so state is shared + // 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 @@ -91,7 +91,7 @@ struct MainTabView: View { } } - // Debug panel (docked) — only when enabled and NOT in PiP mode + // Debug panel (docked) -- only when enabled and NOT in PiP mode if debugLogger.isEnabled && !debugPipMode { debugPanel .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -140,7 +140,7 @@ struct MainTabView: View { dragOffset = 0 } } else if value.translation.height < -20 { - // Small upward swipe — open NowPlaying + // Small upward swipe -- open NowPlaying dragOffset = 0 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = true @@ -157,7 +157,7 @@ struct MainTabView: View { } } - // Now Playing overlay — always rendered, positioned via offset + // Now Playing overlay -- always rendered, positioned via offset // This avoids re-layout animation when appearing if audioPlayer.currentSong != nil { NowPlayingView(isPresented: $showNowPlaying) @@ -166,7 +166,7 @@ struct MainTabView: View { .allowsHitTesting(showNowPlaying) } - // Debug PiP (floating) — above everything except Now Playing + // Debug PiP (floating) -- above everything except Now Playing if debugLogger.isEnabled && debugPipMode { debugPipView .zIndex(showNowPlaying ? 0 : 5) @@ -268,7 +268,7 @@ struct MainTabView: View { Divider().background(Color.white.opacity(0.15)) - // Log entries — filtered by group toggles + // Log entries -- filtered by group toggles ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { @@ -404,7 +404,7 @@ struct MainTabView: View { ) return VStack(spacing: 0) { - // PiP header — always visible + // PiP header -- always visible HStack(spacing: 8) { Image(systemName: "ladybug.fill") .font(.system(size: 10)) @@ -476,9 +476,9 @@ struct MainTabView: View { } ) - // Log content — hidden when collapsed + // Log content -- hidden when collapsed if !pipCollapsed { - // ── Group toggles — same state as docked panel ─────────────── + // ── Group toggles -- same state as docked panel ─────────────── ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { dbgGroupToggle("iPhone", icon: "iphone", isOn: $dbgShowIPhone) @@ -574,7 +574,7 @@ struct MiniPlayerBar: View { 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 + // 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( @@ -584,7 +584,7 @@ struct MiniPlayerBar: View { accentPink: accentPink ) ZStack(alignment: .center) { - // Visualizer behind controls — paused when full NowPlaying is open + // Visualizer behind controls -- paused when full NowPlaying is open if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying { CompactVisualizerView( isPlaying: audioPlayer.isPlaying, @@ -696,7 +696,7 @@ struct DynamicIslandView: View { VStack(spacing: 0) { Spacer().frame(height: 54) // Below status bar + notch - // The pill — only this is tappable for NowPlaying + // The pill -- only this is tappable for NowPlaying HStack(spacing: 0) { Group { if let song = audioPlayer.currentSong { @@ -777,7 +777,7 @@ struct DynamicIslandView: View { Spacer() } - // Status bar stays visible — no .statusBarHidden + // Status bar stays visible -- no .statusBarHidden } } @@ -785,17 +785,17 @@ struct DynamicIslandView: View { // // 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 — +// 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. +// color change -- not on every currentTime tick. private struct MiniProgressBar: View { - // No @ObservedObject on AudioPlayer — currentTime is no longer @Published. + // 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 @@ -809,59 +809,70 @@ private struct MiniProgressBar: View { 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) + // GeometryReader and gesture live OUTSIDE TimelineView. + // Previously the gesture was inside the TimelineView closure -- every 0.1s + // TimelineView re-evaluated its content, reconstructing the DragGesture + // and interrupting in-progress touches, causing missed/dropped scrubs. + // Only the fill Rectangle that reads currentTime sits inside TimelineView. + GeometryReader { geo in + ZStack(alignment: .leading) { + // Track background -- static, no TimelineView needed + Rectangle() + .fill(Color.white.opacity(0.1)) + .frame(height: isScrubbing ? 8 : 3) + // Fill -- inside TimelineView so it updates at 10Hz without + // disturbing the gesture recognizer above + TimelineView(.periodic(from: .now, by: 0.1)) { _ 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) + }() Rectangle() .fill(barColor) - .frame( - width: geo.size.width * progress, - height: isScrubbing ? 8 : 3 - ) + .frame(width: geo.size.width * progress, + height: isScrubbing ? 8 : 3) + } - if isScrubbing { + // Scrub thumb -- only visible while dragging + if isScrubbing { + TimelineView(.periodic(from: .now, by: 0.1)) { _ 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) + }() 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) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + // Gesture is stable -- never reconstructed by TimelineView ticks + .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) } } @@ -873,7 +884,7 @@ func dismissKeyboard() { } /// UIKit tap recognizer that dismisses keyboard without blocking other touches. -/// Added once to the key window — fires alongside all gestures. +/// 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) @@ -891,7 +902,7 @@ private class KeyboardDismissTapRecognizer: UITapGestureRecognizer, UIGestureRec } } -/// Helper target for the tap gesture — calls endEditing on the window +/// Helper target for the tap gesture -- calls endEditing on the window private class KeyboardDismissTarget: NSObject { @objc func dismiss() { dismissKeyboard()