mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-18 07:16:24 +00:00
feat(player): add skip intro/credits support for tvOS
This commit is contained in:
@@ -1198,6 +1198,7 @@ export default function page() {
|
||||
getTechnicalInfo={getTechnicalInfo}
|
||||
playMethod={playMethod}
|
||||
transcodeReasons={transcodeReasons}
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
) : (
|
||||
<Controls
|
||||
|
||||
@@ -17,7 +17,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, ScrollView, View } from "react-native";
|
||||
import { Alert, Dimensions, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
@@ -54,7 +54,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
|
||||
@@ -156,21 +156,59 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
const navigateToPlayer = useCallback(
|
||||
(playbackPosition: string) => {
|
||||
if (!item || !selectedOptions) return;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
playbackPosition,
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
},
|
||||
[item, selectedOptions, isOffline, router],
|
||||
);
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!item || !selectedOptions) return;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
const hasPlaybackProgress =
|
||||
(item.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
if (hasPlaybackProgress) {
|
||||
Alert.alert(
|
||||
t("item_card.resume_playback"),
|
||||
t("item_card.resume_playback_description"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("item_card.play_from_start"),
|
||||
onPress: () => navigateToPlayer("0"),
|
||||
},
|
||||
{
|
||||
text: t("item_card.continue_from", {
|
||||
time: formatDuration(item.UserData?.PlaybackPositionTicks),
|
||||
}),
|
||||
onPress: () =>
|
||||
navigateToPlayer(
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
),
|
||||
isPreferred: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
} else {
|
||||
navigateToPlayer("0");
|
||||
}
|
||||
};
|
||||
|
||||
// TV Option Modal hook for quality, audio, media source selectors
|
||||
|
||||
139
components/tv/TVSkipSegmentCard.tsx
Normal file
139
components/tv/TVSkipSegmentCard.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { type FC, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Pressable,
|
||||
Animated as RNAnimated,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
||||
const BOTTOM_WITH_CONTROLS = 300;
|
||||
const BOTTOM_WITHOUT_CONTROLS = 120;
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const target = controlsVisible
|
||||
? BOTTOM_WITH_CONTROLS
|
||||
: BOTTOM_WITHOUT_CONTROLS;
|
||||
bottomPosition.value = withTiming(target, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
}, [controlsVisible, bottomPosition]);
|
||||
|
||||
const containerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
bottom: bottomPosition.value,
|
||||
}));
|
||||
|
||||
const labelText =
|
||||
type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, containerAnimatedStyle]}
|
||||
pointerEvents='box-none'
|
||||
>
|
||||
<Pressable
|
||||
ref={pressableRef}
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasFocus}
|
||||
>
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.button,
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.3)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
borderColor: focused
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "rgba(255,255,255,0.2)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name='play-forward' size={20} color='#fff' />
|
||||
<Text style={styles.label}>{labelText}</Text>
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
right: 80,
|
||||
zIndex: 100,
|
||||
},
|
||||
button: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 20,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -53,6 +53,8 @@ export type { TVSeriesNavigationProps } from "./TVSeriesNavigation";
|
||||
export { TVSeriesNavigation } from "./TVSeriesNavigation";
|
||||
export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard";
|
||||
export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
||||
export type { TVSkipSegmentCardProps } from "./TVSkipSegmentCard";
|
||||
export { TVSkipSegmentCard } from "./TVSkipSegmentCard";
|
||||
export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard";
|
||||
export { TVSubtitleResultCard } from "./TVSubtitleResultCard";
|
||||
export type { TVTabButtonProps } from "./TVTabButton";
|
||||
|
||||
@@ -29,16 +29,24 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
|
||||
import {
|
||||
TVControlButton,
|
||||
TVNextEpisodeCountdown,
|
||||
TVSkipSegmentCard,
|
||||
} from "@/components/tv";
|
||||
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import type { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
@@ -83,6 +91,7 @@ interface Props {
|
||||
getTechnicalInfo?: () => Promise<TechnicalInfo>;
|
||||
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
|
||||
transcodeReasons?: string[];
|
||||
downloadedFiles?: DownloadedItem[];
|
||||
}
|
||||
|
||||
const TV_SEEKBAR_HEIGHT = 14;
|
||||
@@ -206,6 +215,7 @@ export const Controls: FC<Props> = ({
|
||||
getTechnicalInfo,
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
downloadedFiles,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -391,6 +401,31 @@ export const Controls: FC<Props> = ({
|
||||
seek,
|
||||
});
|
||||
|
||||
// Skip intro/credits hooks
|
||||
// Note: hooks expect seek callback that takes ms, and seek prop already expects ms
|
||||
const offline = useOfflineMode();
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
_play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
_play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
max.value,
|
||||
);
|
||||
|
||||
// Countdown logic - needs to be early so toggleControls can reference it
|
||||
const isCountdownActive = useMemo(() => {
|
||||
if (!nextItem) return false;
|
||||
@@ -398,6 +433,13 @@ export const Controls: FC<Props> = ({
|
||||
return remainingTime > 0 && remainingTime <= 10000;
|
||||
}, [nextItem, item, remainingTime]);
|
||||
|
||||
// Whether any skip card is visible - used to prevent focus conflicts
|
||||
const isSkipCardVisible =
|
||||
(showSkipButton && !isCountdownActive) ||
|
||||
(showSkipCreditButton &&
|
||||
(hasContentAfterCredits || !nextItem) &&
|
||||
!isCountdownActive);
|
||||
|
||||
// Brief delay to ignore focus events when countdown first appears
|
||||
const countdownJustActivatedRef = useRef(false);
|
||||
|
||||
@@ -413,6 +455,41 @@ export const Controls: FC<Props> = ({
|
||||
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]);
|
||||
|
||||
// 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";
|
||||
|
||||
@@ -430,8 +507,12 @@ export const Controls: FC<Props> = ({
|
||||
};
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
// Skip if countdown just became active (ignore initial focus event)
|
||||
if (countdownJustActivatedRef.current) return;
|
||||
// Skip if countdown or skip card just became active (ignore initial focus event)
|
||||
const shouldIgnore =
|
||||
countdownJustActivatedRef.current ||
|
||||
skipCardJustActivatedRef.current ||
|
||||
skipJustPressedRef.current;
|
||||
if (shouldIgnore) return;
|
||||
setShowControls(!showControls);
|
||||
}, [showControls, setShowControls]);
|
||||
|
||||
@@ -459,10 +540,6 @@ export const Controls: FC<Props> = ({
|
||||
setSeekBubbleTime({ hours, minutes, seconds });
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
// No longer needed since modals are screen-based
|
||||
}, []);
|
||||
|
||||
// Show minimal seek bar (only progress bar, no buttons)
|
||||
const showMinimalSeek = useCallback(() => {
|
||||
setShowMinimalSeekBar(true);
|
||||
@@ -499,16 +576,6 @@ export const Controls: FC<Props> = ({
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
// Reset minimal seek bar timeout (call on each seek action)
|
||||
const _resetMinimalSeekTimeout = useCallback(() => {
|
||||
if (minimalSeekBarTimeoutRef.current) {
|
||||
clearTimeout(minimalSeekBarTimeoutRef.current);
|
||||
}
|
||||
minimalSeekBarTimeoutRef.current = setTimeout(() => {
|
||||
setShowMinimalSeekBar(false);
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
const handleOpenAudioSheet = useCallback(() => {
|
||||
setLastOpenedModal("audio");
|
||||
showOptions({
|
||||
@@ -875,8 +942,12 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
// Callback for up/down D-pad - show controls with play button focused
|
||||
const handleVerticalDpad = useCallback(() => {
|
||||
// Skip if countdown just became active (ignore initial focus event)
|
||||
if (countdownJustActivatedRef.current) return;
|
||||
// Skip if countdown or skip card just became active (ignore initial focus event)
|
||||
const shouldIgnore =
|
||||
countdownJustActivatedRef.current ||
|
||||
skipCardJustActivatedRef.current ||
|
||||
skipJustPressedRef.current;
|
||||
if (shouldIgnore) return;
|
||||
setFocusPlayButton(true);
|
||||
setShowControls(true);
|
||||
}, [setShowControls]);
|
||||
@@ -885,7 +956,6 @@ export const Controls: FC<Props> = ({
|
||||
showControls,
|
||||
toggleControls,
|
||||
togglePlay,
|
||||
onBack: handleBack,
|
||||
isProgressBarFocused,
|
||||
onSeekLeft: handleProgressSeekLeft,
|
||||
onSeekRight: handleProgressSeekRight,
|
||||
@@ -1008,6 +1078,33 @@ export const Controls: FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Skip intro card */}
|
||||
<TVSkipSegmentCard
|
||||
show={showSkipButton && !isCountdownActive}
|
||||
onPress={handleSkipIntro}
|
||||
type='intro'
|
||||
hasFocus={showSkipButton && !isCountdownActive}
|
||||
controlsVisible={showControls}
|
||||
/>
|
||||
|
||||
{/* Skip credits card - show when there's content after credits, OR no next episode */}
|
||||
<TVSkipSegmentCard
|
||||
show={
|
||||
showSkipCreditButton &&
|
||||
(hasContentAfterCredits || !nextItem) &&
|
||||
!isCountdownActive
|
||||
}
|
||||
onPress={handleSkipCredit}
|
||||
type='credits'
|
||||
hasFocus={
|
||||
showSkipCreditButton &&
|
||||
(hasContentAfterCredits || !nextItem) &&
|
||||
!isCountdownActive &&
|
||||
!showSkipButton
|
||||
}
|
||||
controlsVisible={showControls}
|
||||
/>
|
||||
|
||||
{nextItem && (
|
||||
<TVNextEpisodeCountdown
|
||||
nextItem={nextItem}
|
||||
@@ -1280,6 +1377,7 @@ export const Controls: FC<Props> = ({
|
||||
refSetter={setProgressBarRef}
|
||||
hasTVPreferredFocus={
|
||||
!isCountdownActive &&
|
||||
!isSkipCardVisible &&
|
||||
lastOpenedModal === null &&
|
||||
!focusPlayButton
|
||||
}
|
||||
|
||||
@@ -101,9 +101,7 @@ export function useRemoteControl({
|
||||
|
||||
// Handle play/pause button press on TV remote
|
||||
if (evt.eventType === "playPause") {
|
||||
if (togglePlay) {
|
||||
togglePlay();
|
||||
}
|
||||
togglePlay?.();
|
||||
onInteraction?.();
|
||||
return;
|
||||
}
|
||||
@@ -134,6 +132,11 @@ export function useRemoteControl({
|
||||
|
||||
// Handle D-pad when controls are hidden
|
||||
if (!showControls) {
|
||||
// Ignore select/enter events - let the native Pressable handle them
|
||||
// This prevents controls from showing when pressing buttons like skip intro
|
||||
if (evt.eventType === "select" || evt.eventType === "enter") {
|
||||
return;
|
||||
}
|
||||
// Minimal seek mode for left/right
|
||||
if (evt.eventType === "left" && onMinimalSeekLeft) {
|
||||
onMinimalSeekLeft();
|
||||
|
||||
@@ -671,7 +671,9 @@
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next Up",
|
||||
@@ -722,7 +724,11 @@
|
||||
"download_button": "Download"
|
||||
},
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched"
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Next",
|
||||
|
||||
Reference in New Issue
Block a user