// // TVPlayerProgressBar.swift // Yattee // // Focusable progress bar for tvOS with smooth touchpad scrubbing support. // #if os(tvOS) import SwiftUI import UIKit /// Progress bar with smooth scrubbing support for tvOS Siri Remote touchpad. struct TVPlayerProgressBar: View { let currentTime: TimeInterval let duration: TimeInterval let bufferedTime: TimeInterval let storyboard: Storyboard? let chapters: [VideoChapter] let onSeek: (TimeInterval) -> Void /// Called when scrubbing state changes - parent should stop auto-hide timer when true var onScrubbingChanged: ((Bool) -> Void)? /// Whether the current stream is live let isLive: Bool /// Whether to show chapter markers on the progress bar (default: true) var showChapters: Bool = true /// SponsorBlock segments to display on the progress bar. var sponsorSegments: [SponsorBlockSegment] = [] /// Settings for SponsorBlock segment display. var sponsorBlockSettings: SponsorBlockSegmentSettings = .default /// Color for the played portion of the progress bar. var playedColor: Color = .red /// Track focus state internally. @FocusState private var isFocused: Bool /// Time during active scrubbing (nil when not scrubbing). @State private var scrubTime: TimeInterval? /// Whether user is actively scrubbing. @State private var isScrubbing = false /// Accumulated pan translation for scrubbing. @State private var panAccumulator: CGFloat = 0 /// The time to display (scrub time if scrubbing, else current time). private var displayTime: TimeInterval { scrubTime ?? currentTime } /// Progress as a fraction (0-1). private var progress: Double { guard duration > 0 else { return 0 } return min(max(displayTime / duration, 0), 1) } /// Buffered progress as a fraction (0-1). private var bufferedProgress: Double { guard duration > 0 else { return 0 } return min(bufferedTime / duration, 1) } var body: some View { ZStack { // Gesture capture layer (only when scrubbing) if isScrubbing { TVPanGestureView( onPanChanged: { translation, velocity in handlePan(translation: translation, velocity: velocity) }, onPanEnded: { handlePanEnded() } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } // Visual content with button for click-to-scrub (disabled for live) Button { if isScrubbing { commitScrub() } else if !isLive { enterScrubMode() } } label: { progressContent } .buttonStyle(TVProgressBarButtonStyle(isFocused: isFocused)) .disabled(isLive) } .focused($isFocused) .onMoveCommand { direction in // D-pad fallback for scrubbing if isScrubbing { handleDPad(direction: direction) } } .onChange(of: isFocused) { _, focused in if !focused { commitScrub() } } .animation(.easeInOut(duration: 0.2), value: isFocused) .animation(.easeInOut(duration: 0.1), value: isScrubbing) } private func enterScrubMode() { scrubTime = currentTime panAccumulator = 0 withAnimation(.easeOut(duration: 0.15)) { isScrubbing = true } onScrubbingChanged?(true) } private var progressContent: some View { VStack(spacing: 12) { // Progress bar (hide for live streams) if !isLive { GeometryReader { geometry in ZStack(alignment: .leading) { // Progress bar with chapter segments (4pt gaps for tvOS visibility) SegmentedProgressBar( chapters: showChapters ? chapters : [], duration: duration, currentTime: displayTime, bufferedTime: bufferedTime, height: isFocused ? (isScrubbing ? 16 : 12) : 6, gapWidth: 4, playedColor: isFocused ? playedColor : .white, bufferedColor: .white.opacity(0.4), backgroundColor: .white.opacity(0.2), sponsorSegments: sponsorSegments, sponsorBlockSettings: sponsorBlockSettings ) // Scrub handle (visible when focused) if isFocused { Circle() .fill(.white) .frame(width: isScrubbing ? 32 : 24, height: isScrubbing ? 32 : 24) .shadow(color: .black.opacity(0.3), radius: 4) .offset(x: (geometry.size.width * progress) - (isScrubbing ? 16 : 12)) .animation(.easeOut(duration: 0.1), value: progress) } } } .frame(height: isFocused ? (isScrubbing ? 16 : 12) : 6) } // Time labels HStack { // Current time or LIVE indicator if isLive { HStack(spacing: 6) { Circle() .fill(.red) .frame(width: 8, height: 8) Text("LIVE") .font(.subheadline.weight(.semibold)) .foregroundStyle(.red) } } else { Text(formatTime(displayTime)) .monospacedDigit() .font(.subheadline) .fontWeight(isScrubbing ? .semibold : .regular) .foregroundStyle(.white) } Spacer() // Scrub hint when focused (only for non-live) if !isLive && isFocused && !isScrubbing { Text("Press to scrub") .font(.caption) .foregroundStyle(.white.opacity(0.5)) } else if !isLive && isScrubbing { Text("Swipe ← → • press to seek") .font(.caption) .foregroundStyle(.white.opacity(0.7)) } Spacer() // Remaining time (only for non-live) if !isLive { Text("-\(formatTime(max(0, duration - displayTime)))") .monospacedDigit() .font(.subheadline) .foregroundStyle(.white.opacity(0.7)) } } // Scrub preview (storyboard thumbnail or large time display) if isScrubbing { if let storyboard { TVSeekPreviewView( storyboard: storyboard, seekTime: scrubTime ?? currentTime, chapters: showChapters ? chapters : [] ) .transition(.scale.combined(with: .opacity)) } else { // Fallback when no storyboard available Text(formatTime(scrubTime ?? currentTime)) .font(.system(size: 48, weight: .medium)) .monospacedDigit() .foregroundStyle(.white) .padding(.horizontal, 32) .padding(.vertical, 16) .background( RoundedRectangle(cornerRadius: 16) .fill(.ultraThinMaterial) ) .transition(.scale.combined(with: .opacity)) } } } } // MARK: - Pan Gesture Handling private func handlePan(translation: CGFloat, velocity: CGFloat) { guard duration > 0, isScrubbing else { return } // Cancel any pending seek when user starts new pan seekTask?.cancel() // Calculate scrub sensitivity based on duration // Lower values = slower/finer scrubbing let baseSensitivity: CGFloat if duration > 3600 { baseSensitivity = duration / 2000 // ~1.8 sec per unit for 1hr video } else if duration > 600 { baseSensitivity = duration / 3000 // ~0.2 sec per unit for 10min video } else { baseSensitivity = duration / 4000 // very fine control for short videos } // Apply velocity multiplier (faster swipe = faster scrub) // Reduced range: 0.3x to 2.0x let velocityMultiplier = min(max(abs(velocity) / 800, 0.3), 2.0) let adjustedSensitivity = baseSensitivity * velocityMultiplier // Update scrub time based on translation delta let delta = translation - panAccumulator panAccumulator = translation let timeChange = TimeInterval(delta * adjustedSensitivity) let currentScrubTime = scrubTime ?? currentTime scrubTime = min(max(0, currentScrubTime + timeChange), duration) } private func handlePanEnded() { // Reset accumulator for next swipe panAccumulator = 0 // Schedule debounced seek but stay in scrub mode scheduleSeek() } @State private var seekTask: Task? private func scheduleSeek() { seekTask?.cancel() seekTask = Task { try? await Task.sleep(for: .milliseconds(1000)) guard !Task.isCancelled else { return } await MainActor.run { if let time = scrubTime { onSeek(time) } } } } // MARK: - D-Pad Fallback private func handleDPad(direction: MoveCommandDirection) { guard duration > 0, isScrubbing else { return } switch direction { case .left, .right: // Determine scrub increment based on video length let scrubAmount: TimeInterval if duration > 3600 { scrubAmount = 30 } else if duration > 600 { scrubAmount = 15 } else { scrubAmount = 10 } let currentScrubTime = scrubTime ?? currentTime if direction == .left { scrubTime = max(0, currentScrubTime - scrubAmount) } else { scrubTime = min(duration, currentScrubTime + scrubAmount) } case .up, .down: // Exit scrub mode and let navigation happen commitScrub() @unknown default: break } } // MARK: - Commit private func commitScrub() { seekTask?.cancel() seekTask = nil let wasScrubbing = isScrubbing if let time = scrubTime { onSeek(time) } withAnimation(.easeOut(duration: 0.15)) { scrubTime = nil isScrubbing = false } panAccumulator = 0 if wasScrubbing { onScrubbingChanged?(false) } } // MARK: - Formatting private func formatTime(_ time: TimeInterval) -> String { let totalSeconds = Int(max(0, time)) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%d:%02d", minutes, seconds) } } // MARK: - Pan Gesture View /// UIKit view that captures pan gestures on the Siri Remote touchpad. struct TVPanGestureView: UIViewRepresentable { let onPanChanged: (CGFloat, CGFloat) -> Void // (translation, velocity) let onPanEnded: () -> Void func makeUIView(context: Context) -> TVPanGestureUIView { let view = TVPanGestureUIView() view.onPanChanged = onPanChanged view.onPanEnded = onPanEnded return view } func updateUIView(_ uiView: TVPanGestureUIView, context: Context) { uiView.onPanChanged = onPanChanged uiView.onPanEnded = onPanEnded } } class TVPanGestureUIView: UIView { var onPanChanged: ((CGFloat, CGFloat) -> Void)? var onPanEnded: (() -> Void)? private var panRecognizer: UIPanGestureRecognizer! override init(frame: CGRect) { super.init(frame: frame) setupGesture() } required init?(coder: NSCoder) { super.init(coder: coder) setupGesture() } private func setupGesture() { panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) panRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] addGestureRecognizer(panRecognizer) // Make view focusable isUserInteractionEnabled = true } @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: self).x let velocity = gesture.velocity(in: self).x switch gesture.state { case .began, .changed: onPanChanged?(translation, velocity) case .ended, .cancelled: onPanEnded?() gesture.setTranslation(.zero, in: self) default: break } } } // MARK: - Button Style /// Button style for the progress bar. struct TVProgressBarButtonStyle: ButtonStyle { let isFocused: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.98 : 1.0) .opacity(configuration.isPressed ? 0.9 : 1.0) } } #endif