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;
inControl: boolean;
showAsButton?: boolean;
/** When true (e.g. in credits-to-end segment), show regardless of time/duration. */
forceShow?: boolean;
}) {
const { t } = useTranslation();
const duration = usePlayerStore((s) => s.progress.duration);
@ -109,7 +111,8 @@ export function NextEpisodeButton(props: {
const setLastSuccessfulSource = usePreferencesStore(
(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 setShouldStartFromBeginning = usePlayerStore(
(s) => s.setShouldStartFromBeginning,

View file

@ -86,18 +86,18 @@ function SkipSegmentButton(props: {
const meta = usePlayerStore((s) => s.meta);
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 =
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
// Find segments that should be shown at the current time (intro, recap; credits excluded when we show NextEpisodeButton)
const activeSegments = props.segments.filter((segment) => {
// Skip credits segments if we're showing NextEpisodeButton instead
if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) {
return false;
}
@ -105,6 +105,17 @@ function SkipSegmentButton(props: {
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(
(segment: SegmentData) => {
if (!display) return;
@ -146,70 +157,70 @@ function SkipSegmentButton(props: {
[display, time, _duration, addSkipEvent, meta, props],
);
// 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 (!props.inControl) return null;
if (status !== "playing") return null;
if (activeSegments.length === 0 && !showNextEpisodeButton) 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";
<>
<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))]";
}
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)]`,
);
// Offset multiple buttons vertically
const verticalOffset = index * 60; // 60px spacing between buttons
const adjustedBottom = bottom.replace(
/bottom-\[calc\(([^)]+)\)\]/,
`bottom-[calc($1 + ${verticalOffset}px)]`,
);
// Show button whenever we're in a segment (not only on hover after first 10s)
const show = showingState === "always" || showingState === "hover";
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,
])}
return (
<Transition
key={segment.type}
animation={animation}
show={show}
className="absolute right-0"
>
<Button
onClick={() => handleSkip(segment)}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
<div
className={classNames([
"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} />
{getSegmentText(segment.type, t)}
</Button>
</div>
</Transition>
);
})}
</div>
<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>
{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 { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
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 { usePreferencesStore } from "@/stores/preferences";
import { getTurnstileToken } from "@/utils/turnstile";
@ -18,6 +18,19 @@ const MAX_RETRIES = 3;
// Track the source of the current skip time (for analytics filtering)
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 {
return currentSkipTimeSource;
}
@ -33,7 +46,7 @@ export interface SegmentData {
export function useSkipTime() {
const { playerMeta: meta } = usePlayerMeta();
const febboxKey = usePreferencesStore((s) => s.febboxKey);
const cacheKey = getMediaKey(meta ?? null);
const cacheKey = getSkipSegmentsCacheKey(meta ?? null);
const skipSegmentsCacheKey = usePlayerStore((s) => s.skipSegmentsCacheKey);
const skipSegments = usePlayerStore((s) => s.skipSegments);
const setSkipSegments = usePlayerStore((s) => s.setSkipSegments);
@ -41,7 +54,10 @@ export function useSkipTime() {
useEffect(() => {
if (!cacheKey) return;
// 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
// eslint-disable-next-line camelcase
const validateSegment = (
@ -86,8 +102,11 @@ export function useSkipTime() {
return false;
};
const fetchTheIntroDBSegments = async (): Promise<SegmentData[]> => {
if (!meta?.tmdbId) return [];
const fetchTheIntroDBSegments = async (): Promise<{
segments: SegmentData[];
tidbNotFound: boolean;
}> => {
if (!meta?.tmdbId) return { segments: [], tidbNotFound: false };
try {
let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`;
@ -148,10 +167,19 @@ export function useSkipTime() {
});
}
return fetchedSegments;
} catch (error) {
// TIDB returned 200 we have segment data for this media (even if no intro)
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);
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> => {
currentSkipTimeSource = null;
// Try TheIntroDB API first (supports both movies and TV shows with full segment data)
const theIntroDBSegments = await fetchTheIntroDBSegments();
const hasIntroSegment = theIntroDBSegments.some(
(s) => s.type === "intro",
);
const nonIntroSegments = theIntroDBSegments.filter(
(s) => s.type !== "intro",
);
try {
// Try TheIntroDB API first (supports both movies and TV shows with full segment data)
const { segments: tidbSegments, tidbNotFound } =
await fetchTheIntroDBSegments();
// If we have a valid intro from TIDB, use all TIDB segments
if (hasIntroSegment) {
currentSkipTimeSource = "theintrodb";
setSkipSegments(cacheKey, theIntroDBSegments);
return;
}
// TIDB returned 200 use whatever segments we got (intro, recap, credits; may be empty)
if (!tidbNotFound) {
currentSkipTimeSource = "theintrodb";
applySegments(tidbSegments);
return;
}
// If TIDB doesn't have a valid intro, try fallbacks to get intro data
// But keep any valid recap/credits segments from TIDB
let fallbackIntroSegment: SegmentData | null = null;
// TIDB returned 404 no segment data for this media; try fallbacks for intro only
const nonIntroSegments: SegmentData[] = [];
let fallbackIntroSegment: SegmentData | null = null;
// Fall back to Fed-skips if TheIntroDB doesn't have intro
// Note: Fed-skips only supports TV shows, not movies
if (febboxKey && meta?.type !== "movie") {
const fedSkipsTime = await fetchFedSkipsTime();
if (fedSkipsTime !== null) {
currentSkipTimeSource = "fed-skips";
fallbackIntroSegment = {
type: "intro",
start_ms: 0, // Assume starts at beginning
end_ms: fedSkipsTime * 1000, // Convert seconds to milliseconds
confidence: null,
submission_count: 1,
};
// Fall back to Fed-skips (TV shows only)
if (febboxKey && meta?.type !== "movie") {
const fedSkipsTime = await fetchFedSkipsTime();
if (fedSkipsTime !== null) {
currentSkipTimeSource = "fed-skips";
fallbackIntroSegment = {
type: "intro",
start_ms: 0,
end_ms: fedSkipsTime * 1000,
confidence: null,
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();
}, [
cacheKey,
skipSegmentsCacheKey,
setSkipSegments,
meta?.tmdbId,
meta?.imdbId,
meta?.title,
@ -297,6 +330,7 @@ export function useSkipTime() {
meta?.season?.number,
meta?.episode?.number,
febboxKey,
setSkipSegments,
]);
// Only return segments when they're for the current media (avoid showing stale data)