Fade out tvOS player controls on auto-hide

This commit is contained in:
Arkadiusz Fal
2026-04-15 20:03:49 +02:00
parent 944f849929
commit 851c7e2ebf

View File

@@ -201,34 +201,39 @@ struct TVPlayerView: View {
// Video layer // Video layer
videoLayer videoLayer
// Controls overlay // Controls overlay always in tree, toggled via opacity so the fade-out
if controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible { // isn't skipped by tvOS's focus engine forcibly tearing down a focused
TVPlayerControlsView( // subview when the conditional flips to false. Focus is redirected to
playerState: playerState, // the background Button after each hide so the remote touch area still
playerService: playerService, // triggers showControls() instead of hitting a disabled hidden control.
focusedControl: $focusedControl, TVPlayerControlsView(
onShowSettings: { showQualitySheet() }, playerState: playerState,
onShowQueue: { showQueueSheet() }, playerService: playerService,
onShowDetails: { showDetailsPanel(tab: .info) }, focusedControl: $focusedControl,
onShowComments: { showDetailsPanel(tab: .comments) }, onShowSettings: { showQualitySheet() },
onShowDebug: { showDebugOverlay() }, onShowQueue: { showQueueSheet() },
onClose: { closeVideo() }, onShowDetails: { showDetailsPanel(tab: .info) },
onScrubbingChanged: { scrubbing in onShowComments: { showDetailsPanel(tab: .comments) },
isScrubbing = scrubbing onShowDebug: { showDebugOverlay() },
if scrubbing { onClose: { closeVideo() },
stopControlsTimer() onScrubbingChanged: { scrubbing in
} else { isScrubbing = scrubbing
startControlsTimer() if scrubbing {
} stopControlsTimer()
}, } else {
remoteSeekTime: scrubberRemoteSeekTime, startControlsTimer()
onRemoteSeek: { forward in }
triggerScrubberRemoteSeek(forward: forward) },
}, remoteSeekTime: scrubberRemoteSeekTime,
cancelScrubTrigger: cancelScrubTrigger onRemoteSeek: { forward in
) triggerScrubberRemoteSeek(forward: forward)
.transition(.opacity.animation(.easeInOut(duration: 0.25))) },
} cancelScrubTrigger: cancelScrubTrigger
)
.opacity(shouldShowControls ? 1 : 0)
.allowsHitTesting(shouldShowControls)
.disabled(!shouldShowControls)
.animation(.easeInOut(duration: 0.25), value: shouldShowControls)
// Swipe-up details panel // Swipe-up details panel
if isDetailsPanelVisible { if isDetailsPanelVisible {
@@ -382,6 +387,13 @@ struct TVPlayerView: View {
} }
} }
// MARK: - Derived State
/// Whether the primary controls overlay should be visible right now.
private var shouldShowControls: Bool {
controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible
}
// MARK: - Controls Timer // MARK: - Controls Timer
private func startControlsTimer() { private func startControlsTimer() {
@@ -392,10 +404,13 @@ struct TVPlayerView: View {
controlsHideTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in controlsHideTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
Task { @MainActor in Task { @MainActor in
withAnimation(.easeOut(duration: 0.3)) { withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false controlsVisible = false
focusedControl = .background
} }
// After controlsVisible flips, the backgroundLayer Button is in the
// tree and focusable move focus to it so the remote touch area
// calls showControls() instead of a hidden/disabled control.
focusedControl = .background
} }
} }
} }
@@ -664,8 +679,11 @@ struct TVPlayerView: View {
stopControlsTimer() stopControlsTimer()
withAnimation(.easeOut(duration: 0.25)) { withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false controlsVisible = false
focusedControl = .background
} }
// Focus is redirected outside withAnimation to keep the fade-out animation
// from being skipped, and to keep the remote touch area pointing at the
// background Button's showControls() action rather than a hidden control.
focusedControl = .background
} }
private func closeVideo() { private func closeVideo() {