mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-20 11:04:19 +01:00
Compare commits
8 Commits
renovate/r
...
fix/auto-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2b9ae7e1 | ||
|
|
56db3591b9 | ||
|
|
5de017612d | ||
|
|
ca9e9f1486 | ||
|
|
980245f19d | ||
|
|
228e847a4e | ||
|
|
15c66bc714 | ||
|
|
c4209cbf8e |
6
bun.lock
6
bun.lock
@@ -81,7 +81,7 @@
|
||||
"react-native-nitro-modules": "0.33.1",
|
||||
"react-native-pager-view": "8.0.1",
|
||||
"react-native-qrcode-svg": "^6.3.21",
|
||||
"react-native-reanimated": "4.5.2",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
@@ -1633,7 +1633,7 @@
|
||||
|
||||
"react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="],
|
||||
|
||||
"react-native-reanimated": ["react-native-reanimated@4.5.2", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.10.x - 0.11.x" } }, "sha512-2XF4a2VyKTy3bxugm9teClxwQ9X4pIkFmoKxIwMQqJdhoFQvjN7In/faKWtjQ8bxTYG+uIJyl6F7oxEwkngjKA=="],
|
||||
"react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="],
|
||||
|
||||
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
|
||||
|
||||
@@ -1647,7 +1647,7 @@
|
||||
|
||||
"react-native-text-ticker": ["react-native-text-ticker@1.16.0", "", {}, "sha512-UHZcIrXzNQ3CnGSA6d5foR9weqSCEk5w+eJGnUf5OQkKbuq/xtt1ZaNVnsgcG5+3f9EnRrNCt52/a46LXVKGGw=="],
|
||||
|
||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
|
||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||
|
||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
@@ -34,11 +33,8 @@ interface BottomControlsProps {
|
||||
showRemoteBubble: boolean;
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showRemoteBubble,
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
nextItem,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
@@ -180,21 +173,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText={t("player.skip_intro")}
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
- Show "Next Episode" if credits extend to video end AND next episode exists */}
|
||||
<SkipButton
|
||||
showButton={
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText={t("player.skip_credits")}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
|
||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -111,6 +112,12 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
// Set when the user manually presses Skip Credits. Once set, the next-episode
|
||||
// countdown is allowed to appear but will NOT auto-fire — the user must tap
|
||||
// it. This prevents a (possibly wrong) credit end timestamp from landing in
|
||||
// the end zone and auto-advancing to the next episode. Resets on unmount
|
||||
// (navigation to the next episode or away).
|
||||
const [suppressAutoNextEpisode, setSuppressAutoNextEpisode] = useState(false);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
@@ -355,6 +362,23 @@ export const Controls: FC<Props> = ({
|
||||
maxMs,
|
||||
);
|
||||
|
||||
// Whether the "Next Episode" countdown will actually be rendered. The Skip
|
||||
// Credits button yields to it only when this is true; if autoplay is
|
||||
// disabled or its episode limit is reached, Skip Credits must stay available
|
||||
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
|
||||
const willShowNextEpisode =
|
||||
!!nextItem &&
|
||||
settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||
|
||||
// Wrap skipCredit so a manual press also arms the auto-advance suppression
|
||||
// (see suppressAutoNextEpisode). The underlying seek is unchanged.
|
||||
const handleSkipCredit = useCallback(() => {
|
||||
setSuppressAutoNextEpisode(true);
|
||||
skipCredit();
|
||||
}, [skipCredit]);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!item || !settings) {
|
||||
@@ -464,8 +488,12 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
// Add a memoized handler for autoplay next episode
|
||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
||||
// If the user manually skipped credits, don't auto-advance — let the
|
||||
// countdown sit so they can tap it. Guards against wrong credit end
|
||||
// timestamps landing in the end zone and skipping the episode's tail.
|
||||
if (suppressAutoNextEpisode) return;
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
}, [goToNextItem, suppressAutoNextEpisode]);
|
||||
|
||||
// Add a memoized handler for manual next episode
|
||||
const handleNextEpisodeManual = useCallback(() => {
|
||||
@@ -587,11 +615,8 @@ export const Controls: FC<Props> = ({
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
@@ -611,6 +636,17 @@ export const Controls: FC<Props> = ({
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
/>
|
||||
</Animated.View>
|
||||
{/* Skip Intro / Skip Credits float independently of the controls so
|
||||
they're visible (and tappable) without summoning the controls. */}
|
||||
<SkipSegmentOverlay
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
willShowNextEpisode={willShowNextEpisode}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={handleSkipCredit}
|
||||
controlsVisible={showControls}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
|
||||
@@ -261,6 +261,12 @@ export const Controls: FC<Props> = ({
|
||||
// Track if play button should have focus (when showing controls via up/down D-pad)
|
||||
const [focusPlayButton, setFocusPlayButton] = useState(false);
|
||||
|
||||
// Set when the user manually presses Skip Credits. Once set, the next-episode
|
||||
// countdown is allowed to appear but will NOT auto-fire — the user must select
|
||||
// it. Prevents a (possibly wrong) credit end timestamp from landing in the end
|
||||
// zone and auto-advancing to the next episode. Resets on unmount.
|
||||
const [suppressAutoNextEpisode, setSuppressAutoNextEpisode] = useState(false);
|
||||
|
||||
// State for progress bar focus and focus guide refs
|
||||
const [isProgressBarFocused, setIsProgressBarFocused] = useState(false);
|
||||
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
||||
@@ -1090,10 +1096,21 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
goToNextItemRef.current = goToNextItem;
|
||||
|
||||
// Wrap skipCredit so a manual press also arms the auto-advance suppression
|
||||
// (see suppressAutoNextEpisode). The underlying seek is unchanged.
|
||||
const handleSkipCredit = useCallback(() => {
|
||||
setSuppressAutoNextEpisode(true);
|
||||
skipCredit();
|
||||
}, [skipCredit]);
|
||||
|
||||
const handleAutoPlayFinish = useCallback(() => {
|
||||
if (exitingRef.current) return;
|
||||
// If the user manually skipped credits, don't auto-advance — let the
|
||||
// countdown sit so they can select it. Guards against wrong credit end
|
||||
// timestamps landing in the end zone and skipping the episode's tail.
|
||||
if (suppressAutoNextEpisode) return;
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
}, [goToNextItem, suppressAutoNextEpisode]);
|
||||
|
||||
const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef;
|
||||
|
||||
@@ -1153,7 +1170,7 @@ export const Controls: FC<Props> = ({
|
||||
(hasContentAfterCredits || !nextItem) &&
|
||||
!isCountdownActive
|
||||
}
|
||||
onPress={skipCredit}
|
||||
onPress={handleSkipCredit}
|
||||
type='credits'
|
||||
controlsVisible={showControls}
|
||||
refSetter={setSkipSegmentRef}
|
||||
|
||||
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal file
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import SkipButton from "./SkipButton";
|
||||
|
||||
interface Props {
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
hasContentAfterCredits: boolean;
|
||||
willShowNextEpisode: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
controlsVisible: boolean;
|
||||
}
|
||||
|
||||
// Offsets are relative to the safe-area insets so they hold in both portrait
|
||||
// and landscape (the insets move with the notch / home indicator).
|
||||
//
|
||||
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
|
||||
// spot and was working fine.
|
||||
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
|
||||
// of the chapters icon), while staying below the vertical volume
|
||||
// slider which sits at the vertical middle of the screen.
|
||||
const HIDDEN_BOTTOM = 24;
|
||||
const HIDDEN_RIGHT = 12;
|
||||
const VISIBLE_BOTTOM = 65;
|
||||
const VISIBLE_RIGHT = 42;
|
||||
const ANIM_DURATION = 250;
|
||||
|
||||
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
|
||||
// itself instantly via a `hidden` (display:none) class, which would preempt
|
||||
// the parent's opacity fade-out — lagging the flag keeps the button rendered
|
||||
// (and visible) while the fade plays out.
|
||||
const useDelayedHide = (value: boolean, duration: number): boolean => {
|
||||
const [display, setDisplay] = useState(value);
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setDisplay(true);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setDisplay(false), duration);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, duration]);
|
||||
return value || display;
|
||||
};
|
||||
|
||||
/**
|
||||
* Floating Skip Intro / Skip Credits buttons shown independently of the
|
||||
* player controls. They appear on their own during an intro or credits segment
|
||||
* without the user having to summon the controls.
|
||||
*/
|
||||
export const SkipSegmentOverlay: FC<Props> = ({
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
hasContentAfterCredits,
|
||||
willShowNextEpisode,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
controlsVisible,
|
||||
}) => {
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
const showCredit =
|
||||
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
|
||||
const visible = showSkipButton || showCredit;
|
||||
|
||||
// Drive each SkipButton with a lagged flag so it stays visible while the
|
||||
// opacity fade-out plays, instead of disappearing the instant its segment
|
||||
// ends. `visible` above still drives opacity/pointerEvents immediately.
|
||||
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
|
||||
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
|
||||
|
||||
const opacity = useSharedValue(visible ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(visible ? 1 : 0, {
|
||||
duration: ANIM_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
}, [visible, opacity]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
// Position is recomputed on render (no slide animation) so the button never
|
||||
// sweeps through an overlap zone while the controls toggle.
|
||||
const bottom =
|
||||
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
|
||||
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { right, bottom }, animatedStyle]}
|
||||
pointerEvents={visible ? "box-none" : "none"}
|
||||
>
|
||||
<SkipButton
|
||||
showButton={renderSkip}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
<SkipButton
|
||||
showButton={renderCredit}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
zIndex: 15,
|
||||
},
|
||||
});
|
||||
@@ -104,7 +104,7 @@
|
||||
"react-native-nitro-modules": "0.33.1",
|
||||
"react-native-pager-view": "8.0.1",
|
||||
"react-native-qrcode-svg": "^6.3.21",
|
||||
"react-native-reanimated": "4.5.2",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
|
||||
Reference in New Issue
Block a user