mirror of
https://github.com/p-stream/p-stream.git
synced 2026-05-12 04:40:52 +00:00
add Feedback buttons to submit to TIDB
This commit is contained in:
parent
9ab5bc4ce5
commit
f23ac179cd
13 changed files with 818 additions and 6 deletions
|
|
@ -977,7 +977,60 @@
|
||||||
"skipTime": {
|
"skipTime": {
|
||||||
"intro": "Skip Intro",
|
"intro": "Skip Intro",
|
||||||
"recap": "Skip Recap",
|
"recap": "Skip Recap",
|
||||||
"credits": "Skip Credits"
|
"credits": "Skip Credits",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Was this skip correct?",
|
||||||
|
"modal": {
|
||||||
|
"title": "Submit Timestamps to TheIntroDB",
|
||||||
|
"description": "Contribute to TheIntroDB by submitting accurate segment timestamps. All submissions are accepted by the community before being published.",
|
||||||
|
"segmentType": "Segment Type",
|
||||||
|
"types": {
|
||||||
|
"intro": "Intro",
|
||||||
|
"recap": "Recap",
|
||||||
|
"credits": "Credits"
|
||||||
|
},
|
||||||
|
"startTimeLabel": "Start (s)",
|
||||||
|
"endTimeLabel": "End (s)",
|
||||||
|
"placeholders": {
|
||||||
|
"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)"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"intro": "3:30 or 210 (required)",
|
||||||
|
"recap": "3:30 or 210 (required)",
|
||||||
|
"credits": "3:30 or 210 (leave empty for end of media)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"whenToTitle": "Timestamps guide:",
|
||||||
|
"whenToDesc": "Enter timestamps in seconds (e.g. 150), mm:ss format (e.g. 2:30), or hh:mm:ss format (e.g. 1:42:20). We'll automatically convert the format for you.",
|
||||||
|
"guide": {
|
||||||
|
"startLabel": "Start:",
|
||||||
|
"startDesc": "Required - when credits begin rolling",
|
||||||
|
"endLabel": "End:",
|
||||||
|
"endDesc": "Optional - empty if extends to end",
|
||||||
|
"durationLabel": "Duration:",
|
||||||
|
"durationDesc": "Min 5s if end provided",
|
||||||
|
"excludeLabel": "Exclude:",
|
||||||
|
"excludeDesc": "Post-credits scenes"
|
||||||
|
},
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"submit": "Submit",
|
||||||
|
"submitting": "Submitting...",
|
||||||
|
"error": {
|
||||||
|
"tidbKey": "TIDB API key is not set",
|
||||||
|
"mediaInfo": "Media information is not available",
|
||||||
|
"endTime": "End time is required",
|
||||||
|
"startTime": "Start time is required",
|
||||||
|
"submission": "Error submitting timestamps",
|
||||||
|
"segment": "No segment selected"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Submission Successful! Thanks!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
|
|
@ -1203,6 +1256,10 @@
|
||||||
"title": "Proxy TMDB",
|
"title": "Proxy TMDB",
|
||||||
"description": "Only needed if you can't access TheMovieDB directly, such as if your ISP blocks it. It is recomended to disable the Discover section to improve performance with this."
|
"description": "Only needed if you can't access TheMovieDB directly, such as if your ISP blocks it. It is recomended to disable the Discover section to improve performance with this."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tidb": {
|
||||||
|
"description": "Contribute to TheIntroDB by leaving feedback on intro, recap, and credits segments. <0>Learn more.</0>",
|
||||||
|
"tokenLabel": "API Key"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preferences": {
|
"preferences": {
|
||||||
|
|
|
||||||
354
src/components/player/TIDBSubmissionForm.tsx
Normal file
354
src/components/player/TIDBSubmissionForm.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/buttons/Button";
|
||||||
|
import { Dropdown } from "@/components/form/Dropdown";
|
||||||
|
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
|
||||||
|
import { SegmentData } from "@/components/player/hooks/useSkipTime";
|
||||||
|
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||||
|
import { Heading3, Paragraph } from "@/components/utils/Text";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
|
import { submitIntro } from "@/utils/tidb";
|
||||||
|
|
||||||
|
type SegmentType = "intro" | "recap" | "credits";
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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)$/);
|
||||||
|
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 hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's in mm:ss format
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
const parsed = parseFloat(timeStr);
|
||||||
|
if (
|
||||||
|
Number.isNaN(parsed) ||
|
||||||
|
!Number.isFinite(parsed) ||
|
||||||
|
parsed < 0 ||
|
||||||
|
parsed > 20000000
|
||||||
|
) {
|
||||||
|
return NaN; // Invalid input
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionFormProps {
|
||||||
|
segment: SegmentData;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionForm({
|
||||||
|
segment,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
}: SubmissionFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const meta = usePlayerStore((s) => s.meta);
|
||||||
|
const tidbKey = usePreferencesStore((s) => s.tidbKey);
|
||||||
|
const submissionModal = useModal("tidb-submission");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<{
|
||||||
|
segment: SegmentType;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}>({
|
||||||
|
segment: segment.type as SegmentType,
|
||||||
|
start: "",
|
||||||
|
end: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-fill the form with current segment data
|
||||||
|
useEffect(() => {
|
||||||
|
if (segment) {
|
||||||
|
setFormData({
|
||||||
|
segment: segment.type as SegmentType,
|
||||||
|
start: segment.start_ms ? (segment.start_ms / 1000).toString() : "",
|
||||||
|
end: segment.end_ms ? (segment.end_ms / 1000).toString() : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [segment]);
|
||||||
|
|
||||||
|
// Show modal when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
submissionModal.show();
|
||||||
|
}, [submissionModal]);
|
||||||
|
|
||||||
|
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"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tidbKey) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(t("player.skipTime.feedback.modal.error.tidbKey"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!meta) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(t("player.skipTime.feedback.modal.error.mediaInfo"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const startSeconds = parseTimeToSeconds(formData.start);
|
||||||
|
const endSeconds = parseTimeToSeconds(formData.end);
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (formData.segment === "intro" || formData.segment === "recap") {
|
||||||
|
if (endSeconds === null || Number.isNaN(endSeconds)) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(t("player.skipTime.feedback.modal.error.endTime"));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (formData.segment === "credits") {
|
||||||
|
if (startSeconds === null || Number.isNaN(startSeconds)) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(t("player.skipTime.feedback.modal.error.startTime"));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare submission data
|
||||||
|
const submissionData: any = {
|
||||||
|
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") {
|
||||||
|
submissionData.start_sec = startSeconds!;
|
||||||
|
submissionData.end_sec = endSeconds !== null ? endSeconds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitIntro(submissionData, tidbKey);
|
||||||
|
|
||||||
|
// Success
|
||||||
|
submissionModal.hide();
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting:", error);
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(
|
||||||
|
`${t("player.skipTime.feedback.modal.error.submission")}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal id={submissionModal.id}>
|
||||||
|
<ModalCard className="!max-w-4xl 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">
|
||||||
|
{t("player.skipTime.feedback.modal.description")}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
{/* Section: Segment timestamps */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="segment"
|
||||||
|
className="block text-sm font-medium text-white mb-1"
|
||||||
|
>
|
||||||
|
{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 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-buttons-purple hover:bg-buttons-purpleHover disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded font-medium transition-colors pointer-events-auto"
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t("player.skipTime.feedback.modal.submitting")
|
||||||
|
: t("player.skipTime.feedback.modal.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</ModalCard>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,7 @@ function SkipSegmentButton(props: {
|
||||||
segments: SegmentData[];
|
segments: SegmentData[];
|
||||||
inControl: boolean;
|
inControl: boolean;
|
||||||
onChangeMeta?: (meta: PlayerMeta) => void;
|
onChangeMeta?: (meta: PlayerMeta) => void;
|
||||||
|
onSkipTriggered?: (segment: SegmentData, skipTime: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const time = usePlayerStore((s) => s.progress.time);
|
const time = usePlayerStore((s) => s.progress.time);
|
||||||
|
|
@ -134,10 +135,15 @@ function SkipSegmentButton(props: {
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify parent that skip was triggered
|
||||||
|
if (props.onSkipTriggered) {
|
||||||
|
props.onSkipTriggered(segment, targetTime);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Skip ${segment.type} button used: ${skipDuration}s total`);
|
console.log(`Skip ${segment.type} button used: ${skipDuration}s total`);
|
||||||
},
|
},
|
||||||
[display, time, _duration, addSkipEvent, meta],
|
[display, time, _duration, addSkipEvent, meta, props],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end
|
// Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end
|
||||||
|
|
|
||||||
35
src/components/player/atoms/TIDBSubmissionSuccessPopout.tsx
Normal file
35
src/components/player/atoms/TIDBSubmissionSuccessPopout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { Flare } from "@/components/utils/Flare";
|
||||||
|
import { Transition } from "@/components/utils/Transition";
|
||||||
|
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||||
|
|
||||||
|
export function TIDBSubmissionSuccessPopout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentOverlay = useOverlayStack((s) => s.currentOverlay);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
animation="slide-down"
|
||||||
|
show={currentOverlay === "tidb-submission-success"}
|
||||||
|
className="absolute inset-x-0 top-4 flex justify-center pointer-events-none"
|
||||||
|
>
|
||||||
|
<Flare.Base className="hover:flare-enabled pointer-events-auto bg-video-context-background pl-4 pr-6 py-3 group w-80 h-full rounded-lg transition-colors text-video-context-type-main">
|
||||||
|
<Flare.Light
|
||||||
|
enabled
|
||||||
|
flareSize={200}
|
||||||
|
cssColorVar="--colors-video-context-light"
|
||||||
|
backgroundClass="bg-video-context-background duration-100"
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<Flare.Child className="flex items-center gap-3 pointer-events-auto relative transition-transform">
|
||||||
|
<Icon className="text-green-500" icon={Icons.CHECKMARK} />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("player.skipTime.feedback.modal.success.title")}
|
||||||
|
</span>
|
||||||
|
</Flare.Child>
|
||||||
|
</Flare.Base>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/player/atoms/ThumbsFeedback.tsx
Normal file
164
src/components/player/atoms/ThumbsFeedback.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { SegmentData } from "@/components/player/hooks/useSkipTime";
|
||||||
|
import { SubmissionForm } from "@/components/player/TIDBSubmissionForm";
|
||||||
|
import { Transition } from "@/components/utils/Transition";
|
||||||
|
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
|
|
||||||
|
interface ThumbsFeedbackProps {
|
||||||
|
controlsShowing: boolean;
|
||||||
|
feedbackData?: {
|
||||||
|
segment: SegmentData;
|
||||||
|
skipTime: number;
|
||||||
|
} | null;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThumbsFeedback({
|
||||||
|
controlsShowing,
|
||||||
|
feedbackData,
|
||||||
|
onAction,
|
||||||
|
}: ThumbsFeedbackProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const time = usePlayerStore((s) => s.progress.time);
|
||||||
|
const tidbKey = usePreferencesStore((s) => s.tidbKey);
|
||||||
|
|
||||||
|
// State for feedback
|
||||||
|
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
|
||||||
|
const feedbackTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (feedbackTimeoutRef.current) {
|
||||||
|
clearTimeout(feedbackTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle feedback data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (feedbackData) {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (feedbackTimeoutRef.current) {
|
||||||
|
clearTimeout(feedbackTimeoutRef.current);
|
||||||
|
feedbackTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide feedback after 5 seconds
|
||||||
|
feedbackTimeoutRef.current = setTimeout(() => {
|
||||||
|
onAction?.();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}, [feedbackData, onAction]);
|
||||||
|
|
||||||
|
const handleThumbsUp = useCallback(() => {
|
||||||
|
if (feedbackTimeoutRef.current) {
|
||||||
|
clearTimeout(feedbackTimeoutRef.current);
|
||||||
|
feedbackTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
onAction?.();
|
||||||
|
}, [onAction]);
|
||||||
|
|
||||||
|
const handleThumbsDown = useCallback(() => {
|
||||||
|
if (feedbackTimeoutRef.current) {
|
||||||
|
clearTimeout(feedbackTimeoutRef.current);
|
||||||
|
feedbackTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setShowSubmissionModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay);
|
||||||
|
|
||||||
|
const handleSubmissionSuccess = useCallback(() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
setCurrentOverlay("tidb-submission-success");
|
||||||
|
onAction?.();
|
||||||
|
}, [onAction, setCurrentOverlay]);
|
||||||
|
|
||||||
|
const handleSubmissionCancel = useCallback(() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
onAction?.();
|
||||||
|
}, [onAction]);
|
||||||
|
|
||||||
|
// Don't show thumbs feedback if TIDB API key is not set
|
||||||
|
if (!tidbKey || tidbKey.trim() === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show feedback if we're within the 5-second window after skip
|
||||||
|
const shouldShowFeedback = !!(
|
||||||
|
feedbackData &&
|
||||||
|
time >= feedbackData.skipTime + 0.1 &&
|
||||||
|
time <= feedbackData.skipTime + 5
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldShowFeedback && !showSubmissionModal) return null;
|
||||||
|
|
||||||
|
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
|
||||||
|
if (!controlsShowing) {
|
||||||
|
bottom = "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0 pointer-events-none">
|
||||||
|
<Transition
|
||||||
|
animation="fade"
|
||||||
|
show={shouldShowFeedback}
|
||||||
|
className="absolute right-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex flex-col items-end space-y-2",
|
||||||
|
bottom,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-white whitespace-nowrap">
|
||||||
|
{t("player.skipTime.feedback.title")}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 pointer-events-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleThumbsUp}
|
||||||
|
className={classNames(
|
||||||
|
"h-10 w-10 rounded-full flex items-center justify-center pointer-events-auto",
|
||||||
|
"bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText",
|
||||||
|
"scale-95 hover:scale-100 transition-all duration-200",
|
||||||
|
)}
|
||||||
|
aria-label="Thumbs up"
|
||||||
|
>
|
||||||
|
<Icon className="text-xl" icon={Icons.THUMBS_UP} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleThumbsDown}
|
||||||
|
className={classNames(
|
||||||
|
"h-10 w-10 rounded-full flex items-center justify-center pointer-events-auto",
|
||||||
|
"bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText",
|
||||||
|
"scale-95 hover:scale-100 transition-all duration-200",
|
||||||
|
)}
|
||||||
|
aria-label="Thumbs down"
|
||||||
|
>
|
||||||
|
<Icon className="text-xl" icon={Icons.THUMBS_DOWN} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSubmissionModal && feedbackData && (
|
||||||
|
<SubmissionForm
|
||||||
|
segment={feedbackData.segment}
|
||||||
|
onSuccess={handleSubmissionSuccess}
|
||||||
|
onCancel={handleSubmissionCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,3 +19,4 @@ export * from "./Chromecast";
|
||||||
export * from "./CastingNotification";
|
export * from "./CastingNotification";
|
||||||
export * from "./Captions";
|
export * from "./Captions";
|
||||||
export * from "./SpeedChangedPopout";
|
export * from "./SpeedChangedPopout";
|
||||||
|
export * from "./TIDBSubmissionSuccessPopout";
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export function useSettingsState(
|
||||||
febboxKey: string | null,
|
febboxKey: string | null,
|
||||||
debridToken: string | null,
|
debridToken: string | null,
|
||||||
debridService: string,
|
debridService: string,
|
||||||
|
tidbKey: string | null,
|
||||||
profile:
|
profile:
|
||||||
| {
|
| {
|
||||||
colorA: string;
|
colorA: string;
|
||||||
|
|
@ -98,6 +99,8 @@ export function useSettingsState(
|
||||||
_resetdebridService,
|
_resetdebridService,
|
||||||
debridServiceChanged,
|
debridServiceChanged,
|
||||||
] = useDerived(debridService);
|
] = useDerived(debridService);
|
||||||
|
const [tidbKeyState, setTIDBKey, resetTIDBKey, tidbKeyChanged] =
|
||||||
|
useDerived(tidbKey);
|
||||||
const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme);
|
const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme);
|
||||||
const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme);
|
const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme);
|
||||||
const resetPreviewTheme = useCallback(
|
const resetPreviewTheme = useCallback(
|
||||||
|
|
@ -272,6 +275,7 @@ export function useSettingsState(
|
||||||
resetBackendUrl();
|
resetBackendUrl();
|
||||||
resetFebboxKey();
|
resetFebboxKey();
|
||||||
resetdebridToken();
|
resetdebridToken();
|
||||||
|
resetTIDBKey();
|
||||||
resetDeviceName();
|
resetDeviceName();
|
||||||
resetNickname();
|
resetNickname();
|
||||||
resetProfile();
|
resetProfile();
|
||||||
|
|
@ -312,6 +316,7 @@ export function useSettingsState(
|
||||||
febboxKeyChanged ||
|
febboxKeyChanged ||
|
||||||
debridTokenChanged ||
|
debridTokenChanged ||
|
||||||
debridServiceChanged ||
|
debridServiceChanged ||
|
||||||
|
tidbKeyChanged ||
|
||||||
profileChanged ||
|
profileChanged ||
|
||||||
enableThumbnailsChanged ||
|
enableThumbnailsChanged ||
|
||||||
enableAutoplayChanged ||
|
enableAutoplayChanged ||
|
||||||
|
|
@ -391,6 +396,11 @@ export function useSettingsState(
|
||||||
set: setdebridService,
|
set: setdebridService,
|
||||||
changed: debridServiceChanged,
|
changed: debridServiceChanged,
|
||||||
},
|
},
|
||||||
|
tidbKey: {
|
||||||
|
state: tidbKeyState,
|
||||||
|
set: setTIDBKey,
|
||||||
|
changed: tidbKeyChanged,
|
||||||
|
},
|
||||||
profile: {
|
profile: {
|
||||||
state: profileState,
|
state: profileState,
|
||||||
set: setProfileState,
|
set: setProfileState,
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,9 @@ export function SettingsPage() {
|
||||||
const debridService = usePreferencesStore((s) => s.debridService);
|
const debridService = usePreferencesStore((s) => s.debridService);
|
||||||
const setdebridService = usePreferencesStore((s) => s.setdebridService);
|
const setdebridService = usePreferencesStore((s) => s.setdebridService);
|
||||||
|
|
||||||
|
const tidbKey = usePreferencesStore((s) => s.tidbKey);
|
||||||
|
const setTIDBKey = usePreferencesStore((s) => s.setTIDBKey);
|
||||||
|
|
||||||
const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
|
const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
|
||||||
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
|
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
|
||||||
|
|
||||||
|
|
@ -551,6 +554,7 @@ export function SettingsPage() {
|
||||||
febboxKey,
|
febboxKey,
|
||||||
debridToken,
|
debridToken,
|
||||||
debridService,
|
debridService,
|
||||||
|
tidbKey,
|
||||||
account ? account.profile : undefined,
|
account ? account.profile : undefined,
|
||||||
enableThumbnails,
|
enableThumbnails,
|
||||||
enableAutoplay,
|
enableAutoplay,
|
||||||
|
|
@ -718,6 +722,7 @@ export function SettingsPage() {
|
||||||
setFebboxKey(state.febboxKey.state);
|
setFebboxKey(state.febboxKey.state);
|
||||||
setdebridToken(state.debridToken.state);
|
setdebridToken(state.debridToken.state);
|
||||||
setdebridService(state.debridService.state);
|
setdebridService(state.debridService.state);
|
||||||
|
setTIDBKey(state.tidbKey.state);
|
||||||
setProxyTmdb(state.proxyTmdb.state);
|
setProxyTmdb(state.proxyTmdb.state);
|
||||||
setEnableCarouselView(state.enableCarouselView.state);
|
setEnableCarouselView(state.enableCarouselView.state);
|
||||||
setEnableMinimalCards(state.enableMinimalCards.state);
|
setEnableMinimalCards(state.enableMinimalCards.state);
|
||||||
|
|
@ -761,6 +766,7 @@ export function SettingsPage() {
|
||||||
setFebboxKey,
|
setFebboxKey,
|
||||||
setdebridToken,
|
setdebridToken,
|
||||||
setdebridService,
|
setdebridService,
|
||||||
|
setTIDBKey,
|
||||||
setEnableAutoplay,
|
setEnableAutoplay,
|
||||||
setEnableSkipCredits,
|
setEnableSkipCredits,
|
||||||
setEnableDiscover,
|
setEnableDiscover,
|
||||||
|
|
@ -929,6 +935,8 @@ export function SettingsPage() {
|
||||||
setdebridToken={state.debridToken.set}
|
setdebridToken={state.debridToken.set}
|
||||||
debridService={state.debridService.state}
|
debridService={state.debridService.state}
|
||||||
setdebridService={state.debridService.set}
|
setdebridService={state.debridService.set}
|
||||||
|
tidbKey={state.tidbKey.state}
|
||||||
|
setTIDBKey={state.tidbKey.set}
|
||||||
proxyTmdb={state.proxyTmdb.state}
|
proxyTmdb={state.proxyTmdb.state}
|
||||||
setProxyTmdb={state.proxyTmdb.set}
|
setProxyTmdb={state.proxyTmdb.set}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { ReactNode, useRef, useState } from "react";
|
import { ReactNode, useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
import { BrandPill } from "@/components/layout/BrandPill";
|
||||||
import { Player } from "@/components/player";
|
import { Player } from "@/components/player";
|
||||||
import { SkipSegmentButton } from "@/components/player/atoms/SkipSegmentButton";
|
import { SkipSegmentButton } from "@/components/player/atoms/SkipSegmentButton";
|
||||||
|
import { ThumbsFeedback } from "@/components/player/atoms/ThumbsFeedback";
|
||||||
import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay";
|
import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay";
|
||||||
import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus";
|
import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus";
|
||||||
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
||||||
import { useSkipTime } from "@/components/player/hooks/useSkipTime";
|
import {
|
||||||
|
SegmentData,
|
||||||
|
useSkipTime,
|
||||||
|
} from "@/components/player/hooks/useSkipTime";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
|
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
@ -74,8 +78,25 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// State for thumbs feedback
|
||||||
|
const [thumbsFeedbackData, setThumbsFeedbackData] = useState<{
|
||||||
|
segment: SegmentData;
|
||||||
|
skipTime: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const segments = useSkipTime();
|
const segments = useSkipTime();
|
||||||
|
|
||||||
|
const handleSkipTriggered = useCallback(
|
||||||
|
(segment: SegmentData, skipTime: number) => {
|
||||||
|
setThumbsFeedbackData({ segment, skipTime });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThumbsFeedback = useCallback(() => {
|
||||||
|
setThumbsFeedbackData(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Player.Container onLoad={props.onLoad} showingControls={showTargets}>
|
<Player.Container onLoad={props.onLoad} showingControls={showTargets}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
@ -238,6 +259,7 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
<Player.VolumeChangedPopout />
|
<Player.VolumeChangedPopout />
|
||||||
<Player.SubtitleDelayPopout />
|
<Player.SubtitleDelayPopout />
|
||||||
<Player.SpeedChangedPopout />
|
<Player.SpeedChangedPopout />
|
||||||
|
<Player.TIDBSubmissionSuccessPopout />
|
||||||
<UnreleasedEpisodeOverlay />
|
<UnreleasedEpisodeOverlay />
|
||||||
|
|
||||||
<Player.NextEpisodeButton
|
<Player.NextEpisodeButton
|
||||||
|
|
@ -251,6 +273,13 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
segments={segments}
|
segments={segments}
|
||||||
inControl={inControl}
|
inControl={inControl}
|
||||||
onChangeMeta={props.onMetaChange}
|
onChangeMeta={props.onMetaChange}
|
||||||
|
onSkipTriggered={handleSkipTriggered}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThumbsFeedback
|
||||||
|
controlsShowing={showTargets}
|
||||||
|
feedbackData={thumbsFeedbackData}
|
||||||
|
onAction={handleThumbsFeedback}
|
||||||
/>
|
/>
|
||||||
</Player.Container>
|
</Player.Container>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
@ -62,6 +63,11 @@ interface DebridProps {
|
||||||
mode?: "onboarding" | "settings";
|
mode?: "onboarding" | "settings";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TIDBKeyProps {
|
||||||
|
tidbKey: string | null;
|
||||||
|
setTIDBKey: (value: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function ProxyEdit({
|
function ProxyEdit({
|
||||||
proxyUrls,
|
proxyUrls,
|
||||||
setProxyUrls,
|
setProxyUrls,
|
||||||
|
|
@ -725,8 +731,53 @@ export function DebridEdit({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TIDBEdit({ tidbKey, setTIDBKey }: TIDBKeyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const preferences = usePreferencesStore();
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
// Enable TIDB key when component loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initializedRef.current && tidbKey === null && preferences.tidbKey) {
|
||||||
|
initializedRef.current = true;
|
||||||
|
setTIDBKey(preferences.tidbKey);
|
||||||
|
}
|
||||||
|
}, [tidbKey, preferences.tidbKey, setTIDBKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard>
|
||||||
|
<div className="my-3">
|
||||||
|
<p className="text-white font-bold mb-3">TheIntroDB</p>
|
||||||
|
<p className="max-w-[40rem] font-medium mb-6">
|
||||||
|
<Trans i18nKey="settings.connections.tidb.description">
|
||||||
|
<MwLink to="https://theintrodb.org/" />
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<p className="text-white font-bold mb-3">
|
||||||
|
{t("settings.connections.tidb.tokenLabel")}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<AuthInputBox
|
||||||
|
onChange={(newToken) => {
|
||||||
|
setTIDBKey(newToken);
|
||||||
|
}}
|
||||||
|
value={tidbKey ?? ""}
|
||||||
|
placeholder="theintrodb:user..."
|
||||||
|
passwordToggleable
|
||||||
|
className="flex-grow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectionsPart(
|
export function ConnectionsPart(
|
||||||
props: BackendEditProps & ProxyEditProps & FebboxKeyProps & DebridProps,
|
props: BackendEditProps &
|
||||||
|
ProxyEditProps &
|
||||||
|
FebboxKeyProps &
|
||||||
|
DebridProps &
|
||||||
|
TIDBKeyProps,
|
||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
|
|
@ -756,6 +807,7 @@ export function ConnectionsPart(
|
||||||
setdebridService={props.setdebridService}
|
setdebridService={props.setdebridService}
|
||||||
mode="settings"
|
mode="settings"
|
||||||
/>
|
/>
|
||||||
|
<TIDBEdit tidbKey={props.tidbKey} setTIDBKey={props.setTIDBKey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ import { useLocation } from "react-router-dom";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { immer } from "zustand/middleware/immer";
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
type OverlayType = "volume" | "subtitle" | "speed" | null;
|
type OverlayType =
|
||||||
|
| "volume"
|
||||||
|
| "subtitle"
|
||||||
|
| "speed"
|
||||||
|
| "tidb-submission-success"
|
||||||
|
| null;
|
||||||
|
|
||||||
interface ModalData {
|
interface ModalData {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface PreferencesStore {
|
||||||
febboxUseMp4: boolean;
|
febboxUseMp4: boolean;
|
||||||
debridToken: string | null;
|
debridToken: string | null;
|
||||||
debridService: string;
|
debridService: string;
|
||||||
|
tidbKey: string | null;
|
||||||
enableLowPerformanceMode: boolean;
|
enableLowPerformanceMode: boolean;
|
||||||
enableNativeSubtitles: boolean;
|
enableNativeSubtitles: boolean;
|
||||||
enableHoldToBoost: boolean;
|
enableHoldToBoost: boolean;
|
||||||
|
|
@ -59,6 +60,7 @@ export interface PreferencesStore {
|
||||||
setFebboxUseMp4(v: boolean): void;
|
setFebboxUseMp4(v: boolean): void;
|
||||||
setdebridToken(v: string | null): void;
|
setdebridToken(v: string | null): void;
|
||||||
setdebridService(v: string): void;
|
setdebridService(v: string): void;
|
||||||
|
setTIDBKey(v: string | null): void;
|
||||||
setEnableLowPerformanceMode(v: boolean): void;
|
setEnableLowPerformanceMode(v: boolean): void;
|
||||||
setEnableNativeSubtitles(v: boolean): void;
|
setEnableNativeSubtitles(v: boolean): void;
|
||||||
setEnableHoldToBoost(v: boolean): void;
|
setEnableHoldToBoost(v: boolean): void;
|
||||||
|
|
@ -93,6 +95,7 @@ export const usePreferencesStore = create(
|
||||||
febboxUseMp4: false,
|
febboxUseMp4: false,
|
||||||
debridToken: null,
|
debridToken: null,
|
||||||
debridService: "realdebrid",
|
debridService: "realdebrid",
|
||||||
|
tidbKey: null,
|
||||||
enableLowPerformanceMode: false,
|
enableLowPerformanceMode: false,
|
||||||
enableNativeSubtitles: false,
|
enableNativeSubtitles: false,
|
||||||
enableHoldToBoost: true,
|
enableHoldToBoost: true,
|
||||||
|
|
@ -206,6 +209,11 @@ export const usePreferencesStore = create(
|
||||||
s.debridService = v;
|
s.debridService = v;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setTIDBKey(v) {
|
||||||
|
set((s) => {
|
||||||
|
s.tidbKey = v;
|
||||||
|
});
|
||||||
|
},
|
||||||
setEnableLowPerformanceMode(v) {
|
setEnableLowPerformanceMode(v) {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.enableLowPerformanceMode = v;
|
s.enableLowPerformanceMode = v;
|
||||||
|
|
|
||||||
83
src/utils/tidb.ts
Normal file
83
src/utils/tidb.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
export type SegmentType = "intro" | "recap" | "credits";
|
||||||
|
|
||||||
|
export interface SubmissionRequest {
|
||||||
|
tmdb_id: number;
|
||||||
|
type: "movie" | "tv";
|
||||||
|
segment: SegmentType;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
start_sec?: number | null;
|
||||||
|
end_sec?: number | null;
|
||||||
|
start_ms?: number | null;
|
||||||
|
end_ms?: number | null;
|
||||||
|
tvdb_id?: number;
|
||||||
|
imdb_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionResponse {
|
||||||
|
ok: boolean;
|
||||||
|
submission?: {
|
||||||
|
id: string;
|
||||||
|
tmdbId: number;
|
||||||
|
type: "movie" | "tv";
|
||||||
|
segment: SegmentType;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
startMs?: number | null;
|
||||||
|
endMs?: number | null;
|
||||||
|
status: "pending" | "accepted" | "rejected";
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TIDBError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public statusCode?: number,
|
||||||
|
public details?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TIDBError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit segment timestamps to TheIntroDB API
|
||||||
|
*/
|
||||||
|
export async function submitIntro(
|
||||||
|
submission: SubmissionRequest,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<SubmissionResponse> {
|
||||||
|
const response = await fetch("https://api.theintrodb.org/v1/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}`;
|
||||||
|
let details: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData: ErrorResponse = await response.json();
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
details = errorData.details;
|
||||||
|
} catch {
|
||||||
|
// If we can't parse the error response, use the status text
|
||||||
|
errorMessage = response.statusText || errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TIDBError(errorMessage, response.status, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SubmissionResponse = await response.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue