Fix: Album streaming, seek bar gesture, and offline playback

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.
This commit is contained in:
Dallas Groot 2026-04-11 17:41:20 -07:00
parent 7657b5841e
commit f19d21e4cc

View file

@ -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()