From c0029577e2df72de768c7cf57786d40d292f8dfc Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:04:57 -0700 Subject: [PATCH] update skip button to support other segments --- src/assets/locales/en.json | 5 + .../player/atoms/SkipIntroButton.tsx | 123 ---------- .../player/atoms/SkipSegmentButton.tsx | 211 ++++++++++++++++++ src/components/player/hooks/useSkipTime.ts | 103 +++++++-- src/pages/parts/player/PlayerPart.tsx | 9 +- 5 files changed, 304 insertions(+), 147 deletions(-) delete mode 100644 src/components/player/atoms/SkipIntroButton.tsx create mode 100644 src/components/player/atoms/SkipSegmentButton.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 7e4d0df3..f782cc8e 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -968,6 +968,11 @@ "remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}", "shortRegular": "{{timeWatched}}", "shortRemaining": "-{{timeLeft}}" + }, + "skipTime": { + "intro": "Skip Intro", + "recap": "Skip Recap", + "credits": "Skip Credits" } }, "support": { diff --git a/src/components/player/atoms/SkipIntroButton.tsx b/src/components/player/atoms/SkipIntroButton.tsx deleted file mode 100644 index 2f95158e..00000000 --- a/src/components/player/atoms/SkipIntroButton.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import classNames from "classnames"; -import { useCallback } from "react"; - -import { Icon, Icons } from "@/components/Icon"; -import { useSkipTracking } from "@/components/player/hooks/useSkipTracking"; -import { Transition } from "@/components/utils/Transition"; -import { usePlayerStore } from "@/stores/player/store"; - -function shouldShowSkipButton( - currentTime: number, - skipTime?: number | null, -): "always" | "hover" | "none" { - if (typeof skipTime !== "number") return "none"; - - // Only show during the first 10 seconds of the intro section - if (currentTime >= 0 && currentTime < skipTime) { - if (currentTime <= 10) return "always"; - return "hover"; - } - - return "none"; -} - -function Button(props: { - className: string; - onClick?: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} - -export function SkipIntroButton(props: { - controlsShowing: boolean; - skipTime?: number | null; - inControl: boolean; -}) { - const time = usePlayerStore((s) => s.progress.time); - const status = usePlayerStore((s) => s.status); - const display = usePlayerStore((s) => s.display); - const meta = usePlayerStore((s) => s.meta); - const { addSkipEvent } = useSkipTracking(20); - const showingState = shouldShowSkipButton(time, props.skipTime); - const animation = showingState === "hover" ? "slide-up" : "fade"; - let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; - if (showingState === "always") { - bottom = props.controlsShowing - ? bottom - : "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; - } - - const handleSkip = useCallback(() => { - if (typeof props.skipTime === "number" && display) { - const startTime = time; - const endTime = props.skipTime; - const skipDuration = endTime - startTime; - - display.setTime(props.skipTime); - - // Add manual skip event with high confidence (user explicitly clicked skip intro) - addSkipEvent({ - startTime, - endTime, - skipDuration, - confidence: 0.95, // High confidence for explicit user action - meta: meta - ? { - title: - meta.type === "show" && meta.episode - ? `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}` - : meta.title, - type: meta.type === "movie" ? "Movie" : "TV Show", - tmdbId: meta.tmdbId, - seasonNumber: meta.season?.number, - episodeNumber: meta.episode?.number, - } - : undefined, - }); - - // eslint-disable-next-line no-console - console.log(`Skip intro button used: ${skipDuration}s total`); - } - }, [props.skipTime, display, time, addSkipEvent, meta]); - if (!props.inControl) return null; - - let show = false; - if (showingState === "always") show = true; - else if (showingState === "hover" && props.controlsShowing) show = true; - if (status !== "playing") show = false; - - return ( - -
- -
-
- ); -} diff --git a/src/components/player/atoms/SkipSegmentButton.tsx b/src/components/player/atoms/SkipSegmentButton.tsx new file mode 100644 index 00000000..4f919e61 --- /dev/null +++ b/src/components/player/atoms/SkipSegmentButton.tsx @@ -0,0 +1,211 @@ +import classNames from "classnames"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { Icon, Icons } from "@/components/Icon"; +import { NextEpisodeButton } from "@/components/player/atoms/NextEpisodeButton"; +import { SegmentData } from "@/components/player/hooks/useSkipTime"; +import { useSkipTracking } from "@/components/player/hooks/useSkipTracking"; +import { Transition } from "@/components/utils/Transition"; +import { PlayerMeta } from "@/stores/player/slices/source"; +import { usePlayerStore } from "@/stores/player/store"; + +function getSegmentText( + type: "intro" | "recap" | "credits", + t: (key: string) => string, +): string { + switch (type) { + case "intro": + return t("player.skipTime.intro"); + case "recap": + return t("player.skipTime.recap"); + case "credits": + return t("player.skipTime.credits"); + default: + return t("player.skipTime.intro"); + } +} + +function shouldShowSkipButton( + currentTime: number, + segment: SegmentData | null, +): "always" | "hover" | "none" { + if (!segment) return "none"; + + // Convert current time to milliseconds for comparison + const currentTimeMs = currentTime * 1000; + + // Handle start time (null means 0/start of video) + const startMs = segment.start_ms ?? 0; + + // Handle end time (null means end of video, so we show until the end) + const endMs = segment.end_ms ?? Infinity; + + // Check if current time is within the segment + if (currentTimeMs >= startMs && currentTimeMs <= endMs) { + // Show "always" for the first 10 seconds of the segment, then "hover" + const timeInSegment = currentTimeMs - startMs; + if (timeInSegment <= 10000) return "always"; // First 10 seconds + return "hover"; + } + + return "none"; +} + +function Button(props: { + className: string; + onClick?: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function SkipSegmentButton(props: { + controlsShowing: boolean; + segments: SegmentData[]; + inControl: boolean; + onChangeMeta?: (meta: PlayerMeta) => void; +}) { + const { t } = useTranslation(); + const time = usePlayerStore((s) => s.progress.time); + const _duration = usePlayerStore((s) => s.progress.duration); + const status = usePlayerStore((s) => s.status); + const display = usePlayerStore((s) => s.display); + const meta = usePlayerStore((s) => s.meta); + const { addSkipEvent } = useSkipTracking(20); + + // Check if we should show NextEpisodeButton instead of credits skip button + const shouldShowNextEpisodeInsteadOfCredits = + meta?.type === "show" && + props.segments.some((segment) => { + if (segment.type !== "credits") return false; + // Show NextEpisodeButton if credits end at video end (null means end of video) + return segment.end_ms === null; + }); + + // Find segments that should be shown at the current time + const activeSegments = props.segments.filter((segment) => { + // Skip credits segments if we're showing NextEpisodeButton instead + if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) { + return false; + } + const showingState = shouldShowSkipButton(time, segment); + return showingState !== "none"; + }); + + const handleSkip = useCallback( + (segment: SegmentData) => { + if (!display) return; + + const startTime = time; + // Skip to the end of the segment (or end of video if end_ms is null) + const targetTime = segment.end_ms ? segment.end_ms / 1000 : _duration; + const skipDuration = targetTime - startTime; + display.setTime(targetTime); + + // Add manual skip event with high confidence (user explicitly clicked skip) + addSkipEvent({ + startTime, + endTime: targetTime, + skipDuration, + confidence: 0.95, // High confidence for explicit user action + meta: meta + ? { + title: + meta.type === "show" && meta.episode + ? `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}` + : meta.title, + type: meta.type === "movie" ? "Movie" : "TV Show", + tmdbId: meta.tmdbId, + seasonNumber: meta.season?.number, + episodeNumber: meta.episode?.number, + } + : undefined, + }); + + // eslint-disable-next-line no-console + console.log(`Skip ${segment.type} button used: ${skipDuration}s total`); + }, + [display, time, _duration, addSkipEvent, meta], + ); + + // Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end + if (shouldShowNextEpisodeInsteadOfCredits && props.inControl) { + return ( + + ); + } + + if (!props.inControl || activeSegments.length === 0) return null; + + // If status is not playing, don't show buttons + if (status !== "playing") return null; + + return ( +
+ {activeSegments.map((segment, index) => { + const showingState = shouldShowSkipButton(time, segment); + const animation = showingState === "hover" ? "slide-up" : "fade"; + + let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; + if (showingState === "always") { + bottom = props.controlsShowing + ? bottom + : "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; + } + + // Offset multiple buttons vertically + const verticalOffset = index * 60; // 60px spacing between buttons + const adjustedBottom = bottom.replace( + /bottom-\[calc\(([^)]+)\)\]/, + `bottom-[calc($1 + ${verticalOffset}px)]`, + ); + + let show = false; + if (showingState === "always") show = true; + else if (showingState === "hover" && props.controlsShowing) show = true; + + return ( + +
+ +
+
+ ); + })} +
+ ); +} + +export { SkipSegmentButton }; diff --git a/src/components/player/hooks/useSkipTime.ts b/src/components/player/hooks/useSkipTime.ts index 334526f6..6dabb322 100644 --- a/src/components/player/hooks/useSkipTime.ts +++ b/src/components/player/hooks/useSkipTime.ts @@ -27,17 +27,25 @@ export function useSkipTimeSource(): typeof currentSkipTimeSource { return currentSkipTimeSource; } +export interface SegmentData { + type: "intro" | "recap" | "credits"; + start_ms: number | null; + end_ms: number | null; + confidence: number | null; + submission_count: number; +} + export function useSkipTime() { const { playerMeta: meta } = usePlayerMeta(); - const [skiptime, setSkiptime] = useState(null); + const [segments, setSegments] = useState([]); const febboxKey = usePreferencesStore((s) => s.febboxKey); useEffect(() => { - const fetchTheIntroDBTime = async (): Promise => { - if (!meta?.tmdbId) return null; + const fetchTheIntroDBSegments = async (): Promise => { + if (!meta?.tmdbId) return []; try { - let apiUrl = `${THE_INTRO_DB_BASE_URL}/intro?tmdb_id=${meta.tmdbId}`; + let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`; if ( meta.type !== "movie" && meta.season?.number && @@ -48,15 +56,45 @@ export function useSkipTime() { const data = await mwFetch(apiUrl); - if (data && typeof data.end_ms === "number") { - // Convert milliseconds to seconds - return Math.floor(data.end_ms / 1000); + const fetchedSegments: SegmentData[] = []; + + // Add intro segment if it has data + if (data?.intro && data.intro.submission_count > 0) { + fetchedSegments.push({ + type: "intro", + start_ms: data.intro.start_ms, + end_ms: data.intro.end_ms, + confidence: data.intro.confidence, + submission_count: data.intro.submission_count, + }); } - return null; + // Add recap segment if it has data + if (data?.recap && data.recap.submission_count > 0) { + fetchedSegments.push({ + type: "recap", + start_ms: data.recap.start_ms, + end_ms: data.recap.end_ms, + confidence: data.recap.confidence, + submission_count: data.recap.submission_count, + }); + } + + // Add credits segment if it has data + if (data?.credits && data.credits.submission_count > 0) { + fetchedSegments.push({ + type: "credits", + start_ms: data.credits.start_ms, + end_ms: data.credits.end_ms, + confidence: data.credits.confidence, + submission_count: data.credits.submission_count, + }); + } + + return fetchedSegments; } catch (error) { - console.error("Error fetching TIDB time:", error); - return null; + console.error("Error fetching TIDB segments:", error); + return []; } }; @@ -187,22 +225,31 @@ export function useSkipTime() { }; const fetchSkipTime = async (): Promise => { - // Reset source + // Reset source and segments currentSkipTimeSource = null; + setSegments([]); - // Try TheIntroDB API first (supports both movies and TV shows) - const theIntroDBTime = await fetchTheIntroDBTime(); - if (theIntroDBTime !== null) { + // Try TheIntroDB API first (supports both movies and TV shows with full segment data) + const theIntroDBSegments = await fetchTheIntroDBSegments(); + if (theIntroDBSegments.length > 0) { currentSkipTimeSource = "theintrodb"; - setSkiptime(theIntroDBTime); + setSegments(theIntroDBSegments); return; } - // Try QuickWatch API (TV shows only) + // Try QuickWatch API (TV shows only) - convert to intro segment const quickWatchTime = await fetchQuickWatchTime(); if (quickWatchTime !== null) { currentSkipTimeSource = "quickwatch"; - setSkiptime(quickWatchTime); + setSegments([ + { + type: "intro", + start_ms: 0, // Assume starts at beginning + end_ms: quickWatchTime * 1000, // Convert seconds to milliseconds + confidence: null, + submission_count: 1, + }, + ]); return; } @@ -212,7 +259,15 @@ export function useSkipTime() { const fedSkipsTime = await fetchFedSkipsTime(); if (fedSkipsTime !== null) { currentSkipTimeSource = "fed-skips"; - setSkiptime(fedSkipsTime); + setSegments([ + { + type: "intro", + start_ms: 0, // Assume starts at beginning + end_ms: fedSkipsTime * 1000, // Convert seconds to milliseconds + confidence: null, + submission_count: 1, + }, + ]); return; } } @@ -221,8 +276,16 @@ export function useSkipTime() { const introDBTime = await fetchIntroDBTime(); if (introDBTime !== null) { currentSkipTimeSource = "introdb"; + setSegments([ + { + type: "intro", + start_ms: 0, // Assume starts at beginning + end_ms: introDBTime * 1000, // Convert seconds to milliseconds + confidence: null, + submission_count: 1, + }, + ]); } - setSkiptime(introDBTime); }; fetchSkipTime(); @@ -236,5 +299,5 @@ export function useSkipTime() { febboxKey, ]); - return skiptime; + return segments; } diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index ed54b4e3..2c1ec852 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { BrandPill } from "@/components/layout/BrandPill"; import { Player } from "@/components/player"; -import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton"; +import { SkipSegmentButton } from "@/components/player/atoms/SkipSegmentButton"; import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay"; import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; @@ -74,7 +74,7 @@ export function PlayerPart(props: PlayerPartProps) { }, 1000); }; - const skiptime = useSkipTime(); + const segments = useSkipTime(); return ( @@ -246,10 +246,11 @@ export function PlayerPart(props: PlayerPartProps) { inControl={inControl} /> - );