update skip button to support other segments

This commit is contained in:
Pas 2026-01-14 14:04:57 -07:00
parent 0b2536486f
commit c0029577e2
5 changed files with 304 additions and 147 deletions

View file

@ -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": {

View file

@ -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 (
<button
className={classNames(
"font-bold rounded h-10 w-40 scale-95 hover:scale-100 transition-all duration-200",
props.className,
)}
type="button"
onClick={props.onClick}
>
{props.children}
</button>
);
}
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 (
<Transition
animation={animation}
show={show}
className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0"
>
<div
className={classNames([
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
bottom,
])}
>
<Button
onClick={handleSkip}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
>
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
Skip Intro
</Button>
</div>
</Transition>
);
}

View file

@ -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 (
<button
className={classNames(
"font-bold rounded h-10 w-40 scale-95 hover:scale-100 transition-all duration-200",
props.className,
)}
type="button"
onClick={props.onClick}
>
{props.children}
</button>
);
}
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 (
<NextEpisodeButton
controlsShowing={props.controlsShowing}
onChange={props.onChangeMeta}
inControl={props.inControl}
/>
);
}
if (!props.inControl || activeSegments.length === 0) return null;
// If status is not playing, don't show buttons
if (status !== "playing") return null;
return (
<div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0">
{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 (
<Transition
key={segment.type}
animation={animation}
show={show}
className="absolute right-0"
>
<div
className={classNames([
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
adjustedBottom,
])}
>
<Button
onClick={() => handleSkip(segment)}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
>
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
{getSegmentText(segment.type, t)}
</Button>
</div>
</Transition>
);
})}
</div>
);
}
export { SkipSegmentButton };

View file

@ -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<number | null>(null);
const [segments, setSegments] = useState<SegmentData[]>([]);
const febboxKey = usePreferencesStore((s) => s.febboxKey);
useEffect(() => {
const fetchTheIntroDBTime = async (): Promise<number | null> => {
if (!meta?.tmdbId) return null;
const fetchTheIntroDBSegments = async (): Promise<SegmentData[]> => {
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<void> => {
// 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;
}

View file

@ -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 (
<Player.Container onLoad={props.onLoad} showingControls={showTargets}>
@ -246,10 +246,11 @@ export function PlayerPart(props: PlayerPartProps) {
inControl={inControl}
/>
<SkipIntroButton
<SkipSegmentButton
controlsShowing={showTargets}
skipTime={skiptime}
segments={segments}
inControl={inControl}
onChangeMeta={props.onMetaChange}
/>
</Player.Container>
);