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}
/>
-
);