fix skip section buttons not showing

This commit is contained in:
Pas 2026-02-01 13:44:59 -07:00
parent 64a241e2ed
commit a9e2ff2dc6
3 changed files with 174 additions and 126 deletions

View file

@ -96,6 +96,8 @@ export function NextEpisodeButton(props: {
onChange?: (meta: PlayerMeta) => void; onChange?: (meta: PlayerMeta) => void;
inControl: boolean; inControl: boolean;
showAsButton?: boolean; showAsButton?: boolean;
/** When true (e.g. in credits-to-end segment), show regardless of time/duration. */
forceShow?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const duration = usePlayerStore((s) => s.progress.duration); const duration = usePlayerStore((s) => s.progress.duration);
@ -109,7 +111,8 @@ export function NextEpisodeButton(props: {
const setLastSuccessfulSource = usePreferencesStore( const setLastSuccessfulSource = usePreferencesStore(
(s) => s.setLastSuccessfulSource, (s) => s.setLastSuccessfulSource,
); );
const showingState = shouldShowNextEpisodeButton(time, duration); const timeBasedState = shouldShowNextEpisodeButton(time, duration);
const showingState = props.forceShow ? "always" : timeBasedState;
const status = usePlayerStore((s) => s.status); const status = usePlayerStore((s) => s.status);
const setShouldStartFromBeginning = usePlayerStore( const setShouldStartFromBeginning = usePlayerStore(
(s) => s.setShouldStartFromBeginning, (s) => s.setShouldStartFromBeginning,

View file

@ -86,18 +86,18 @@ function SkipSegmentButton(props: {
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
const { addSkipEvent } = useSkipTracking(20); const { addSkipEvent } = useSkipTracking(20);
// Check if we should show NextEpisodeButton instead of credits skip button // Only replace with NextEpisodeButton when credits have no end (end_ms === null) i.e. credits
// run to the end of the video. When end_ms is a number, there may be content after (e.g. post-
// credits scene), so we show the normal "Skip credits" button that seeks to end_ms.
const shouldShowNextEpisodeInsteadOfCredits = const shouldShowNextEpisodeInsteadOfCredits =
meta?.type === "show" && meta?.type === "show" &&
props.segments.some((segment) => { props.segments.some((segment) => {
if (segment.type !== "credits") return false; if (segment.type !== "credits") return false;
// Show NextEpisodeButton if credits end at video end (null means end of video)
return segment.end_ms === null; return segment.end_ms === null;
}); });
// Find segments that should be shown at the current time // Find segments that should be shown at the current time (intro, recap; credits excluded when we show NextEpisodeButton)
const activeSegments = props.segments.filter((segment) => { const activeSegments = props.segments.filter((segment) => {
// Skip credits segments if we're showing NextEpisodeButton instead
if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) { if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) {
return false; return false;
} }
@ -105,6 +105,17 @@ function SkipSegmentButton(props: {
return showingState !== "none"; return showingState !== "none";
}); });
// NextEpisodeButton only for the "credits to end of video" segment (end_ms === null)
const creditsSegment = props.segments.find(
(s) => s.type === "credits" && s.end_ms === null,
);
const inCreditsSegment =
creditsSegment != null && time * 1000 >= (creditsSegment.start_ms ?? 0);
const showNextEpisodeButton =
shouldShowNextEpisodeInsteadOfCredits &&
props.inControl &&
inCreditsSegment;
const handleSkip = useCallback( const handleSkip = useCallback(
(segment: SegmentData) => { (segment: SegmentData) => {
if (!display) return; if (!display) return;
@ -146,70 +157,70 @@ function SkipSegmentButton(props: {
[display, time, _duration, addSkipEvent, meta, props], [display, time, _duration, addSkipEvent, meta, props],
); );
// Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end if (!props.inControl) return null;
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; if (status !== "playing") return null;
if (activeSegments.length === 0 && !showNextEpisodeButton) return null;
return ( return (
<div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0"> <>
{activeSegments.map((segment, index) => { <div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0">
const showingState = shouldShowSkipButton(time, segment); {activeSegments.map((segment, index) => {
const animation = showingState === "hover" ? "slide-up" : "fade"; const showingState = shouldShowSkipButton(time, segment);
const animation = showingState === "hover" ? "slide-up" : "fade";
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
if (showingState === "always") { if (showingState === "always") {
bottom = props.controlsShowing bottom = props.controlsShowing
? bottom ? bottom
: "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; : "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
} }
// Offset multiple buttons vertically // Offset multiple buttons vertically
const verticalOffset = index * 60; // 60px spacing between buttons const verticalOffset = index * 60; // 60px spacing between buttons
const adjustedBottom = bottom.replace( const adjustedBottom = bottom.replace(
/bottom-\[calc\(([^)]+)\)\]/, /bottom-\[calc\(([^)]+)\)\]/,
`bottom-[calc($1 + ${verticalOffset}px)]`, `bottom-[calc($1 + ${verticalOffset}px)]`,
); );
// Show button whenever we're in a segment (not only on hover after first 10s) let show = false;
const show = showingState === "always" || showingState === "hover"; if (showingState === "always") show = true;
else if (showingState === "hover" && props.controlsShowing)
show = true;
return ( return (
<Transition <Transition
key={segment.type} key={segment.type}
animation={animation} animation={animation}
show={show} show={show}
className="absolute right-0" className="absolute right-0"
>
<div
className={classNames([
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
adjustedBottom,
])}
> >
<Button <div
onClick={() => handleSkip(segment)} className={classNames([
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" "absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
adjustedBottom,
])}
> >
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} /> <Button
{getSegmentText(segment.type, t)} onClick={() => handleSkip(segment)}
</Button> className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
</div> >
</Transition> <Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
); {getSegmentText(segment.type, t)}
})} </Button>
</div> </div>
</Transition>
);
})}
</div>
{showNextEpisodeButton && (
<NextEpisodeButton
controlsShowing={props.controlsShowing}
onChange={props.onChangeMeta}
inControl={props.inControl}
forceShow
/>
)}
</>
); );
} }

View file

@ -4,7 +4,7 @@ import { useEffect } from "react";
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { getMediaKey } from "@/stores/player/slices/source"; import type { PlayerMeta } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences"; import { usePreferencesStore } from "@/stores/preferences";
import { getTurnstileToken } from "@/utils/turnstile"; import { getTurnstileToken } from "@/utils/turnstile";
@ -18,6 +18,19 @@ const MAX_RETRIES = 3;
// Track the source of the current skip time (for analytics filtering) // Track the source of the current skip time (for analytics filtering)
let currentSkipTimeSource: "fed-skips" | "introdb" | "theintrodb" | null = null; let currentSkipTimeSource: "fed-skips" | "introdb" | "theintrodb" | null = null;
// Prevent multiple components from triggering overlapping fetches for the same media
let fetchingForCacheKey: string | null = null;
/** Cache key for skip segments matches TIDB API (tmdbId + season + episode number). */
function getSkipSegmentsCacheKey(meta: PlayerMeta | null): string | null {
if (!meta?.tmdbId) return null;
if (meta.type === "movie") return `skip-${meta.type}-${meta.tmdbId}`;
if (meta.type === "show" && meta.season != null && meta.episode != null) {
return `skip-${meta.type}-${meta.tmdbId}-${meta.season.number}-${meta.episode.number}`;
}
return null;
}
export function useSkipTimeSource(): typeof currentSkipTimeSource { export function useSkipTimeSource(): typeof currentSkipTimeSource {
return currentSkipTimeSource; return currentSkipTimeSource;
} }
@ -33,7 +46,7 @@ export interface SegmentData {
export function useSkipTime() { export function useSkipTime() {
const { playerMeta: meta } = usePlayerMeta(); const { playerMeta: meta } = usePlayerMeta();
const febboxKey = usePreferencesStore((s) => s.febboxKey); const febboxKey = usePreferencesStore((s) => s.febboxKey);
const cacheKey = getMediaKey(meta ?? null); const cacheKey = getSkipSegmentsCacheKey(meta ?? null);
const skipSegmentsCacheKey = usePlayerStore((s) => s.skipSegmentsCacheKey); const skipSegmentsCacheKey = usePlayerStore((s) => s.skipSegmentsCacheKey);
const skipSegments = usePlayerStore((s) => s.skipSegments); const skipSegments = usePlayerStore((s) => s.skipSegments);
const setSkipSegments = usePlayerStore((s) => s.setSkipSegments); const setSkipSegments = usePlayerStore((s) => s.setSkipSegments);
@ -41,7 +54,10 @@ export function useSkipTime() {
useEffect(() => { useEffect(() => {
if (!cacheKey) return; if (!cacheKey) return;
// Already have segments for this media don't refetch (e.g. when opening menu) // Already have segments for this media don't refetch (e.g. when opening menu)
if (cacheKey === skipSegmentsCacheKey) return; if (usePlayerStore.getState().skipSegmentsCacheKey === cacheKey) return;
// Another fetch for this key is already in progress (e.g. two components mounted)
if (fetchingForCacheKey === cacheKey) return;
fetchingForCacheKey = cacheKey;
// Validate segment data according to rules // Validate segment data according to rules
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
const validateSegment = ( const validateSegment = (
@ -86,8 +102,11 @@ export function useSkipTime() {
return false; return false;
}; };
const fetchTheIntroDBSegments = async (): Promise<SegmentData[]> => { const fetchTheIntroDBSegments = async (): Promise<{
if (!meta?.tmdbId) return []; segments: SegmentData[];
tidbNotFound: boolean;
}> => {
if (!meta?.tmdbId) return { segments: [], tidbNotFound: false };
try { try {
let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`; let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`;
@ -148,10 +167,19 @@ export function useSkipTime() {
}); });
} }
return fetchedSegments; // TIDB returned 200 we have segment data for this media (even if no intro)
} catch (error) { return { segments: fetchedSegments, tidbNotFound: false };
} catch (error: unknown) {
const err = error as {
response?: { status?: number };
status?: number;
};
const status = err?.response?.status ?? err?.status;
if (status === 404) {
return { segments: [], tidbNotFound: true };
}
console.error("Error fetching TIDB segments:", error); console.error("Error fetching TIDB segments:", error);
return []; return { segments: [], tidbNotFound: false };
} }
}; };
@ -219,77 +247,82 @@ export function useSkipTime() {
} }
}; };
const applySegments = (segmentsToApply: SegmentData[]) => {
// Only update store if this fetch is still for the current media (avoid stale overwrite)
const currentKey = getSkipSegmentsCacheKey(
usePlayerStore.getState().meta ?? null,
);
if (currentKey === cacheKey) {
setSkipSegments(cacheKey, segmentsToApply);
}
};
const fetchSkipTime = async (): Promise<void> => { const fetchSkipTime = async (): Promise<void> => {
currentSkipTimeSource = null; currentSkipTimeSource = null;
// Try TheIntroDB API first (supports both movies and TV shows with full segment data) try {
const theIntroDBSegments = await fetchTheIntroDBSegments(); // Try TheIntroDB API first (supports both movies and TV shows with full segment data)
const hasIntroSegment = theIntroDBSegments.some( const { segments: tidbSegments, tidbNotFound } =
(s) => s.type === "intro", await fetchTheIntroDBSegments();
);
const nonIntroSegments = theIntroDBSegments.filter(
(s) => s.type !== "intro",
);
// If we have a valid intro from TIDB, use all TIDB segments // TIDB returned 200 use whatever segments we got (intro, recap, credits; may be empty)
if (hasIntroSegment) { if (!tidbNotFound) {
currentSkipTimeSource = "theintrodb"; currentSkipTimeSource = "theintrodb";
setSkipSegments(cacheKey, theIntroDBSegments); applySegments(tidbSegments);
return; return;
} }
// If TIDB doesn't have a valid intro, try fallbacks to get intro data // TIDB returned 404 no segment data for this media; try fallbacks for intro only
// But keep any valid recap/credits segments from TIDB const nonIntroSegments: SegmentData[] = [];
let fallbackIntroSegment: SegmentData | null = null; let fallbackIntroSegment: SegmentData | null = null;
// Fall back to Fed-skips if TheIntroDB doesn't have intro // Fall back to Fed-skips (TV shows only)
// Note: Fed-skips only supports TV shows, not movies if (febboxKey && meta?.type !== "movie") {
if (febboxKey && meta?.type !== "movie") { const fedSkipsTime = await fetchFedSkipsTime();
const fedSkipsTime = await fetchFedSkipsTime(); if (fedSkipsTime !== null) {
if (fedSkipsTime !== null) { currentSkipTimeSource = "fed-skips";
currentSkipTimeSource = "fed-skips"; fallbackIntroSegment = {
fallbackIntroSegment = { type: "intro",
type: "intro", start_ms: 0,
start_ms: 0, // Assume starts at beginning end_ms: fedSkipsTime * 1000,
end_ms: fedSkipsTime * 1000, // Convert seconds to milliseconds confidence: null,
confidence: null, submission_count: 1,
submission_count: 1, };
}; }
}
// Last resort: IntroDB API (TV shows only)
if (!fallbackIntroSegment && meta?.type !== "movie") {
const introDBTime = await fetchIntroDBTime();
if (introDBTime !== null) {
currentSkipTimeSource = "introdb";
fallbackIntroSegment = {
type: "intro",
start_ms: 0,
end_ms: introDBTime * 1000,
confidence: null,
submission_count: 1,
};
}
}
const finalSegments: SegmentData[] = [];
if (fallbackIntroSegment) {
finalSegments.push(fallbackIntroSegment);
}
finalSegments.push(...nonIntroSegments);
applySegments(finalSegments);
} finally {
if (fetchingForCacheKey === cacheKey) {
fetchingForCacheKey = null;
} }
} }
// Last resort: Fall back to IntroDB API (TV shows only, available to all users)
if (!fallbackIntroSegment) {
const introDBTime = await fetchIntroDBTime();
if (introDBTime !== null) {
currentSkipTimeSource = "introdb";
fallbackIntroSegment = {
type: "intro",
start_ms: 0, // Assume starts at beginning
end_ms: introDBTime * 1000, // Convert seconds to milliseconds
confidence: null,
submission_count: 1,
};
}
}
// Combine fallback intro with any valid TIDB segments (recap/credits)
const finalSegments: SegmentData[] = [];
if (fallbackIntroSegment) {
finalSegments.push(fallbackIntroSegment);
}
// Add any valid recap/credits segments from TIDB
finalSegments.push(...nonIntroSegments);
// Always update cache (even when empty) so we don't refetch for this media
setSkipSegments(cacheKey, finalSegments);
}; };
fetchSkipTime(); fetchSkipTime();
}, [ }, [
cacheKey, cacheKey,
skipSegmentsCacheKey,
setSkipSegments,
meta?.tmdbId, meta?.tmdbId,
meta?.imdbId, meta?.imdbId,
meta?.title, meta?.title,
@ -297,6 +330,7 @@ export function useSkipTime() {
meta?.season?.number, meta?.season?.number,
meta?.episode?.number, meta?.episode?.number,
febboxKey, febboxKey,
setSkipSegments,
]); ]);
// Only return segments when they're for the current media (avoid showing stale data) // Only return segments when they're for the current media (avoid showing stale data)