fix(tv): improve skip/countdown focus and back button handling

This commit is contained in:
Fredrik Burmester
2026-02-01 14:03:20 +01:00
parent 2775075187
commit fb7cee7718
5 changed files with 199 additions and 162 deletions

View File

@@ -43,6 +43,10 @@ import {
type MpvPlayerViewRef,
type MpvVideoSource,
} from "@/modules";
import {
isNativeTVControlsAvailable,
TVPlayerControlsView,
} from "@/modules/tv-player-controls";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider";
@@ -1189,37 +1193,87 @@ export default function page() {
item &&
!isPipMode &&
(Platform.isTV ? (
<TVControls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
play={play}
pause={pause}
seek={seek}
audioIndex={currentAudioIndex}
subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange}
previousItem={previousItem}
nextItem={nextItem}
goToPreviousItem={goToPreviousItem}
goToNextItem={goToNextItem}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
downloadedFiles={downloadedFiles}
/>
// TV Controls: Use native SwiftUI controls if enabled and available, otherwise JS controls
settings.useNativeTVControls &&
isNativeTVControlsAvailable() ? (
<TVPlayerControlsView
isPlaying={isPlaying}
progress={progress.value}
duration={item.RunTimeTicks ? item.RunTimeTicks / 10000 : 0}
cacheProgress={cacheProgress.value}
isBuffering={isBuffering}
title={item.Name ?? ""}
subtitle={
item.Type === "Episode"
? `${item.SeriesName} - S${item.ParentIndexNumber} E${item.IndexNumber}`
: undefined
}
isLiveTV={
item.Type === "Program" || item.Type === "TvChannel"
}
visible={showControls}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPlayPause={togglePlay}
onSeek={(e) => seek(e.nativeEvent.positionMs)}
onSkipForward={() => {
const newPos = Math.min(
(item.RunTimeTicks ?? 0) / 10000,
progress.value + 30000,
);
progress.value = newPos;
seek(newPos);
}}
onSkipBackward={() => {
const newPos = Math.max(0, progress.value - 10000);
progress.value = newPos;
seek(newPos);
}}
// Audio/subtitle settings will be handled in future iteration
// These would need the same modal hooks as the JS controls
onBack={() => router.back()}
onVisibilityChange={(e) =>
setShowControls(e.nativeEvent.visible)
}
/>
) : (
<TVControls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
play={play}
pause={pause}
seek={seek}
audioIndex={currentAudioIndex}
subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange}
previousItem={previousItem}
nextItem={nextItem}
goToPreviousItem={goToPreviousItem}
goToNextItem={goToNextItem}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
downloadedFiles={downloadedFiles}
/>
)
) : (
<Controls
mediaSource={stream?.mediaSource}

View File

@@ -31,8 +31,6 @@ export interface TVNextEpisodeCountdownProps {
onFinish: () => void;
/** Called when user presses the card to skip to next episode */
onPlayNext?: () => void;
/** Whether this card should capture focus when visible */
hasFocus?: boolean;
/** Whether controls are visible - affects card position */
controlsVisible?: boolean;
}
@@ -48,7 +46,6 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
isPlaying,
onFinish,
onPlayNext,
hasFocus = false,
controlsVisible = false,
}) => {
const typography = useScaledTVTypography();
@@ -141,7 +138,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
onPress={onPlayNext}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasFocus}
hasTVPreferredFocus={true}
focusable={true}
>
<RNAnimated.View style={[animatedStyle, focused && styles.focusedCard]}>

View File

@@ -1,12 +1,8 @@
import { Ionicons } from "@expo/vector-icons";
import { type FC, useEffect, useRef } from "react";
import type { FC } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Animated as RNAnimated,
StyleSheet,
View,
} from "react-native";
import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
@@ -20,8 +16,6 @@ export interface TVSkipSegmentCardProps {
show: boolean;
onPress: () => void;
type: "intro" | "credits";
/** Whether this card should capture focus when visible */
hasFocus?: boolean;
/** Whether controls are visible - affects card position */
controlsVisible?: boolean;
}
@@ -34,30 +28,15 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
show,
onPress,
type,
hasFocus = false,
controlsVisible = false,
}) => {
const { t } = useTranslation();
const pressableRef = useRef<View>(null);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.1,
duration: 120,
});
// Programmatically request focus when card appears with hasFocus=true
useEffect(() => {
if (!show || !hasFocus || !pressableRef.current) return;
const timer = setTimeout(() => {
// Use setNativeProps to trigger focus update on tvOS
(pressableRef.current as any)?.setNativeProps?.({
hasTVPreferredFocus: true,
});
}, 50);
return () => clearTimeout(timer);
}, [show, hasFocus]);
// Animated position based on controls visibility
const bottomPosition = useSharedValue(
controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS,
@@ -88,11 +67,10 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
pointerEvents='box-none'
>
<Pressable
ref={pressableRef}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasFocus}
hasTVPreferredFocus={true}
>
<RNAnimated.View
style={[

View File

@@ -426,69 +426,28 @@ export const Controls: FC<Props> = ({
max.value,
);
// Countdown logic - needs to be early so toggleControls can reference it
// Countdown logic
const isCountdownActive = useMemo(() => {
if (!nextItem) return false;
if (item?.Type !== "Episode") return false;
return remainingTime > 0 && remainingTime <= 10000;
}, [nextItem, item, remainingTime]);
// Whether any skip card is visible - used to prevent focus conflicts
const isSkipCardVisible =
(showSkipButton && !isCountdownActive) ||
(showSkipCreditButton &&
// Simple boolean - when skip cards or countdown are visible, they have focus
const isSkipOrCountdownVisible = useMemo(() => {
const skipIntroVisible = showSkipButton && !isCountdownActive;
const skipCreditsVisible =
showSkipCreditButton &&
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive);
// Brief delay to ignore focus events when countdown first appears
const countdownJustActivatedRef = useRef(false);
useEffect(() => {
if (!isCountdownActive) {
countdownJustActivatedRef.current = false;
return;
}
countdownJustActivatedRef.current = true;
const timeout = setTimeout(() => {
countdownJustActivatedRef.current = false;
}, 200);
return () => clearTimeout(timeout);
}, [isCountdownActive]);
// Brief delay to ignore focus events when skip card first appears
const skipCardJustActivatedRef = useRef(false);
useEffect(() => {
if (!isSkipCardVisible) {
skipCardJustActivatedRef.current = false;
return;
}
skipCardJustActivatedRef.current = true;
const timeout = setTimeout(() => {
skipCardJustActivatedRef.current = false;
}, 200);
return () => clearTimeout(timeout);
}, [isSkipCardVisible]);
// Brief delay to ignore focus events after pressing skip button
const skipJustPressedRef = useRef(false);
// Wrapper to prevent focus events after skip actions
const handleSkipWithDelay = useCallback((skipFn: () => void) => {
skipJustPressedRef.current = true;
skipFn();
setTimeout(() => {
skipJustPressedRef.current = false;
}, 500);
}, []);
const handleSkipIntro = useCallback(() => {
handleSkipWithDelay(skipIntro);
}, [handleSkipWithDelay, skipIntro]);
const handleSkipCredit = useCallback(() => {
handleSkipWithDelay(skipCredit);
}, [handleSkipWithDelay, skipCredit]);
!isCountdownActive;
return skipIntroVisible || skipCreditsVisible || isCountdownActive;
}, [
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
nextItem,
isCountdownActive,
]);
// Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels)
const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel";
@@ -507,14 +466,9 @@ export const Controls: FC<Props> = ({
};
const toggleControls = useCallback(() => {
// Skip if countdown or skip card just became active (ignore initial focus event)
const shouldIgnore =
countdownJustActivatedRef.current ||
skipCardJustActivatedRef.current ||
skipJustPressedRef.current;
if (shouldIgnore) return;
if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't toggle
setShowControls(!showControls);
}, [showControls, setShowControls]);
}, [showControls, setShowControls, isSkipOrCountdownVisible]);
const [showSeekBubble, setShowSeekBubble] = useState(false);
const [seekBubbleTime, setSeekBubbleTime] = useState({
@@ -942,18 +896,22 @@ export const Controls: FC<Props> = ({
// Callback for up/down D-pad - show controls with play button focused
const handleVerticalDpad = useCallback(() => {
// Skip if countdown or skip card just became active (ignore initial focus event)
const shouldIgnore =
countdownJustActivatedRef.current ||
skipCardJustActivatedRef.current ||
skipJustPressedRef.current;
if (shouldIgnore) return;
if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't show controls
setFocusPlayButton(true);
setShowControls(true);
}, [setShowControls, isSkipOrCountdownVisible]);
const hideControls = useCallback(() => {
setShowControls(false);
setFocusPlayButton(false);
}, [setShowControls]);
const handleBack = useCallback(() => {
router.back();
}, [router]);
const { isSliding: isRemoteSliding } = useRemoteControl({
showControls,
showControls: showControls,
toggleControls,
togglePlay,
isProgressBarFocused,
@@ -966,15 +924,13 @@ export const Controls: FC<Props> = ({
onLongSeekRightStart: handleDpadLongSeekForward,
onLongSeekStop: stopContinuousSeeking,
onVerticalDpad: handleVerticalDpad,
onHideControls: hideControls,
onBack: handleBack,
videoTitle: item?.Name ?? undefined,
});
const hideControls = useCallback(() => {
setShowControls(false);
setFocusPlayButton(false);
}, [setShowControls]);
const { handleControlsInteraction } = useControlsTimeout({
showControls,
showControls: showControls,
isSliding: isRemoteSliding,
episodeView: false,
onHideControls: hideControls,
@@ -1081,9 +1037,8 @@ export const Controls: FC<Props> = ({
{/* Skip intro card */}
<TVSkipSegmentCard
show={showSkipButton && !isCountdownActive}
onPress={handleSkipIntro}
onPress={skipIntro}
type='intro'
hasFocus={showSkipButton && !isCountdownActive}
controlsVisible={showControls}
/>
@@ -1094,14 +1049,8 @@ export const Controls: FC<Props> = ({
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive
}
onPress={handleSkipCredit}
onPress={skipCredit}
type='credits'
hasFocus={
showSkipCreditButton &&
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive &&
!showSkipButton
}
controlsVisible={showControls}
/>
@@ -1113,7 +1062,6 @@ export const Controls: FC<Props> = ({
isPlaying={isPlaying}
onFinish={handleAutoPlayFinish}
onPlayNext={handleNextItemButton}
hasFocus={isCountdownActive}
controlsVisible={showControls}
/>
)}
@@ -1215,7 +1163,7 @@ export const Controls: FC<Props> = ({
<Animated.View
style={[styles.bottomContainer, bottomAnimatedStyle]}
pointerEvents={showControls && !false ? "auto" : "none"}
pointerEvents={showControls ? "auto" : "none"}
>
<View
style={[
@@ -1375,12 +1323,7 @@ export const Controls: FC<Props> = ({
onFocus={() => setIsProgressBarFocused(true)}
onBlur={() => setIsProgressBarFocused(false)}
refSetter={setProgressBarRef}
hasTVPreferredFocus={
!isCountdownActive &&
!isSkipCardVisible &&
lastOpenedModal === null &&
!focusPlayButton
}
hasTVPreferredFocus={false}
/>
</TVFocusGuideView>

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Platform } from "react-native";
import { useEffect, useRef, useState } from "react";
import { Alert, BackHandler, Platform } from "react-native";
import { type SharedValue, useSharedValue } from "react-native-reanimated";
// TV event handler with fallback for non-TV platforms
@@ -23,6 +23,10 @@ interface UseRemoteControlProps {
disableSeeking?: boolean;
/** Callback for back/menu button press (tvOS: menu, Android TV: back) */
onBack?: () => void;
/** Callback to hide controls (called on back press when controls are visible) */
onHideControls?: () => void;
/** Title of the video being played (shown in exit confirmation) */
videoTitle?: string;
/** Whether the progress bar currently has focus */
isProgressBarFocused?: boolean;
/** Callback for seeking left when progress bar is focused */
@@ -69,6 +73,8 @@ export function useRemoteControl({
toggleControls,
togglePlay,
onBack,
onHideControls,
videoTitle,
isProgressBarFocused,
onSeekLeft,
onSeekRight,
@@ -87,14 +93,73 @@ export function useRemoteControl({
const [isSliding] = useState(false);
const [time] = useState({ hours: 0, minutes: 0, seconds: 0 });
// Use refs to avoid stale closures in BackHandler
const showControlsRef = useRef(showControls);
const onHideControlsRef = useRef(onHideControls);
const onBackRef = useRef(onBack);
const videoTitleRef = useRef(videoTitle);
useEffect(() => {
showControlsRef.current = showControls;
onHideControlsRef.current = onHideControls;
onBackRef.current = onBack;
videoTitleRef.current = videoTitle;
}, [showControls, onHideControls, onBack, videoTitle]);
// Handle hardware back button (works on both Android TV and tvOS)
useEffect(() => {
if (!Platform.isTV) return;
const handleBackPress = () => {
if (showControlsRef.current && onHideControlsRef.current) {
// Controls are visible - just hide them
onHideControlsRef.current();
return true; // Prevent default back navigation
}
if (onBackRef.current) {
// Controls are hidden - show confirmation before exiting
Alert.alert(
"Stop Playback",
videoTitleRef.current
? `Stop playing "${videoTitleRef.current}"?`
: "Are you sure you want to stop playback?",
[
{ text: "Cancel", style: "cancel" },
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
],
);
return true; // Prevent default back navigation
}
return false; // Let default back navigation happen
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, []);
// TV remote control handling (no-op on non-TV platforms)
useTVEventHandler((evt) => {
if (!evt) return;
// Handle back/menu button press (tvOS: menu, Android TV: back)
if (evt.eventType === "menu" || evt.eventType === "back") {
if (onBack) {
onBack();
// Back/menu is handled by BackHandler above, but keep this for tvOS menu button
if (evt.eventType === "menu") {
if (showControls && onHideControls) {
onHideControls();
} else if (onBack) {
Alert.alert(
"Stop Playback",
videoTitle
? `Stop playing "${videoTitle}"?`
: "Are you sure you want to stop playback?",
[
{ text: "Cancel", style: "cancel" },
{ text: "Stop", style: "destructive", onPress: onBack },
],
);
}
return;
}
@@ -154,8 +219,8 @@ export function useRemoteControl({
onVerticalDpad();
return;
}
// For other D-pad presses, show full controls
toggleControls();
// Ignore all other events (focus/blur, swipes, etc.)
// User can press up/down to show controls
return;
}