From c8360419c35498c84741b299cbfd118d491257ce Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 10 Apr 2026 07:33:14 -0700 Subject: [PATCH] miniplayer fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: Snap-back on seek end: Previously isScrubbing = false was set immediately after seekToPercent(pct). AVPlayer.seek is asynchronous — currentTime doesn’t update instantly. So displayProgress switched from scrubPosition back to the old playbackTime / playbackDuration for up to 250ms until the timer polled again, causing a visible snap-back. Now scrubPosition is held at pct and isScrubbing clears after a 150ms delay — enough time for AVPlayer to confirm the seek position before the bar resumes tracking currentTime. Stale timer poller removed: playbackTime and playbackDuration were @State vars updated by a Timer.publish every 0.25s. Since currentTime and duration are already @Published on AudioPlayer, the timer was just adding lag and a secondary update cycle. They’re now computed properties reading directly from audioPlayer.currentTime and audioPlayer.duration — updates arrive instantly via SwiftUI’s observation, same as the full Now Playing view. --- iOS/Views/Common/MainTabView.swift | 33 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index 5421c2b..a46fdcb 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -492,20 +492,19 @@ struct MiniPlayerBar: View { var namespace: Namespace.ID @State private var isScrubbing = false @State private var scrubPosition: Double = 0 - @State private var playbackTime: TimeInterval = 0 - @State private var playbackDuration: TimeInterval = 0 - + private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - // Poll currentTime at our own pace — doesn't trigger parent view redraws - private let timePoller = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect() - + + // Read directly from @Published properties — no timer lag + private var playbackTime: TimeInterval { audioPlayer.currentTime } + private var playbackDuration: TimeInterval { audioPlayer.duration } + private var displayProgress: Double { if isScrubbing { return scrubPosition } guard playbackDuration > 0 else { return 0 } return min(playbackTime / playbackDuration, 1.0) } - + var body: some View { VStack(spacing: 0) { // Scrubbable progress bar at top — uses album color, generous touch target @@ -514,14 +513,14 @@ struct MiniPlayerBar: View { Rectangle() .fill(Color.white.opacity(0.1)) .frame(height: isScrubbing ? 8 : 3) - + Rectangle() .fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink) .frame( width: geo.size.width * displayProgress, height: isScrubbing ? 8 : 3 ) - + if isScrubbing { Circle() .fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink) @@ -540,11 +539,17 @@ struct MiniPlayerBar: View { .onEnded { value in let pct = min(max(value.location.x / geo.size.width, 0), 1) audioPlayer.seekToPercent(pct) - isScrubbing = false + // Hold scrubPosition until AVPlayer confirms the seek — + // without this the bar snaps back to the pre-seek position + // for up to 250ms while currentTime catches up. + scrubPosition = pct + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + isScrubbing = false + } } ) } - .frame(height: isScrubbing ? 20 : 14) // Generous touch target even when not scrubbing + .frame(height: isScrubbing ? 20 : 14) .animation(.easeInOut(duration: 0.15), value: isScrubbing) // Player content @@ -639,10 +644,6 @@ struct MiniPlayerBar: View { .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .shadow(color: .black.opacity(0.3), radius: 8, y: 4) .padding(.horizontal, 8) - .onReceive(timePoller) { _ in - playbackTime = audioPlayer.currentTime - playbackDuration = audioPlayer.duration - } } }