From f19d21e4cc667b5a3b266d9d06614b576f806518 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sat, 11 Apr 2026 17:41:20 -0700 Subject: [PATCH] Fix: Album streaming, seek bar gesture, and offline playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve several playback bugs introduced during the CPU optimization work. Stop SyncEngine from overwriting the Subsonic album cache with synthetic Companion IDs, which was silently breaking every album in the Albums tab by routing through the Companion song path instead of Navidrome streaming. Fix MiniProgressBar seek gesture dropping touches by moving the DragGesture outside the TimelineView closure — the 10Hz timer was reconstructing the gesture recognizer every tick, interrupting in-progress drags. Add AVPlayerItem status observation to AudioPlayer so stream failures surface in the debug console instead of silently stalling. Add fuzzy title/artist/duration fallback to OfflineManager.localURL so songs remain playable offline after a Companion API restructure changes their Navidrome ID. --- iOS/Views/Common/MainTabView.swift | 131 ++++++++++++++++------------- 1 file changed, 71 insertions(+), 60 deletions(-) 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()