diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json
index 86c6d2ea..32845ede 100644
--- a/src/assets/locales/en.json
+++ b/src/assets/locales/en.json
@@ -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",
diff --git a/src/components/overlays/Modal.tsx b/src/components/overlays/Modal.tsx
index fea9fe41..b8abfc12 100644
--- a/src/components/overlays/Modal.tsx
+++ b/src/components/overlays/Modal.tsx
@@ -26,8 +26,13 @@ export function ModalCard(props: {
className?: ReactNode;
}) {
return (
-
-
+
@@ -50,7 +55,7 @@ export function Modal(props: { id: string; children?: ReactNode }) {
-
+
{props.children}
diff --git a/src/components/player/TIDBSubmissionForm.tsx b/src/components/player/TIDBSubmissionForm.tsx
index 02e6f629..a1a19b40 100644
--- a/src/components/player/TIDBSubmissionForm.tsx
+++ b/src/components/player/TIDBSubmissionForm.tsx
@@ -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
(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 (
-
-
-
- {t("player.skipTime.feedback.modal.title")}
-
-
+
+
+
+
+
+ {t("player.skipTime.feedback.modal.title")}
+
+
+
{t("player.skipTime.feedback.modal.description")}
-
- {/* Section: Segment timestamps */}
-
-
+
+ {/* Section: Segment type and timestamps (example-style card) */}
+
+
{t("player.skipTime.feedback.modal.segmentType")}
*
-
-
- setFormData({ ...formData, segment: item.id as SegmentType })
- }
- />
-
+
-
diff --git a/src/components/player/atoms/SkipSegmentButton.tsx b/src/components/player/atoms/SkipSegmentButton.tsx
index f5440e4e..4eb8d217 100644
--- a/src/components/player/atoms/SkipSegmentButton.tsx
+++ b/src/components/player/atoms/SkipSegmentButton.tsx
@@ -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");
}
diff --git a/src/components/player/hooks/useSkipTime.ts b/src/components/player/hooks/useSkipTime.ts
index 3d6375b4..be34290a 100644
--- a/src/components/player/hooks/useSkipTime.ts
+++ b/src/components/player/hooks/useSkipTime.ts
@@ -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);
};
diff --git a/src/utils/tidb.ts b/src/utils/tidb.ts
index d483f469..623de25a 100644
--- a/src/utils/tidb.ts
+++ b/src/utils/tidb.ts
@@ -1,4 +1,4 @@
-export type SegmentType = "intro" | "recap" | "credits";
+export type SegmentType = "intro" | "recap" | "credits" | "preview";
export interface SubmissionRequest {
tmdb_id: number;