mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
update TIDB integration
This commit is contained in:
parent
20f7a08dd0
commit
adbdd19e66
6 changed files with 281 additions and 255 deletions
|
|
@ -1003,7 +1003,8 @@
|
|||
"types": {
|
||||
"intro": "Intro",
|
||||
"recap": "Recap",
|
||||
"credits": "Credits"
|
||||
"credits": "Credits",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"startTimeLabel": "Start (s)",
|
||||
"endTimeLabel": "End (s)",
|
||||
|
|
@ -1011,12 +1012,14 @@
|
|||
"start": {
|
||||
"intro": "2:30 or 150 (leave empty for start at beginning)",
|
||||
"recap": "2:30 or 150 (leave empty for start at beginning)",
|
||||
"credits": "2:30 or 150 (required)"
|
||||
"credits": "2:30 or 150 (required)",
|
||||
"preview": "2:30 or 150 (required)"
|
||||
},
|
||||
"end": {
|
||||
"intro": "3:30 or 210 (required)",
|
||||
"recap": "3:30 or 210 (required)",
|
||||
"credits": "3:30 or 210 (leave empty for end of media)"
|
||||
"credits": "3:30 or 210 (leave empty for end of media)",
|
||||
"preview": "3:30 or 210 (leave empty for end of media)"
|
||||
}
|
||||
},
|
||||
"whenToTitle": "Timestamps guide:",
|
||||
|
|
@ -1029,7 +1032,35 @@
|
|||
"durationLabel": "Duration:",
|
||||
"durationDesc": "Min 5s if end provided",
|
||||
"excludeLabel": "Exclude:",
|
||||
"excludeDesc": "Post-credits scenes"
|
||||
"excludeDesc": "Post-credits scenes",
|
||||
"intro": {
|
||||
"whenToTitle": "Intro timestamps guide:",
|
||||
"startDesc": "Optional - leave empty if the intro starts at the beginning of the episode.",
|
||||
"endDesc": "Required - when the intro ends (usually when the title card or main episode begins).",
|
||||
"durationDesc": "Most intros are 30–90 seconds. Minimum 5s if both start and end are set.",
|
||||
"excludeDesc": "Do not include cold opens or pre-intro scenes; only the main title sequence."
|
||||
},
|
||||
"recap": {
|
||||
"whenToTitle": "Recap timestamps guide:",
|
||||
"startDesc": "Optional - leave empty if the recap starts at the beginning of the episode.",
|
||||
"endDesc": "Required - when the recap ends and the new content begins.",
|
||||
"durationDesc": "Recaps vary in length. Minimum 5s if both start and end are set.",
|
||||
"excludeDesc": "Only the \"previously on\" or recap segment; do not include the intro or new scenes."
|
||||
},
|
||||
"credits": {
|
||||
"whenToTitle": "Credits timestamps guide:",
|
||||
"startDesc": "Required - when the closing credits begin (e.g. when the first credit text appears).",
|
||||
"endDesc": "Optional - leave empty if credits run to the end of the file.",
|
||||
"durationDesc": "Minimum 5s if end is provided. Can extend to end of media.",
|
||||
"excludeDesc": "Exclude mid-credits or post-credits scenes; only the main credit roll."
|
||||
},
|
||||
"preview": {
|
||||
"whenToTitle": "Preview timestamps guide:",
|
||||
"startDesc": "Required - when the preview starts (e.g. when the preview text appears).",
|
||||
"endDesc": "Optional - leave empty if the preview ends at the end of the media.",
|
||||
"durationDesc": "Preview varies in length. Minimum 5s.",
|
||||
"excludeDesc": "Preview is usually 15-90 seconds at the end of the episode previewing the next episode."
|
||||
}
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"submit": "Submit",
|
||||
|
|
|
|||
|
|
@ -26,8 +26,13 @@ export function ModalCard(props: {
|
|||
className?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("w-full max-w-[30rem] m-4", props.className)}>
|
||||
<div className="w-full bg-modal-background rounded-xl p-8 pointer-events-auto">
|
||||
<div
|
||||
className={classNames(
|
||||
"w-full max-w-[30rem] m-4 pointer-events-auto",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="w-full bg-modal-background rounded-xl p-8">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -50,7 +55,7 @@ export function Modal(props: { id: string; children?: ReactNode }) {
|
|||
<Helmet>
|
||||
<html data-no-scroll />
|
||||
</Helmet>
|
||||
<div className="flex absolute inset-0 items-center justify-center flex-col">
|
||||
<div className="flex absolute inset-0 items-center justify-center flex-col pointer-events-none">
|
||||
{props.children}
|
||||
</div>
|
||||
</OverlayPortal>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Dropdown } from "@/components/form/Dropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Modal, useModal } from "@/components/overlays/Modal";
|
||||
import { SegmentData } from "@/components/player/hooks/useSkipTime";
|
||||
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||
|
|
@ -11,7 +11,7 @@ import { usePlayerStore } from "@/stores/player/store";
|
|||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { submitIntro } from "@/utils/tidb";
|
||||
|
||||
type SegmentType = "intro" | "recap" | "credits";
|
||||
type SegmentType = "intro" | "recap" | "credits" | "preview";
|
||||
|
||||
// Helper function to parse time format (hh:mm:ss, mm:ss, or seconds)
|
||||
// Returns null if empty string, NaN if invalid, or number if valid
|
||||
|
|
@ -19,46 +19,43 @@ function parseTimeToSeconds(timeStr: string): number | null {
|
|||
if (!timeStr.trim()) return null;
|
||||
|
||||
// Check if it's in hh:mm:ss format
|
||||
const hhmmssMatch = timeStr.match(/^(\d{1,2}):([0-5]?\d):([0-5]?\d)$/);
|
||||
const hhmmssMatch = timeStr.match(/^($\d{1,2}$):($[0-5]?\d$):($[0-5]?\d$)$/);
|
||||
if (hhmmssMatch) {
|
||||
const hours = parseInt(hhmmssMatch[1], 10);
|
||||
const minutes = parseInt(hhmmssMatch[2], 10);
|
||||
const seconds = parseInt(hhmmssMatch[3], 10);
|
||||
|
||||
// Validate reasonable bounds (max 99 hours, minutes/seconds 0-59)
|
||||
if (hours > 99 || minutes > 59 || seconds > 59) {
|
||||
return NaN; // Invalid format
|
||||
return NaN;
|
||||
}
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
// Check if it's in mm:ss format
|
||||
const mmssMatch = timeStr.match(/^(\d{1,3}):([0-5]?\d)$/);
|
||||
const mmssMatch = timeStr.match(/^($\d{1,3}$):($[0-5]?\d$)$/);
|
||||
if (mmssMatch) {
|
||||
const minutes = parseInt(mmssMatch[1], 10);
|
||||
const seconds = parseInt(mmssMatch[2], 10);
|
||||
|
||||
// Validate reasonable bounds (max 999 minutes, seconds 0-59)
|
||||
if (minutes > 999 || seconds > 59) {
|
||||
return NaN; // Invalid format
|
||||
return NaN;
|
||||
}
|
||||
|
||||
return minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
// Otherwise, treat as plain seconds (but only if no colons in input)
|
||||
if (timeStr.includes(":")) {
|
||||
return NaN; // Invalid time format - has colons but didn't match time patterns
|
||||
return NaN;
|
||||
}
|
||||
const parsed = parseFloat(timeStr);
|
||||
if (
|
||||
Number.isNaN(parsed) ||
|
||||
!Number.isFinite(parsed) ||
|
||||
parsed < 0 ||
|
||||
parsed > 20000000
|
||||
parsed > 21600000
|
||||
) {
|
||||
return NaN; // Invalid input
|
||||
return NaN;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
|
|
@ -79,6 +76,7 @@ export function TIDBSubmissionForm({
|
|||
const meta = usePlayerStore((s) => s.meta);
|
||||
const tidbKey = usePreferencesStore((s) => s.tidbKey);
|
||||
const submissionModal = useModal("tidb-submission");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState<{
|
||||
segment: SegmentType;
|
||||
|
|
@ -109,7 +107,6 @@ export function TIDBSubmissionForm({
|
|||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if form is valid
|
||||
if (!formData.segment) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(t("player.skipTime.feedback.modal.error.segment"));
|
||||
|
|
@ -131,7 +128,7 @@ export function TIDBSubmissionForm({
|
|||
const startSeconds = parseTimeToSeconds(formData.start);
|
||||
const endSeconds = parseTimeToSeconds(formData.end);
|
||||
|
||||
// Basic validation
|
||||
// Validate required fields based on segment type
|
||||
if (formData.segment === "intro" || formData.segment === "recap") {
|
||||
if (endSeconds === null || Number.isNaN(endSeconds)) {
|
||||
// eslint-disable-next-line no-alert
|
||||
|
|
@ -139,7 +136,10 @@ export function TIDBSubmissionForm({
|
|||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} else if (formData.segment === "credits") {
|
||||
} else if (
|
||||
formData.segment === "credits" ||
|
||||
formData.segment === "preview"
|
||||
) {
|
||||
if (startSeconds === null || Number.isNaN(startSeconds)) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(t("player.skipTime.feedback.modal.error.startTime"));
|
||||
|
|
@ -148,31 +148,38 @@ export function TIDBSubmissionForm({
|
|||
}
|
||||
}
|
||||
|
||||
// Prepare submission data
|
||||
const submissionData: any = {
|
||||
const submissionData: {
|
||||
tmdb_id: number;
|
||||
type: "movie" | "tv";
|
||||
segment: SegmentType;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
start_sec?: number | null;
|
||||
end_sec?: number | null;
|
||||
} = {
|
||||
tmdb_id: parseInt(meta.tmdbId.toString(), 10),
|
||||
type: meta.type === "show" ? "tv" : "movie",
|
||||
segment: formData.segment,
|
||||
};
|
||||
|
||||
// Add season/episode for TV shows
|
||||
if (meta.type === "show" && meta.season && meta.episode) {
|
||||
submissionData.season = meta.season.number;
|
||||
submissionData.episode = meta.episode.number;
|
||||
}
|
||||
|
||||
// Set start_sec and end_sec based on segment type
|
||||
if (formData.segment === "intro" || formData.segment === "recap") {
|
||||
submissionData.start_sec = startSeconds !== null ? startSeconds : null;
|
||||
submissionData.end_sec = endSeconds!;
|
||||
} else if (formData.segment === "credits") {
|
||||
} else if (
|
||||
formData.segment === "credits" ||
|
||||
formData.segment === "preview"
|
||||
) {
|
||||
submissionData.start_sec = startSeconds!;
|
||||
submissionData.end_sec = endSeconds !== null ? endSeconds : null;
|
||||
}
|
||||
|
||||
await submitIntro(submissionData, tidbKey);
|
||||
|
||||
// Success
|
||||
submissionModal.hide();
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
|
|
@ -186,175 +193,200 @@ export function TIDBSubmissionForm({
|
|||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
submissionModal.hide();
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal id={submissionModal.id}>
|
||||
<div className="w-full max-w-[30rem] m-4 px-4">
|
||||
<div className="w-full bg-modal-background rounded-xl p-8 pointer-events-auto max-h-[90vh] md:max-h-[80vh] overflow-y-auto">
|
||||
<Heading3 className="!mt-0 !mb-4">
|
||||
{t("player.skipTime.feedback.modal.title")}
|
||||
</Heading3>
|
||||
<Paragraph className="!mt-1 !mb-6">
|
||||
<div className="w-full max-w-[32rem] md:max-w-[50rem] lg:max-w-[60rem] m-4 px-4 max-h-[90vh] overflow-y-auto pointer-events-none">
|
||||
<div className="w-full bg-modal-background rounded-xl p-6 md:p-8 pointer-events-auto">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon icon={Icons.CLOCK} className="h-5 w-5 text-white" />
|
||||
<Heading3 className="!mt-0 !mb-0">
|
||||
{t("player.skipTime.feedback.modal.title")}
|
||||
</Heading3>
|
||||
</div>
|
||||
<Paragraph className="!mt-2 !mb-6 text-gray-300">
|
||||
{t("player.skipTime.feedback.modal.description")}
|
||||
</Paragraph>
|
||||
|
||||
<div className="space-y-4 mt-4 pb-4">
|
||||
{/* Section: Segment timestamps */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="segment"
|
||||
className="block text-sm font-medium text-white mb-1"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Section: Segment type and timestamps (example-style card) */}
|
||||
<div className="space-y-4 rounded-xl border border-background-secondary bg-authentication-inputBg/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t("player.skipTime.feedback.modal.segmentType")}
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Dropdown
|
||||
options={[
|
||||
{
|
||||
id: "intro",
|
||||
name: t("player.skipTime.feedback.modal.types.intro"),
|
||||
},
|
||||
{
|
||||
id: "recap",
|
||||
name: t("player.skipTime.feedback.modal.types.recap"),
|
||||
},
|
||||
{
|
||||
id: "credits",
|
||||
name: t("player.skipTime.feedback.modal.types.credits"),
|
||||
},
|
||||
]}
|
||||
selectedItem={{
|
||||
id: formData.segment,
|
||||
name:
|
||||
formData.segment === "intro"
|
||||
? t("player.skipTime.feedback.modal.types.intro")
|
||||
: formData.segment === "recap"
|
||||
? t("player.skipTime.feedback.modal.types.recap")
|
||||
: t("player.skipTime.feedback.modal.types.credits"),
|
||||
}}
|
||||
setSelectedItem={(item) =>
|
||||
setFormData({ ...formData, segment: item.id as SegmentType })
|
||||
}
|
||||
/>
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["intro", "recap", "credits", "preview"] as const).map(
|
||||
(seg) => (
|
||||
<Button
|
||||
key={seg}
|
||||
theme="secondary"
|
||||
className={
|
||||
formData.segment === seg
|
||||
? "!border-2 !border-buttons-purple !bg-buttons-purple/20 focus:outline-none focus-visible:outline-none"
|
||||
: "!border-2 !border-background-secondary hover:!bg-authentication-inputBg focus:outline-none focus-visible:outline-none"
|
||||
}
|
||||
onClick={() => setFormData({ ...formData, segment: seg })}
|
||||
>
|
||||
{seg === "intro"
|
||||
? t("player.skipTime.feedback.modal.types.intro")
|
||||
: seg === "recap"
|
||||
? t("player.skipTime.feedback.modal.types.recap")
|
||||
: seg === "credits"
|
||||
? t("player.skipTime.feedback.modal.types.credits")
|
||||
: t("player.skipTime.feedback.modal.types.preview")}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("player.skipTime.feedback.modal.whenToDesc")}
|
||||
</p>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tidb-start"
|
||||
className="block text-sm font-medium text-white mb-1"
|
||||
>
|
||||
{t("player.skipTime.feedback.modal.startTimeLabel")}
|
||||
{formData.segment === "credits" ||
|
||||
formData.segment === "preview" ? (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
) : null}
|
||||
</label>
|
||||
<AuthInputBox
|
||||
value={formData.start}
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, start: value })
|
||||
}
|
||||
placeholder={t(
|
||||
`player.skipTime.feedback.modal.placeholders.start.${formData.segment}`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tidb-end"
|
||||
className="block text-sm font-medium text-white mb-1"
|
||||
>
|
||||
{t("player.skipTime.feedback.modal.endTimeLabel")}
|
||||
{formData.segment === "intro" ||
|
||||
formData.segment === "recap" ? (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
) : null}
|
||||
</label>
|
||||
<AuthInputBox
|
||||
value={formData.end}
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, end: value })
|
||||
}
|
||||
placeholder={t(
|
||||
`player.skipTime.feedback.modal.placeholders.end.${formData.segment}`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timing guidance (segment-specific) */}
|
||||
<div className="rounded-lg border border-background-secondary bg-modal-background p-4">
|
||||
<h3 className="font-semibold text-white mb-2 text-sm">
|
||||
{t(
|
||||
`player.skipTime.feedback.modal.guide.${formData.segment}.whenToTitle`,
|
||||
{
|
||||
defaultValue: t(
|
||||
"player.skipTime.feedback.modal.whenToTitle",
|
||||
),
|
||||
},
|
||||
)}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs text-gray-400">
|
||||
<div>
|
||||
<span className="font-medium text-gray-300 block mb-0.5">
|
||||
{t("player.skipTime.feedback.modal.guide.startLabel")}
|
||||
</span>
|
||||
{t(
|
||||
`player.skipTime.feedback.modal.guide.${formData.segment}.startDesc`,
|
||||
{
|
||||
defaultValue: t(
|
||||
"player.skipTime.feedback.modal.guide.startDesc",
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-300 block mb-0.5">
|
||||
{t("player.skipTime.feedback.modal.guide.endLabel")}
|
||||
</span>
|
||||
{t(
|
||||
`player.skipTime.feedback.modal.guide.${formData.segment}.endDesc`,
|
||||
{
|
||||
defaultValue: t(
|
||||
"player.skipTime.feedback.modal.guide.endDesc",
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-300 block mb-0.5">
|
||||
{t(
|
||||
"player.skipTime.feedback.modal.guide.durationLabel",
|
||||
)}
|
||||
</span>
|
||||
{t(
|
||||
`player.skipTime.feedback.modal.guide.${formData.segment}.durationDesc`,
|
||||
{
|
||||
defaultValue: t(
|
||||
"player.skipTime.feedback.modal.guide.durationDesc",
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-300 block mb-0.5">
|
||||
{t("player.skipTime.feedback.modal.guide.excludeLabel")}
|
||||
</span>
|
||||
{t(
|
||||
`player.skipTime.feedback.modal.guide.${formData.segment}.excludeDesc`,
|
||||
{
|
||||
defaultValue: t(
|
||||
"player.skipTime.feedback.modal.guide.excludeDesc",
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2 justify-between">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("player.skipTime.feedback.modal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
theme="purple"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
icon={Icons.ARROW_RIGHT}
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
>
|
||||
{isSubmitting
|
||||
? t("player.skipTime.feedback.modal.submitting")
|
||||
: t("player.skipTime.feedback.modal.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="start"
|
||||
className="block text-sm font-medium text-white mb-1"
|
||||
>
|
||||
{t("player.skipTime.feedback.modal.startTimeLabel")}
|
||||
{formData.segment === "credits" ? (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
) : null}
|
||||
</label>
|
||||
<AuthInputBox
|
||||
value={formData.start}
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, start: value })
|
||||
}
|
||||
placeholder={t(
|
||||
`player.skipTime.feedback.modal.placeholders.start.${formData.segment}`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="end"
|
||||
className="block text-sm font-medium text-white mb-1"
|
||||
>
|
||||
{t("player.skipTime.feedback.modal.endTimeLabel")}
|
||||
{formData.segment === "intro" ||
|
||||
formData.segment === "recap" ? (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
) : null}
|
||||
</label>
|
||||
<AuthInputBox
|
||||
value={formData.end}
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, end: value })
|
||||
}
|
||||
placeholder={t(
|
||||
`player.skipTime.feedback.modal.placeholders.end.${formData.segment}`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timing Guidance Section */}
|
||||
<div className="mt-6 p-4 bg-pill-background rounded-lg">
|
||||
<h3 className="font-semibold text-white mb-3">
|
||||
{t("player.skipTime.feedback.modal.whenToTitle")}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-300">
|
||||
{t("player.skipTime.feedback.modal.whenToDesc")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 my-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-1">
|
||||
{t("player.skipTime.feedback.modal.guide.startLabel")}
|
||||
</h4>
|
||||
<p className="text-xs">
|
||||
{t("player.skipTime.feedback.modal.guide.startDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1">
|
||||
{t("player.skipTime.feedback.modal.guide.endLabel")}
|
||||
</h4>
|
||||
<p className="text-xs">
|
||||
{t("player.skipTime.feedback.modal.guide.endDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1">
|
||||
{t("player.skipTime.feedback.modal.guide.durationLabel")}
|
||||
</h4>
|
||||
<p className="text-xs">
|
||||
{t("player.skipTime.feedback.modal.guide.durationDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1">
|
||||
{t("player.skipTime.feedback.modal.guide.excludeLabel")}
|
||||
</h4>
|
||||
<p className="text-xs">
|
||||
{t("player.skipTime.feedback.modal.guide.excludeDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 justify-between">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={() => {
|
||||
submissionModal.hide();
|
||||
if (onCancel) onCancel();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("player.skipTime.feedback.modal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
theme="purple"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={() => {
|
||||
// Trigger form submission
|
||||
const form = document.querySelector("form");
|
||||
if (form) form.requestSubmit();
|
||||
}}
|
||||
>
|
||||
{isSubmitting
|
||||
? t("player.skipTime.feedback.modal.submitting")
|
||||
: t("player.skipTime.feedback.modal.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { PlayerMeta } from "@/stores/player/slices/source";
|
|||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
function getSegmentText(
|
||||
type: "intro" | "recap" | "credits",
|
||||
type: "intro" | "recap" | "credits" | "preview",
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
switch (type) {
|
||||
|
|
@ -21,6 +21,8 @@ function getSegmentText(
|
|||
return t("player.skipTime.recap");
|
||||
case "credits":
|
||||
return t("player.skipTime.credits");
|
||||
case "preview":
|
||||
return t("player.skipTime.preview");
|
||||
default:
|
||||
return t("player.skipTime.intro");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function useSkipTimeSource(): typeof currentSkipTimeSource {
|
|||
}
|
||||
|
||||
export interface SegmentData {
|
||||
type: "intro" | "recap" | "credits";
|
||||
type: "intro" | "recap" | "credits" | "preview";
|
||||
start_ms: number | null;
|
||||
end_ms: number | null;
|
||||
confidence: number | null;
|
||||
|
|
@ -58,49 +58,6 @@ export function useSkipTime() {
|
|||
// 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 = (
|
||||
type: "intro" | "recap" | "credits",
|
||||
// eslint-disable-next-line camelcase
|
||||
start_ms: number | null,
|
||||
// eslint-disable-next-line camelcase
|
||||
end_ms: number | null,
|
||||
): boolean => {
|
||||
// eslint-disable-next-line camelcase
|
||||
const start = start_ms ?? 0;
|
||||
// eslint-disable-next-line camelcase
|
||||
const end = end_ms;
|
||||
|
||||
if (type === "intro") {
|
||||
// Intro: end_ms is required, duration must be 0 or 5-200 seconds
|
||||
if (end === null) return false;
|
||||
const duration = (end - start) / 1000;
|
||||
if (duration === 0) return true; // No intro is valid
|
||||
return duration >= 5 && duration <= 200;
|
||||
}
|
||||
|
||||
if (type === "recap") {
|
||||
// Recap: end_ms is required, duration must be 0 or 5-1200 seconds
|
||||
if (end === null) return false;
|
||||
const duration = (end - start) / 1000;
|
||||
if (duration === 0) return true; // No recap is valid
|
||||
return duration >= 5 && duration <= 1200;
|
||||
}
|
||||
|
||||
if (type === "credits") {
|
||||
// Credits: start_ms is required
|
||||
// If end_ms is provided, duration must be at least 5 seconds
|
||||
// If end_ms is null, credits extend to end of video (valid)
|
||||
// eslint-disable-next-line camelcase
|
||||
if (start_ms === null) return false;
|
||||
if (end === null) return true; // Credits to end of video is valid
|
||||
const duration = (end - start) / 1000;
|
||||
return duration >= 5;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchTheIntroDBSegments = async (): Promise<{
|
||||
segments: SegmentData[];
|
||||
|
|
@ -122,12 +79,8 @@ export function useSkipTime() {
|
|||
|
||||
const fetchedSegments: SegmentData[] = [];
|
||||
|
||||
// Add intro segment if it has valid data
|
||||
if (
|
||||
data?.intro &&
|
||||
data.intro.submission_count > 0 &&
|
||||
validateSegment("intro", data.intro.start_ms, data.intro.end_ms)
|
||||
) {
|
||||
// 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,
|
||||
|
|
@ -137,12 +90,8 @@ export function useSkipTime() {
|
|||
});
|
||||
}
|
||||
|
||||
// Add recap segment if it has valid data
|
||||
if (
|
||||
data?.recap &&
|
||||
data.recap.submission_count > 0 &&
|
||||
validateSegment("recap", data.recap.start_ms, data.recap.end_ms)
|
||||
) {
|
||||
// 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,
|
||||
|
|
@ -152,12 +101,8 @@ export function useSkipTime() {
|
|||
});
|
||||
}
|
||||
|
||||
// Add credits segment if it has valid data
|
||||
if (
|
||||
data?.credits &&
|
||||
data.credits.submission_count > 0 &&
|
||||
validateSegment("credits", data.credits.start_ms, data.credits.end_ms)
|
||||
) {
|
||||
// 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,
|
||||
|
|
@ -167,6 +112,17 @@ export function useSkipTime() {
|
|||
});
|
||||
}
|
||||
|
||||
// Add preview segment if it has data
|
||||
if (data?.preview && data.preview.submission_count > 0) {
|
||||
fetchedSegments.push({
|
||||
type: "preview",
|
||||
start_ms: data.preview.start_ms,
|
||||
end_ms: data.preview.end_ms,
|
||||
confidence: data.preview.confidence,
|
||||
submission_count: data.preview.submission_count,
|
||||
});
|
||||
}
|
||||
|
||||
// TIDB returned 200 – we have segment data for this media (even if no intro)
|
||||
return { segments: fetchedSegments, tidbNotFound: false };
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -213,7 +169,7 @@ export function useSkipTime() {
|
|||
|
||||
const parseSkipTime = (timeStr: string | undefined): number | null => {
|
||||
if (!timeStr || typeof timeStr !== "string") return null;
|
||||
const match = timeStr.match(/^(\d+)s$/);
|
||||
const match = timeStr.match(/^($\d+$)s$/);
|
||||
if (!match) return null;
|
||||
return parseInt(match[1], 10);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type SegmentType = "intro" | "recap" | "credits";
|
||||
export type SegmentType = "intro" | "recap" | "credits" | "preview";
|
||||
|
||||
export interface SubmissionRequest {
|
||||
tmdb_id: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue