mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-02 08:28:10 +00:00
fix(tv): improve skip/countdown focus and back button handling
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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