diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 6dbea857..cb1ddd52 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -201,34 +201,39 @@ struct TVPlayerView: View { // Video layer videoLayer - // Controls overlay - if controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible { - TVPlayerControlsView( - playerState: playerState, - playerService: playerService, - focusedControl: $focusedControl, - onShowSettings: { showQualitySheet() }, - onShowQueue: { showQueueSheet() }, - onShowDetails: { showDetailsPanel(tab: .info) }, - onShowComments: { showDetailsPanel(tab: .comments) }, - onShowDebug: { showDebugOverlay() }, - onClose: { closeVideo() }, - onScrubbingChanged: { scrubbing in - isScrubbing = scrubbing - if scrubbing { - stopControlsTimer() - } else { - startControlsTimer() - } - }, - remoteSeekTime: scrubberRemoteSeekTime, - onRemoteSeek: { forward in - triggerScrubberRemoteSeek(forward: forward) - }, - cancelScrubTrigger: cancelScrubTrigger - ) - .transition(.opacity.animation(.easeInOut(duration: 0.25))) - } + // Controls overlay — always in tree, toggled via opacity so the fade-out + // isn't skipped by tvOS's focus engine forcibly tearing down a focused + // subview when the conditional flips to false. Focus is redirected to + // the background Button after each hide so the remote touch area still + // triggers showControls() instead of hitting a disabled hidden control. + TVPlayerControlsView( + playerState: playerState, + playerService: playerService, + focusedControl: $focusedControl, + onShowSettings: { showQualitySheet() }, + onShowQueue: { showQueueSheet() }, + onShowDetails: { showDetailsPanel(tab: .info) }, + onShowComments: { showDetailsPanel(tab: .comments) }, + onShowDebug: { showDebugOverlay() }, + onClose: { closeVideo() }, + onScrubbingChanged: { scrubbing in + isScrubbing = scrubbing + if scrubbing { + stopControlsTimer() + } else { + startControlsTimer() + } + }, + remoteSeekTime: scrubberRemoteSeekTime, + onRemoteSeek: { forward in + triggerScrubberRemoteSeek(forward: forward) + }, + cancelScrubTrigger: cancelScrubTrigger + ) + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(shouldShowControls) + .disabled(!shouldShowControls) + .animation(.easeInOut(duration: 0.25), value: shouldShowControls) // Swipe-up details panel 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 private func startControlsTimer() { @@ -392,10 +404,13 @@ struct TVPlayerView: View { controlsHideTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in Task { @MainActor in - withAnimation(.easeOut(duration: 0.3)) { + withAnimation(.easeOut(duration: 0.25)) { 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() withAnimation(.easeOut(duration: 0.25)) { 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() {