mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-20 00:06:32 +00:00
fix(tv): improve skip/countdown focus and back button handling
This commit is contained in:
@@ -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]}>
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user