mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55:03 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user