From f23ac179cd4f4fd7319df0f80a5e9edc29aace6c Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:59:31 -0700 Subject: [PATCH] add Feedback buttons to submit to TIDB --- src/assets/locales/en.json | 59 ++- src/components/player/TIDBSubmissionForm.tsx | 354 ++++++++++++++++++ .../player/atoms/SkipSegmentButton.tsx | 8 +- .../atoms/TIDBSubmissionSuccessPopout.tsx | 35 ++ .../player/atoms/ThumbsFeedback.tsx | 164 ++++++++ src/components/player/atoms/index.ts | 1 + src/hooks/useSettingsState.ts | 10 + src/pages/Settings.tsx | 8 + src/pages/parts/player/PlayerPart.tsx | 33 +- src/pages/parts/settings/ConnectionsPart.tsx | 54 ++- src/stores/interface/overlayStack.ts | 7 +- src/stores/preferences/index.tsx | 8 + src/utils/tidb.ts | 83 ++++ 13 files changed, 818 insertions(+), 6 deletions(-) create mode 100644 src/components/player/TIDBSubmissionForm.tsx create mode 100644 src/components/player/atoms/TIDBSubmissionSuccessPopout.tsx create mode 100644 src/components/player/atoms/ThumbsFeedback.tsx create mode 100644 src/utils/tidb.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 4e210074..9e43dfb5 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -977,7 +977,60 @@ "skipTime": { "intro": "Skip Intro", "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": { @@ -1203,6 +1256,10 @@ "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." } + }, + "tidb": { + "description": "Contribute to TheIntroDB by leaving feedback on intro, recap, and credits segments. <0>Learn more.0>", + "tokenLabel": "API Key" } }, "preferences": { diff --git a/src/components/player/TIDBSubmissionForm.tsx b/src/components/player/TIDBSubmissionForm.tsx new file mode 100644 index 00000000..e0913ec5 --- /dev/null +++ b/src/components/player/TIDBSubmissionForm.tsx @@ -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 ( + + + + {t("player.skipTime.feedback.modal.title")} + + + {t("player.skipTime.feedback.modal.description")} + + + + {/* Section: Segment timestamps */} + + + {t("player.skipTime.feedback.modal.segmentType")} + * + + + setFormData({ ...formData, segment: item.id as SegmentType }) + } + /> + + + + + + + {t("player.skipTime.feedback.modal.startTimeLabel")} + {formData.segment === "credits" ? ( + * + ) : null} + + + setFormData({ ...formData, start: value }) + } + placeholder={t( + `player.skipTime.feedback.modal.placeholders.start.${formData.segment}`, + )} + /> + + + + {t("player.skipTime.feedback.modal.endTimeLabel")} + {formData.segment === "intro" || + formData.segment === "recap" ? ( + * + ) : null} + + setFormData({ ...formData, end: value })} + placeholder={t( + `player.skipTime.feedback.modal.placeholders.end.${formData.segment}`, + )} + /> + + + + {/* Timing Guidance Section */} + + + {t("player.skipTime.feedback.modal.whenToTitle")} + + + + {t("player.skipTime.feedback.modal.whenToDesc")} + + + + + + {t("player.skipTime.feedback.modal.guide.startLabel")} + + + {t("player.skipTime.feedback.modal.guide.startDesc")} + + + + + {t("player.skipTime.feedback.modal.guide.endLabel")} + + + {t("player.skipTime.feedback.modal.guide.endDesc")} + + + + + {t("player.skipTime.feedback.modal.guide.durationLabel")} + + + {t("player.skipTime.feedback.modal.guide.durationDesc")} + + + + + {t("player.skipTime.feedback.modal.guide.excludeLabel")} + + + {t("player.skipTime.feedback.modal.guide.excludeDesc")} + + + + + + + { + submissionModal.hide(); + if (onCancel) onCancel(); + }} + disabled={isSubmitting} + > + {t("player.skipTime.feedback.modal.cancel")} + + + {isSubmitting + ? t("player.skipTime.feedback.modal.submitting") + : t("player.skipTime.feedback.modal.submit")} + + + + + + + ); +} diff --git a/src/components/player/atoms/SkipSegmentButton.tsx b/src/components/player/atoms/SkipSegmentButton.tsx index 4f919e61..a8e9cfe0 100644 --- a/src/components/player/atoms/SkipSegmentButton.tsx +++ b/src/components/player/atoms/SkipSegmentButton.tsx @@ -76,6 +76,7 @@ function SkipSegmentButton(props: { segments: SegmentData[]; inControl: boolean; onChangeMeta?: (meta: PlayerMeta) => void; + onSkipTriggered?: (segment: SegmentData, skipTime: number) => void; }) { const { t } = useTranslation(); const time = usePlayerStore((s) => s.progress.time); @@ -134,10 +135,15 @@ function SkipSegmentButton(props: { : undefined, }); + // Notify parent that skip was triggered + if (props.onSkipTriggered) { + props.onSkipTriggered(segment, targetTime); + } + // eslint-disable-next-line no-console 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 diff --git a/src/components/player/atoms/TIDBSubmissionSuccessPopout.tsx b/src/components/player/atoms/TIDBSubmissionSuccessPopout.tsx new file mode 100644 index 00000000..83f241dd --- /dev/null +++ b/src/components/player/atoms/TIDBSubmissionSuccessPopout.tsx @@ -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 ( + + + + + + + {t("player.skipTime.feedback.modal.success.title")} + + + + + ); +} diff --git a/src/components/player/atoms/ThumbsFeedback.tsx b/src/components/player/atoms/ThumbsFeedback.tsx new file mode 100644 index 00000000..868856bc --- /dev/null +++ b/src/components/player/atoms/ThumbsFeedback.tsx @@ -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(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 ( + <> + + + + + {t("player.skipTime.feedback.title")} + + + + + + + + + + + + + + {showSubmissionModal && feedbackData && ( + + )} + > + ); +} diff --git a/src/components/player/atoms/index.ts b/src/components/player/atoms/index.ts index 7d6fbb8b..1a20b56d 100644 --- a/src/components/player/atoms/index.ts +++ b/src/components/player/atoms/index.ts @@ -19,3 +19,4 @@ export * from "./Chromecast"; export * from "./CastingNotification"; export * from "./Captions"; export * from "./SpeedChangedPopout"; +export * from "./TIDBSubmissionSuccessPopout"; diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index b704be17..a88609cf 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -48,6 +48,7 @@ export function useSettingsState( febboxKey: string | null, debridToken: string | null, debridService: string, + tidbKey: string | null, profile: | { colorA: string; @@ -98,6 +99,8 @@ export function useSettingsState( _resetdebridService, debridServiceChanged, ] = useDerived(debridService); + const [tidbKeyState, setTIDBKey, resetTIDBKey, tidbKeyChanged] = + useDerived(tidbKey); const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme); const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme); const resetPreviewTheme = useCallback( @@ -272,6 +275,7 @@ export function useSettingsState( resetBackendUrl(); resetFebboxKey(); resetdebridToken(); + resetTIDBKey(); resetDeviceName(); resetNickname(); resetProfile(); @@ -312,6 +316,7 @@ export function useSettingsState( febboxKeyChanged || debridTokenChanged || debridServiceChanged || + tidbKeyChanged || profileChanged || enableThumbnailsChanged || enableAutoplayChanged || @@ -391,6 +396,11 @@ export function useSettingsState( set: setdebridService, changed: debridServiceChanged, }, + tidbKey: { + state: tidbKeyState, + set: setTIDBKey, + changed: tidbKeyChanged, + }, profile: { state: profileState, set: setProfileState, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 5a7ac6f8..9caa891e 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -385,6 +385,9 @@ export function SettingsPage() { const debridService = usePreferencesStore((s) => s.debridService); 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 setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails); @@ -551,6 +554,7 @@ export function SettingsPage() { febboxKey, debridToken, debridService, + tidbKey, account ? account.profile : undefined, enableThumbnails, enableAutoplay, @@ -718,6 +722,7 @@ export function SettingsPage() { setFebboxKey(state.febboxKey.state); setdebridToken(state.debridToken.state); setdebridService(state.debridService.state); + setTIDBKey(state.tidbKey.state); setProxyTmdb(state.proxyTmdb.state); setEnableCarouselView(state.enableCarouselView.state); setEnableMinimalCards(state.enableMinimalCards.state); @@ -761,6 +766,7 @@ export function SettingsPage() { setFebboxKey, setdebridToken, setdebridService, + setTIDBKey, setEnableAutoplay, setEnableSkipCredits, setEnableDiscover, @@ -929,6 +935,8 @@ export function SettingsPage() { setdebridToken={state.debridToken.set} debridService={state.debridService.state} setdebridService={state.debridService.set} + tidbKey={state.tidbKey.state} + setTIDBKey={state.tidbKey.set} proxyTmdb={state.proxyTmdb.state} setProxyTmdb={state.proxyTmdb.set} /> diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 2c1ec852..9c81e0ae 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -1,13 +1,17 @@ -import { ReactNode, useRef, useState } from "react"; +import { ReactNode, useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BrandPill } from "@/components/layout/BrandPill"; import { Player } from "@/components/player"; import { SkipSegmentButton } from "@/components/player/atoms/SkipSegmentButton"; +import { ThumbsFeedback } from "@/components/player/atoms/ThumbsFeedback"; import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay"; import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus"; 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 { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; @@ -74,8 +78,25 @@ export function PlayerPart(props: PlayerPartProps) { }, 1000); }; + // State for thumbs feedback + const [thumbsFeedbackData, setThumbsFeedbackData] = useState<{ + segment: SegmentData; + skipTime: number; + } | null>(null); + const segments = useSkipTime(); + const handleSkipTriggered = useCallback( + (segment: SegmentData, skipTime: number) => { + setThumbsFeedbackData({ segment, skipTime }); + }, + [], + ); + + const handleThumbsFeedback = useCallback(() => { + setThumbsFeedbackData(null); + }, []); + return ( {props.children} @@ -238,6 +259,7 @@ export function PlayerPart(props: PlayerPartProps) { + + + ); diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index f06e2a36..da187f09 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -3,6 +3,7 @@ import { SetStateAction, useCallback, useEffect, + useRef, useState, } from "react"; import { Trans, useTranslation } from "react-i18next"; @@ -62,6 +63,11 @@ interface DebridProps { mode?: "onboarding" | "settings"; } +interface TIDBKeyProps { + tidbKey: string | null; + setTIDBKey: (value: string | null) => void; +} + function ProxyEdit({ proxyUrls, setProxyUrls, @@ -725,8 +731,53 @@ export function DebridEdit({ 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 ( + + + TheIntroDB + + + + + + + {t("settings.connections.tidb.tokenLabel")} + + + { + setTIDBKey(newToken); + }} + value={tidbKey ?? ""} + placeholder="theintrodb:user..." + passwordToggleable + className="flex-grow" + /> + + + + ); +} + export function ConnectionsPart( - props: BackendEditProps & ProxyEditProps & FebboxKeyProps & DebridProps, + props: BackendEditProps & + ProxyEditProps & + FebboxKeyProps & + DebridProps & + TIDBKeyProps, ) { const { t } = useTranslation(); return ( @@ -756,6 +807,7 @@ export function ConnectionsPart( setdebridService={props.setdebridService} mode="settings" /> + ); diff --git a/src/stores/interface/overlayStack.ts b/src/stores/interface/overlayStack.ts index c84a9f90..1a07200c 100644 --- a/src/stores/interface/overlayStack.ts +++ b/src/stores/interface/overlayStack.ts @@ -3,7 +3,12 @@ import { useLocation } from "react-router-dom"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; -type OverlayType = "volume" | "subtitle" | "speed" | null; +type OverlayType = + | "volume" + | "subtitle" + | "speed" + | "tidb-submission-success" + | null; interface ModalData { id: number; diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index bd09c4cf..fb4d53c0 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -29,6 +29,7 @@ export interface PreferencesStore { febboxUseMp4: boolean; debridToken: string | null; debridService: string; + tidbKey: string | null; enableLowPerformanceMode: boolean; enableNativeSubtitles: boolean; enableHoldToBoost: boolean; @@ -59,6 +60,7 @@ export interface PreferencesStore { setFebboxUseMp4(v: boolean): void; setdebridToken(v: string | null): void; setdebridService(v: string): void; + setTIDBKey(v: string | null): void; setEnableLowPerformanceMode(v: boolean): void; setEnableNativeSubtitles(v: boolean): void; setEnableHoldToBoost(v: boolean): void; @@ -93,6 +95,7 @@ export const usePreferencesStore = create( febboxUseMp4: false, debridToken: null, debridService: "realdebrid", + tidbKey: null, enableLowPerformanceMode: false, enableNativeSubtitles: false, enableHoldToBoost: true, @@ -206,6 +209,11 @@ export const usePreferencesStore = create( s.debridService = v; }); }, + setTIDBKey(v) { + set((s) => { + s.tidbKey = v; + }); + }, setEnableLowPerformanceMode(v) { set((s) => { s.enableLowPerformanceMode = v; diff --git a/src/utils/tidb.ts b/src/utils/tidb.ts new file mode 100644 index 00000000..d483f469 --- /dev/null +++ b/src/utils/tidb.ts @@ -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 { + 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; +}
+ {t("player.skipTime.feedback.modal.whenToDesc")} +
+ {t("player.skipTime.feedback.modal.guide.startDesc")} +
+ {t("player.skipTime.feedback.modal.guide.endDesc")} +
+ {t("player.skipTime.feedback.modal.guide.durationDesc")} +
+ {t("player.skipTime.feedback.modal.guide.excludeDesc")} +
TheIntroDB
+ + + +
+ {t("settings.connections.tidb.tokenLabel")} +