diff --git a/src/components/player/atoms/NextEpisodeButton.tsx b/src/components/player/atoms/NextEpisodeButton.tsx index a59d2efb..f18c89bd 100644 --- a/src/components/player/atoms/NextEpisodeButton.tsx +++ b/src/components/player/atoms/NextEpisodeButton.tsx @@ -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, diff --git a/src/components/player/atoms/SkipSegmentButton.tsx b/src/components/player/atoms/SkipSegmentButton.tsx index ab34645a..f5440e4e 100644 --- a/src/components/player/atoms/SkipSegmentButton.tsx +++ b/src/components/player/atoms/SkipSegmentButton.tsx @@ -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 ( - - ); - } - - 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 ( -
- {activeSegments.map((segment, index) => { - const showingState = shouldShowSkipButton(time, segment); - const animation = showingState === "hover" ? "slide-up" : "fade"; + <> +
+ {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 ( - -
- -
-
- ); - })} -
+ +
+ + ); + })} + + {showNextEpisodeButton && ( + + )} + ); } diff --git a/src/components/player/hooks/useSkipTime.ts b/src/components/player/hooks/useSkipTime.ts index 215f7d38..3d6375b4 100644 --- a/src/components/player/hooks/useSkipTime.ts +++ b/src/components/player/hooks/useSkipTime.ts @@ -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 => { - 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 => { 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)