mirror of
https://github.com/p-stream/p-stream.git
synced 2026-05-06 07:59:16 +00:00
update skip button to support other segments
This commit is contained in:
parent
0b2536486f
commit
c0029577e2
5 changed files with 304 additions and 147 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
211
src/components/player/atoms/SkipSegmentButton.tsx
Normal file
211
src/components/player/atoms/SkipSegmentButton.tsx
Normal 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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue