diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 81c77ab8c..c3937aa48 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -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 = ({ showRemoteBubble, currentTime, remainingTime, - showSkipButton, showSkipCreditButton, hasContentAfterCredits, - skipIntro, - skipCredit, nextItem, handleNextEpisodeAutoPlay, handleNextEpisodeManual, @@ -180,21 +173,11 @@ export const BottomControls: FC = ({ ) : null} - - {/* 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 */} - + {/* Smart Skip Credits behavior (drives Next Episode timing): + - "Skip Credits" button itself is rendered in SkipSegmentOverlay + (independent of controls visibility). + - "Next Episode" shows when credits extend to video end AND a next + episode exists. */} {settings.autoPlayNextEpisode !== false && (settings.maxAutoPlayEpisodeCount.value === -1 || settings.autoPlayEpisodeCount < diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 3651876a4..a80e8416b 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -38,6 +38,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"; @@ -570,11 +571,8 @@ export const Controls: FC = ({ showRemoteBubble={showRemoteBubble} currentTime={currentTime} remainingTime={remainingTime} - showSkipButton={showSkipButton} showSkipCreditButton={showSkipCreditButton} hasContentAfterCredits={hasContentAfterCredits} - skipIntro={skipIntro} - skipCredit={skipCredit} nextItem={nextItem} handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay} handleNextEpisodeManual={handleNextEpisodeManual} @@ -594,6 +592,17 @@ export const Controls: FC = ({ time={isSliding || showRemoteBubble ? time : remoteTime} /> + {/* Skip Intro / Skip Credits float independently of the controls so + they're visible (and tappable) without summoning the controls. */} + )} {settings.maxAutoPlayEpisodeCount.value !== -1 && ( diff --git a/components/video-player/controls/SkipSegmentOverlay.tsx b/components/video-player/controls/SkipSegmentOverlay.tsx new file mode 100644 index 000000000..1adba22d8 --- /dev/null +++ b/components/video-player/controls/SkipSegmentOverlay.tsx @@ -0,0 +1,102 @@ +import type { FC } from "react"; +import { useEffect } 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; + hasNextItem: 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; + +/** + * 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 = ({ + showSkipButton, + showSkipCreditButton, + hasContentAfterCredits, + hasNextItem, + skipIntro, + skipCredit, + controlsVisible, +}) => { + const insets = useControlsSafeAreaInsets(); + + const showCredit = + showSkipCreditButton && (hasContentAfterCredits || !hasNextItem); + const visible = showSkipButton || showCredit; + + 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 ( + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + flexDirection: "row", + gap: 8, + zIndex: 15, + }, +});