Compare commits

...

8 Commits

Author SHA1 Message Date
lance chant
aa2b9ae7e1 Merge branch 'develop' into fix/auto-skip-stop 2026-07-19 11:27:27 +02:00
Lance Chant
56db3591b9 fix: attempt at stopping the auto skip/play next episode
This is to attempt to stop it auto playing the next episode when it's
not ready/ the user still watching

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:22:18 +02:00
Lance Chant
5de017612d Merge remote-tracking branch 'origin/develop' into fix/skip-button-rework
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:05:29 +02:00
Lance Chant
ca9e9f1486 removing comment about the skip button location
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:02:56 +02:00
lance chant
980245f19d Merge branch 'develop' into fix/skip-button-rework 2026-07-17 14:47:48 +02:00
lance chant
228e847a4e Update components/video-player/controls/SkipSegmentOverlay.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-17 11:51:37 +02:00
Lance Chant
15c66bc714 Addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:45:42 +02:00
Lance Chant
c4209cbf8e fix: fixing the skip button
Allowing it to show independently of the controls
Fixing the position of the button to fit over things better

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:02:23 +02:00
4 changed files with 184 additions and 28 deletions

View File

@@ -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 <

View File

@@ -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 && (

View File

@@ -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}

View 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,
},
});