Live-seek tvOS scrubber and auto-commit on idle

Throttle SELECT-based scrubbing to seek the underlying frame ~every
150ms instead of waiting 1s after pan-end, so the visible frame keeps
up with the scrub handle. Hide the redundant storyboard panel during
live scrub (the frame itself is now the preview) but keep the chapter
capsule visible. Storyboard panel still shown for D-pad arrow-seek
where the frame doesn't move until commit.

Auto-commit scrub mode after 3s of inactivity, matching
AVPlayerViewController behavior — playback resumes via the existing
scrub-pause wiring instead of staying paused indefinitely.
This commit is contained in:
Arkadiusz Fal
2026-05-09 11:24:46 +02:00
parent 80838db9cc
commit 9e13bffa8c

View File

@@ -160,10 +160,12 @@ struct TVPlayerProgressBar: View {
private func enterScrubMode() { private func enterScrubMode() {
scrubTime = currentTime scrubTime = currentTime
panAccumulator = 0 panAccumulator = 0
lastLiveSeekTime = nil
withAnimation(.easeOut(duration: 0.15)) { withAnimation(.easeOut(duration: 0.15)) {
isScrubbing = true isScrubbing = true
} }
onScrubbingChanged?(true) onScrubbingChanged?(true)
scheduleIdleAutoCommit()
} }
private var progressContent: some View { private var progressContent: some View {
@@ -241,27 +243,35 @@ struct TVPlayerProgressBar: View {
@ViewBuilder @ViewBuilder
private func scrubPreviewOverlay(geometry: GeometryProxy) -> some View { private func scrubPreviewOverlay(geometry: GeometryProxy) -> some View {
if isScrubbing || showArrowSeekPreview { // Storyboard panel: only for arrow-seek (accumulator mode). During
let seekTime = displayTime // SELECT-based scrub we live-seek the underlying frame, so the
let currentChapter = showChapters ? chapters.last(where: { $0.startTime <= seekTime }) : nil // storyboard would just duplicate what's already on screen.
// Storyboard panel is 320 thumbnail + 4pt horizontal padding * 2 = 328, plus shadow. // Chapter capsule: shown for either scrub mode it conveys info the
// Use a slightly larger clamp width so the shadow stays on screen. // raw frame doesn't (chapter title) and stays useful during live seek.
let panelWidth: CGFloat = 344 let seekTime = displayTime
// Panel height: thumbnail 180 + 4pt vertical padding * 2 = 188 (round up for shadow). let currentChapter = showChapters ? chapters.last(where: { $0.startTime <= seekTime }) : nil
let panelHeight: CGFloat = 200 // Storyboard panel is 320 thumbnail + 4pt horizontal padding * 2 = 328, plus shadow.
let capsuleSpacing: CGFloat = 8 // Use a slightly larger clamp width so the shadow stays on screen.
// Approximate capsule height (24pt text + 6pt padding * 2 + shadow) used only let panelWidth: CGFloat = 344
// for vertical positioning, not for layout sizing. // Panel height: thumbnail 180 + 4pt vertical padding * 2 = 188 (round up for shadow).
let capsuleApproxHeight: CGFloat = 44 let panelHeight: CGFloat = 200
let capsuleSpacing: CGFloat = 8
// Approximate capsule height (24pt text + 6pt padding * 2 + shadow) used only
// for vertical positioning, not for layout sizing.
let capsuleApproxHeight: CGFloat = 44
let xTarget = geometry.size.width * progress let xTarget = geometry.size.width * progress
let halfPanel = panelWidth / 2 let halfPanel = panelWidth / 2
let clampedPanelX = max(halfPanel, min(geometry.size.width - halfPanel, xTarget)) let clampedPanelX = max(halfPanel, min(geometry.size.width - halfPanel, xTarget))
let panelCenterY = -panelHeight / 2 - 16 let panelCenterY = -panelHeight / 2 - 16
let capsuleCenterY = -panelHeight - 16 - capsuleSpacing - capsuleApproxHeight / 2 // When the storyboard panel is hidden, place the capsule where the
// panel would have been so it doesn't float far above the bar.
let capsuleCenterY = showArrowSeekPreview
? -panelHeight - 16 - capsuleSpacing - capsuleApproxHeight / 2
: -16 - capsuleApproxHeight / 2
ZStack { ZStack {
// Storyboard panel follows scrub handle, tight horizontal clamp. if showArrowSeekPreview {
Group { Group {
if let storyboard { if let storyboard {
TVSeekPreviewView( TVSeekPreviewView(
@@ -283,18 +293,17 @@ struct TVPlayerProgressBar: View {
} }
.fixedSize() .fixedSize()
.position(x: clampedPanelX, y: panelCenterY) .position(x: clampedPanelX, y: panelCenterY)
.transition(.scale.combined(with: .opacity))
// Chapter capsule sized to its title (up to screen width minus margin), }
// positioned to follow the scrub handle and clamped to stay on screen.
if let currentChapter { if (isScrubbing || showArrowSeekPreview), let currentChapter {
TVChapterCapsuleView(title: currentChapter.title) TVChapterCapsuleView(title: currentChapter.title)
.positioned(xTarget: xTarget, availableWidth: geometry.size.width) .positioned(xTarget: xTarget, availableWidth: geometry.size.width)
.position(x: geometry.size.width / 2, y: capsuleCenterY) .position(x: geometry.size.width / 2, y: capsuleCenterY)
} .transition(.opacity)
} }
.transition(.scale.combined(with: .opacity))
.allowsHitTesting(false)
} }
.allowsHitTesting(false)
} }
// MARK: - Pan Gesture Handling // MARK: - Pan Gesture Handling
@@ -302,8 +311,7 @@ struct TVPlayerProgressBar: View {
private func handlePan(translation: CGFloat, velocity: CGFloat) { private func handlePan(translation: CGFloat, velocity: CGFloat) {
guard duration > 0, isScrubbing else { return } guard duration > 0, isScrubbing else { return }
// Cancel any pending seek when user starts new pan scheduleIdleAutoCommit()
seekTask?.cancel()
// Calculate scrub sensitivity based on duration // Calculate scrub sensitivity based on duration
// Lower values = slower/finer scrubbing // Lower values = slower/finer scrubbing
@@ -328,23 +336,44 @@ struct TVPlayerProgressBar: View {
let timeChange = TimeInterval(delta * adjustedSensitivity) let timeChange = TimeInterval(delta * adjustedSensitivity)
let currentScrubTime = scrubTime ?? currentTime let currentScrubTime = scrubTime ?? currentTime
scrubTime = min(max(0, currentScrubTime + timeChange), duration) scrubTime = min(max(0, currentScrubTime + timeChange), duration)
scheduleLiveSeek()
} }
private func handlePanEnded() { private func handlePanEnded() {
// Reset accumulator for next swipe // Reset accumulator for next swipe
panAccumulator = 0 panAccumulator = 0
// Schedule debounced seek but stay in scrub mode // Flush any pending throttled seek so the underlying frame catches up
scheduleSeek() // immediately when the user lifts their finger.
flushLiveSeek()
scheduleIdleAutoCommit()
} }
@State private var seekTask: Task<Void, Never>? @State private var seekTask: Task<Void, Never>?
@State private var lastLiveSeekTime: Date?
private func scheduleSeek() { /// Live-seek throttle: ~150ms leading + trailing.
seekTask?.cancel() /// Keeps the underlying frame in sync with the storyboard preview while
seekTask = Task { /// scrubbing without flooding the backend with seek calls.
try? await Task.sleep(for: .milliseconds(1000)) private func scheduleLiveSeek() {
guard !Task.isCancelled else { return } let interval: TimeInterval = 0.15
await MainActor.run { let now = Date()
let elapsed = lastLiveSeekTime.map { now.timeIntervalSince($0) } ?? .infinity
if elapsed >= interval {
seekTask?.cancel()
seekTask = nil
lastLiveSeekTime = now
if let time = scrubTime {
onSeek(time)
}
} else if seekTask == nil {
let delay = interval - elapsed
seekTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
seekTask = nil
lastLiveSeekTime = Date()
if let time = scrubTime { if let time = scrubTime {
onSeek(time) onSeek(time)
} }
@@ -352,6 +381,35 @@ struct TVPlayerProgressBar: View {
} }
} }
private func flushLiveSeek() {
seekTask?.cancel()
seekTask = nil
lastLiveSeekTime = Date()
if let time = scrubTime {
onSeek(time)
}
}
// MARK: - Idle auto-commit
/// Auto-commit timer: AVPlayerViewController commits scrub mode after a
/// few seconds of inactivity so playback can resume.
@State private var idleAutoCommitTask: Task<Void, Never>?
private func scheduleIdleAutoCommit() {
idleAutoCommitTask?.cancel()
idleAutoCommitTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled, isScrubbing else { return }
commitScrub()
}
}
private func cancelIdleAutoCommit() {
idleAutoCommitTask?.cancel()
idleAutoCommitTask = nil
}
// MARK: - D-Pad Fallback // MARK: - D-Pad Fallback
private func handleDPad(direction: MoveCommandDirection) { private func handleDPad(direction: MoveCommandDirection) {
@@ -395,7 +453,8 @@ struct TVPlayerProgressBar: View {
} else { } else {
scrubTime = min(duration, currentScrubTime + scrubAmount) scrubTime = min(duration, currentScrubTime + scrubAmount)
} }
scheduleSeek() scheduleLiveSeek()
scheduleIdleAutoCommit()
case .up, .down: case .up, .down:
// Exit scrub mode and let navigation happen // Exit scrub mode and let navigation happen
@@ -414,6 +473,7 @@ struct TVPlayerProgressBar: View {
private func commitScrub() { private func commitScrub() {
seekTask?.cancel() seekTask?.cancel()
seekTask = nil seekTask = nil
cancelIdleAutoCommit()
let wasScrubbing = isScrubbing let wasScrubbing = isScrubbing
@@ -430,6 +490,7 @@ struct TVPlayerProgressBar: View {
dpadStreakCount = 0 dpadStreakCount = 0
lastDPadTime = nil lastDPadTime = nil
lastDPadDirection = nil lastDPadDirection = nil
lastLiveSeekTime = nil
arrowSeekPreviewHideTask?.cancel() arrowSeekPreviewHideTask?.cancel()
arrowSeekPreviewHideTask = nil arrowSeekPreviewHideTask = nil
@@ -441,6 +502,7 @@ struct TVPlayerProgressBar: View {
private func cancelScrub() { private func cancelScrub() {
seekTask?.cancel() seekTask?.cancel()
seekTask = nil seekTask = nil
cancelIdleAutoCommit()
let wasScrubbing = isScrubbing let wasScrubbing = isScrubbing
@@ -453,6 +515,7 @@ struct TVPlayerProgressBar: View {
dpadStreakCount = 0 dpadStreakCount = 0
lastDPadTime = nil lastDPadTime = nil
lastDPadDirection = nil lastDPadDirection = nil
lastLiveSeekTime = nil
arrowSeekPreviewHideTask?.cancel() arrowSeekPreviewHideTask?.cancel()
arrowSeekPreviewHideTask = nil arrowSeekPreviewHideTask = nil