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,7 +243,11 @@ 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
// SELECT-based scrub we live-seek the underlying frame, so the
// storyboard would just duplicate what's already on screen.
// Chapter capsule: shown for either scrub mode it conveys info the
// raw frame doesn't (chapter title) and stays useful during live seek.
let seekTime = displayTime let seekTime = displayTime
let currentChapter = showChapters ? chapters.last(where: { $0.startTime <= seekTime }) : nil let currentChapter = showChapters ? chapters.last(where: { $0.startTime <= seekTime }) : nil
// Storyboard panel is 320 thumbnail + 4pt horizontal padding * 2 = 328, plus shadow. // Storyboard panel is 320 thumbnail + 4pt horizontal padding * 2 = 328, plus shadow.
@@ -258,10 +264,14 @@ struct TVPlayerProgressBar: View {
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,27 +293,25 @@ 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), if (isScrubbing || showArrowSeekPreview), let currentChapter {
// positioned to follow the scrub handle and clamped to stay on screen.
if 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
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.
/// Keeps the underlying frame in sync with the storyboard preview while
/// scrubbing without flooding the backend with seek calls.
private func scheduleLiveSeek() {
let interval: TimeInterval = 0.15
let now = Date()
let elapsed = lastLiveSeekTime.map { now.timeIntervalSince($0) } ?? .infinity
if elapsed >= interval {
seekTask?.cancel() seekTask?.cancel()
seekTask = Task { seekTask = nil
try? await Task.sleep(for: .milliseconds(1000)) 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 } guard !Task.isCancelled else { return }
await MainActor.run { 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