mirror of
https://github.com/p-stream/p-stream.git
synced 2026-05-07 04:29:55 +00:00
fix skip section buttons not showing
This commit is contained in:
parent
64a241e2ed
commit
a9e2ff2dc6
3 changed files with 174 additions and 126 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue