pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add Feedback buttons to submit to TIDB

Pas f23ac179 9ab5bc4c

+818 -6
+58 -1
src/assets/locales/en.json
··· 977 977 "skipTime": { 978 978 "intro": "Skip Intro", 979 979 "recap": "Skip Recap", 980 - "credits": "Skip Credits" 980 + "credits": "Skip Credits", 981 + "feedback": { 982 + "title": "Was this skip correct?", 983 + "modal": { 984 + "title": "Submit Timestamps to TheIntroDB", 985 + "description": "Contribute to TheIntroDB by submitting accurate segment timestamps. All submissions are accepted by the community before being published.", 986 + "segmentType": "Segment Type", 987 + "types": { 988 + "intro": "Intro", 989 + "recap": "Recap", 990 + "credits": "Credits" 991 + }, 992 + "startTimeLabel": "Start (s)", 993 + "endTimeLabel": "End (s)", 994 + "placeholders": { 995 + "start": { 996 + "intro": "2:30 or 150 (leave empty for start at beginning)", 997 + "recap": "2:30 or 150 (leave empty for start at beginning)", 998 + "credits": "2:30 or 150 (required)" 999 + }, 1000 + "end": { 1001 + "intro": "3:30 or 210 (required)", 1002 + "recap": "3:30 or 210 (required)", 1003 + "credits": "3:30 or 210 (leave empty for end of media)" 1004 + } 1005 + }, 1006 + "whenToTitle": "Timestamps guide:", 1007 + "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.", 1008 + "guide": { 1009 + "startLabel": "Start:", 1010 + "startDesc": "Required - when credits begin rolling", 1011 + "endLabel": "End:", 1012 + "endDesc": "Optional - empty if extends to end", 1013 + "durationLabel": "Duration:", 1014 + "durationDesc": "Min 5s if end provided", 1015 + "excludeLabel": "Exclude:", 1016 + "excludeDesc": "Post-credits scenes" 1017 + }, 1018 + "cancel": "Cancel", 1019 + "submit": "Submit", 1020 + "submitting": "Submitting...", 1021 + "error": { 1022 + "tidbKey": "TIDB API key is not set", 1023 + "mediaInfo": "Media information is not available", 1024 + "endTime": "End time is required", 1025 + "startTime": "Start time is required", 1026 + "submission": "Error submitting timestamps", 1027 + "segment": "No segment selected" 1028 + }, 1029 + "success": { 1030 + "title": "Submission Successful! Thanks!" 1031 + } 1032 + } 1033 + } 981 1034 } 982 1035 }, 983 1036 "support": { ··· 1203 1256 "title": "Proxy TMDB", 1204 1257 "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." 1205 1258 } 1259 + }, 1260 + "tidb": { 1261 + "description": "Contribute to TheIntroDB by leaving feedback on intro, recap, and credits segments. <0>Learn more.</0>", 1262 + "tokenLabel": "API Key" 1206 1263 } 1207 1264 }, 1208 1265 "preferences": {
+354
src/components/player/TIDBSubmissionForm.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import { Button } from "@/components/buttons/Button"; 5 + import { Dropdown } from "@/components/form/Dropdown"; 6 + import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; 7 + import { SegmentData } from "@/components/player/hooks/useSkipTime"; 8 + import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; 9 + import { Heading3, Paragraph } from "@/components/utils/Text"; 10 + import { usePlayerStore } from "@/stores/player/store"; 11 + import { usePreferencesStore } from "@/stores/preferences"; 12 + import { submitIntro } from "@/utils/tidb"; 13 + 14 + type SegmentType = "intro" | "recap" | "credits"; 15 + 16 + // Helper function to parse time format (hh:mm:ss, mm:ss, or seconds) 17 + // Returns null if empty string, NaN if invalid, or number if valid 18 + function parseTimeToSeconds(timeStr: string): number | null { 19 + if (!timeStr.trim()) return null; 20 + 21 + // Check if it's in hh:mm:ss format 22 + const hhmmssMatch = timeStr.match(/^(\d{1,2}):([0-5]?\d):([0-5]?\d)$/); 23 + if (hhmmssMatch) { 24 + const hours = parseInt(hhmmssMatch[1], 10); 25 + const minutes = parseInt(hhmmssMatch[2], 10); 26 + const seconds = parseInt(hhmmssMatch[3], 10); 27 + 28 + // Validate reasonable bounds (max 99 hours, minutes/seconds 0-59) 29 + if (hours > 99 || minutes > 59 || seconds > 59) { 30 + return NaN; // Invalid format 31 + } 32 + 33 + return hours * 3600 + minutes * 60 + seconds; 34 + } 35 + 36 + // Check if it's in mm:ss format 37 + const mmssMatch = timeStr.match(/^(\d{1,3}):([0-5]?\d)$/); 38 + if (mmssMatch) { 39 + const minutes = parseInt(mmssMatch[1], 10); 40 + const seconds = parseInt(mmssMatch[2], 10); 41 + 42 + // Validate reasonable bounds (max 999 minutes, seconds 0-59) 43 + if (minutes > 999 || seconds > 59) { 44 + return NaN; // Invalid format 45 + } 46 + 47 + return minutes * 60 + seconds; 48 + } 49 + 50 + // Otherwise, treat as plain seconds (but only if no colons in input) 51 + if (timeStr.includes(":")) { 52 + return NaN; // Invalid time format - has colons but didn't match time patterns 53 + } 54 + const parsed = parseFloat(timeStr); 55 + if ( 56 + Number.isNaN(parsed) || 57 + !Number.isFinite(parsed) || 58 + parsed < 0 || 59 + parsed > 20000000 60 + ) { 61 + return NaN; // Invalid input 62 + } 63 + 64 + return parsed; 65 + } 66 + 67 + interface SubmissionFormProps { 68 + segment: SegmentData; 69 + onSuccess?: () => void; 70 + onCancel?: () => void; 71 + } 72 + 73 + export function SubmissionForm({ 74 + segment, 75 + onSuccess, 76 + onCancel, 77 + }: SubmissionFormProps) { 78 + const { t } = useTranslation(); 79 + const meta = usePlayerStore((s) => s.meta); 80 + const tidbKey = usePreferencesStore((s) => s.tidbKey); 81 + const submissionModal = useModal("tidb-submission"); 82 + const [isSubmitting, setIsSubmitting] = useState(false); 83 + const [formData, setFormData] = useState<{ 84 + segment: SegmentType; 85 + start: string; 86 + end: string; 87 + }>({ 88 + segment: segment.type as SegmentType, 89 + start: "", 90 + end: "", 91 + }); 92 + 93 + // Pre-fill the form with current segment data 94 + useEffect(() => { 95 + if (segment) { 96 + setFormData({ 97 + segment: segment.type as SegmentType, 98 + start: segment.start_ms ? (segment.start_ms / 1000).toString() : "", 99 + end: segment.end_ms ? (segment.end_ms / 1000).toString() : "", 100 + }); 101 + } 102 + }, [segment]); 103 + 104 + // Show modal when component mounts 105 + useEffect(() => { 106 + submissionModal.show(); 107 + }, [submissionModal]); 108 + 109 + const handleSubmit = async (e: React.FormEvent) => { 110 + e.preventDefault(); 111 + 112 + // Check if form is valid 113 + if (!formData.segment) { 114 + // eslint-disable-next-line no-alert 115 + alert(t("player.skipTime.feedback.modal.error.segment")); 116 + return; 117 + } 118 + 119 + if (!tidbKey) { 120 + // eslint-disable-next-line no-alert 121 + alert(t("player.skipTime.feedback.modal.error.tidbKey")); 122 + return; 123 + } 124 + if (!meta) { 125 + // eslint-disable-next-line no-alert 126 + alert(t("player.skipTime.feedback.modal.error.mediaInfo")); 127 + return; 128 + } 129 + setIsSubmitting(true); 130 + try { 131 + const startSeconds = parseTimeToSeconds(formData.start); 132 + const endSeconds = parseTimeToSeconds(formData.end); 133 + 134 + // Basic validation 135 + if (formData.segment === "intro" || formData.segment === "recap") { 136 + if (endSeconds === null || Number.isNaN(endSeconds)) { 137 + // eslint-disable-next-line no-alert 138 + alert(t("player.skipTime.feedback.modal.error.endTime")); 139 + setIsSubmitting(false); 140 + return; 141 + } 142 + } else if (formData.segment === "credits") { 143 + if (startSeconds === null || Number.isNaN(startSeconds)) { 144 + // eslint-disable-next-line no-alert 145 + alert(t("player.skipTime.feedback.modal.error.startTime")); 146 + setIsSubmitting(false); 147 + return; 148 + } 149 + } 150 + 151 + // Prepare submission data 152 + const submissionData: any = { 153 + tmdb_id: parseInt(meta.tmdbId.toString(), 10), 154 + type: meta.type === "show" ? "tv" : "movie", 155 + segment: formData.segment, 156 + }; 157 + 158 + // Add season/episode for TV shows 159 + if (meta.type === "show" && meta.season && meta.episode) { 160 + submissionData.season = meta.season.number; 161 + submissionData.episode = meta.episode.number; 162 + } 163 + 164 + // Set start_sec and end_sec based on segment type 165 + if (formData.segment === "intro" || formData.segment === "recap") { 166 + submissionData.start_sec = startSeconds !== null ? startSeconds : null; 167 + submissionData.end_sec = endSeconds!; 168 + } else if (formData.segment === "credits") { 169 + submissionData.start_sec = startSeconds!; 170 + submissionData.end_sec = endSeconds !== null ? endSeconds : null; 171 + } 172 + 173 + await submitIntro(submissionData, tidbKey); 174 + 175 + // Success 176 + submissionModal.hide(); 177 + if (onSuccess) onSuccess(); 178 + } catch (error) { 179 + console.error("Error submitting:", error); 180 + // eslint-disable-next-line no-alert 181 + alert( 182 + `${t("player.skipTime.feedback.modal.error.submission")}: ${error instanceof Error ? error.message : String(error)}`, 183 + ); 184 + } finally { 185 + setIsSubmitting(false); 186 + } 187 + }; 188 + 189 + return ( 190 + <Modal id={submissionModal.id}> 191 + <ModalCard className="!max-w-4xl max-h-[80vh] overflow-y-auto"> 192 + <Heading3 className="!mt-0 !mb-4"> 193 + {t("player.skipTime.feedback.modal.title")} 194 + </Heading3> 195 + <Paragraph className="!mt-1 !mb-6"> 196 + {t("player.skipTime.feedback.modal.description")} 197 + </Paragraph> 198 + 199 + <div className="space-y-4 mt-4"> 200 + {/* Section: Segment timestamps */} 201 + <div> 202 + <label 203 + htmlFor="segment" 204 + className="block text-sm font-medium text-white mb-1" 205 + > 206 + {t("player.skipTime.feedback.modal.segmentType")} 207 + <span className="text-red-500 ml-1">*</span> 208 + </label> 209 + <Dropdown 210 + options={[ 211 + { 212 + id: "intro", 213 + name: t("player.skipTime.feedback.modal.types.intro"), 214 + }, 215 + { 216 + id: "recap", 217 + name: t("player.skipTime.feedback.modal.types.recap"), 218 + }, 219 + { 220 + id: "credits", 221 + name: t("player.skipTime.feedback.modal.types.credits"), 222 + }, 223 + ]} 224 + selectedItem={{ 225 + id: formData.segment, 226 + name: 227 + formData.segment === "intro" 228 + ? t("player.skipTime.feedback.modal.types.intro") 229 + : formData.segment === "recap" 230 + ? t("player.skipTime.feedback.modal.types.recap") 231 + : t("player.skipTime.feedback.modal.types.credits"), 232 + }} 233 + setSelectedItem={(item) => 234 + setFormData({ ...formData, segment: item.id as SegmentType }) 235 + } 236 + /> 237 + </div> 238 + 239 + <form onSubmit={handleSubmit} className="space-y-4"> 240 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 241 + <div> 242 + <label 243 + htmlFor="start" 244 + className="block text-sm font-medium text-white mb-1" 245 + > 246 + {t("player.skipTime.feedback.modal.startTimeLabel")} 247 + {formData.segment === "credits" ? ( 248 + <span className="text-red-500 ml-1">*</span> 249 + ) : null} 250 + </label> 251 + <AuthInputBox 252 + value={formData.start} 253 + onChange={(value) => 254 + setFormData({ ...formData, start: value }) 255 + } 256 + placeholder={t( 257 + `player.skipTime.feedback.modal.placeholders.start.${formData.segment}`, 258 + )} 259 + /> 260 + </div> 261 + <div> 262 + <label 263 + htmlFor="end" 264 + className="block text-sm font-medium text-white mb-1" 265 + > 266 + {t("player.skipTime.feedback.modal.endTimeLabel")} 267 + {formData.segment === "intro" || 268 + formData.segment === "recap" ? ( 269 + <span className="text-red-500 ml-1">*</span> 270 + ) : null} 271 + </label> 272 + <AuthInputBox 273 + value={formData.end} 274 + onChange={(value) => setFormData({ ...formData, end: value })} 275 + placeholder={t( 276 + `player.skipTime.feedback.modal.placeholders.end.${formData.segment}`, 277 + )} 278 + /> 279 + </div> 280 + </div> 281 + 282 + {/* Timing Guidance Section */} 283 + <div className="mt-6 p-4 bg-pill-background rounded-lg"> 284 + <h3 className="font-semibold text-white mb-3"> 285 + {t("player.skipTime.feedback.modal.whenToTitle")} 286 + </h3> 287 + 288 + <p className="text-sm text-gray-300"> 289 + {t("player.skipTime.feedback.modal.whenToDesc")} 290 + </p> 291 + 292 + <div className="grid grid-cols-2 md:grid-cols-4 gap-4 my-4"> 293 + <div> 294 + <h4 className="font-medium mb-1"> 295 + {t("player.skipTime.feedback.modal.guide.startLabel")} 296 + </h4> 297 + <p className="text-xs"> 298 + {t("player.skipTime.feedback.modal.guide.startDesc")} 299 + </p> 300 + </div> 301 + <div> 302 + <h4 className="font-medium mb-1"> 303 + {t("player.skipTime.feedback.modal.guide.endLabel")} 304 + </h4> 305 + <p className="text-xs"> 306 + {t("player.skipTime.feedback.modal.guide.endDesc")} 307 + </p> 308 + </div> 309 + <div> 310 + <h4 className="font-medium mb-1"> 311 + {t("player.skipTime.feedback.modal.guide.durationLabel")} 312 + </h4> 313 + <p className="text-xs"> 314 + {t("player.skipTime.feedback.modal.guide.durationDesc")} 315 + </p> 316 + </div> 317 + <div> 318 + <h4 className="font-medium mb-1"> 319 + {t("player.skipTime.feedback.modal.guide.excludeLabel")} 320 + </h4> 321 + <p className="text-xs"> 322 + {t("player.skipTime.feedback.modal.guide.excludeDesc")} 323 + </p> 324 + </div> 325 + </div> 326 + </div> 327 + 328 + <div className="flex gap-2 pt-4 justify-between"> 329 + <Button 330 + theme="secondary" 331 + onClick={() => { 332 + submissionModal.hide(); 333 + if (onCancel) onCancel(); 334 + }} 335 + disabled={isSubmitting} 336 + > 337 + {t("player.skipTime.feedback.modal.cancel")} 338 + </Button> 339 + <button 340 + type="submit" 341 + disabled={isSubmitting} 342 + 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" 343 + > 344 + {isSubmitting 345 + ? t("player.skipTime.feedback.modal.submitting") 346 + : t("player.skipTime.feedback.modal.submit")} 347 + </button> 348 + </div> 349 + </form> 350 + </div> 351 + </ModalCard> 352 + </Modal> 353 + ); 354 + }
+7 -1
src/components/player/atoms/SkipSegmentButton.tsx
··· 76 76 segments: SegmentData[]; 77 77 inControl: boolean; 78 78 onChangeMeta?: (meta: PlayerMeta) => void; 79 + onSkipTriggered?: (segment: SegmentData, skipTime: number) => void; 79 80 }) { 80 81 const { t } = useTranslation(); 81 82 const time = usePlayerStore((s) => s.progress.time); ··· 134 135 : undefined, 135 136 }); 136 137 138 + // Notify parent that skip was triggered 139 + if (props.onSkipTriggered) { 140 + props.onSkipTriggered(segment, targetTime); 141 + } 142 + 137 143 // eslint-disable-next-line no-console 138 144 console.log(`Skip ${segment.type} button used: ${skipDuration}s total`); 139 145 }, 140 - [display, time, _duration, addSkipEvent, meta], 146 + [display, time, _duration, addSkipEvent, meta, props], 141 147 ); 142 148 143 149 // Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end
+35
src/components/player/atoms/TIDBSubmissionSuccessPopout.tsx
··· 1 + import { useTranslation } from "react-i18next"; 2 + 3 + import { Icon, Icons } from "@/components/Icon"; 4 + import { Flare } from "@/components/utils/Flare"; 5 + import { Transition } from "@/components/utils/Transition"; 6 + import { useOverlayStack } from "@/stores/interface/overlayStack"; 7 + 8 + export function TIDBSubmissionSuccessPopout() { 9 + const { t } = useTranslation(); 10 + const currentOverlay = useOverlayStack((s) => s.currentOverlay); 11 + 12 + return ( 13 + <Transition 14 + animation="slide-down" 15 + show={currentOverlay === "tidb-submission-success"} 16 + className="absolute inset-x-0 top-4 flex justify-center pointer-events-none" 17 + > 18 + <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"> 19 + <Flare.Light 20 + enabled 21 + flareSize={200} 22 + cssColorVar="--colors-video-context-light" 23 + backgroundClass="bg-video-context-background duration-100" 24 + className="rounded-lg" 25 + /> 26 + <Flare.Child className="flex items-center gap-3 pointer-events-auto relative transition-transform"> 27 + <Icon className="text-green-500" icon={Icons.CHECKMARK} /> 28 + <span className="font-medium"> 29 + {t("player.skipTime.feedback.modal.success.title")} 30 + </span> 31 + </Flare.Child> 32 + </Flare.Base> 33 + </Transition> 34 + ); 35 + }
+164
src/components/player/atoms/ThumbsFeedback.tsx
··· 1 + import classNames from "classnames"; 2 + import { useCallback, useEffect, useRef, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + 5 + import { Icon, Icons } from "@/components/Icon"; 6 + import { SegmentData } from "@/components/player/hooks/useSkipTime"; 7 + import { SubmissionForm } from "@/components/player/TIDBSubmissionForm"; 8 + import { Transition } from "@/components/utils/Transition"; 9 + import { useOverlayStack } from "@/stores/interface/overlayStack"; 10 + import { usePlayerStore } from "@/stores/player/store"; 11 + import { usePreferencesStore } from "@/stores/preferences"; 12 + 13 + interface ThumbsFeedbackProps { 14 + controlsShowing: boolean; 15 + feedbackData?: { 16 + segment: SegmentData; 17 + skipTime: number; 18 + } | null; 19 + onAction?: () => void; 20 + } 21 + 22 + export function ThumbsFeedback({ 23 + controlsShowing, 24 + feedbackData, 25 + onAction, 26 + }: ThumbsFeedbackProps) { 27 + const { t } = useTranslation(); 28 + const time = usePlayerStore((s) => s.progress.time); 29 + const tidbKey = usePreferencesStore((s) => s.tidbKey); 30 + 31 + // State for feedback 32 + const [showSubmissionModal, setShowSubmissionModal] = useState(false); 33 + const feedbackTimeoutRef = useRef<NodeJS.Timeout | null>(null); 34 + 35 + // Cleanup timeout on unmount 36 + useEffect(() => { 37 + return () => { 38 + if (feedbackTimeoutRef.current) { 39 + clearTimeout(feedbackTimeoutRef.current); 40 + } 41 + }; 42 + }, []); 43 + 44 + // Handle feedback data changes 45 + useEffect(() => { 46 + if (feedbackData) { 47 + // Clear any existing timeout 48 + if (feedbackTimeoutRef.current) { 49 + clearTimeout(feedbackTimeoutRef.current); 50 + feedbackTimeoutRef.current = null; 51 + } 52 + 53 + // Hide feedback after 5 seconds 54 + feedbackTimeoutRef.current = setTimeout(() => { 55 + onAction?.(); 56 + }, 5000); 57 + } 58 + }, [feedbackData, onAction]); 59 + 60 + const handleThumbsUp = useCallback(() => { 61 + if (feedbackTimeoutRef.current) { 62 + clearTimeout(feedbackTimeoutRef.current); 63 + feedbackTimeoutRef.current = null; 64 + } 65 + onAction?.(); 66 + }, [onAction]); 67 + 68 + const handleThumbsDown = useCallback(() => { 69 + if (feedbackTimeoutRef.current) { 70 + clearTimeout(feedbackTimeoutRef.current); 71 + feedbackTimeoutRef.current = null; 72 + } 73 + setShowSubmissionModal(true); 74 + }, []); 75 + 76 + const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); 77 + 78 + const handleSubmissionSuccess = useCallback(() => { 79 + setShowSubmissionModal(false); 80 + setCurrentOverlay("tidb-submission-success"); 81 + onAction?.(); 82 + }, [onAction, setCurrentOverlay]); 83 + 84 + const handleSubmissionCancel = useCallback(() => { 85 + setShowSubmissionModal(false); 86 + onAction?.(); 87 + }, [onAction]); 88 + 89 + // Don't show thumbs feedback if TIDB API key is not set 90 + if (!tidbKey || tidbKey.trim() === "") { 91 + return null; 92 + } 93 + 94 + // Only show feedback if we're within the 5-second window after skip 95 + const shouldShowFeedback = !!( 96 + feedbackData && 97 + time >= feedbackData.skipTime + 0.1 && 98 + time <= feedbackData.skipTime + 5 99 + ); 100 + 101 + if (!shouldShowFeedback && !showSubmissionModal) return null; 102 + 103 + let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; 104 + if (!controlsShowing) { 105 + bottom = "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; 106 + } 107 + 108 + return ( 109 + <> 110 + <div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0 pointer-events-none"> 111 + <Transition 112 + animation="fade" 113 + show={shouldShowFeedback} 114 + className="absolute right-0" 115 + > 116 + <div 117 + className={classNames( 118 + "absolute bottom-0 right-0 transition-[bottom] duration-200 flex flex-col items-end space-y-2", 119 + bottom, 120 + )} 121 + > 122 + <div className="text-sm font-medium text-white whitespace-nowrap"> 123 + {t("player.skipTime.feedback.title")} 124 + </div> 125 + <div className="flex items-center space-x-3 pointer-events-auto"> 126 + <button 127 + type="button" 128 + onClick={handleThumbsUp} 129 + className={classNames( 130 + "h-10 w-10 rounded-full flex items-center justify-center pointer-events-auto", 131 + "bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText", 132 + "scale-95 hover:scale-100 transition-all duration-200", 133 + )} 134 + aria-label="Thumbs up" 135 + > 136 + <Icon className="text-xl" icon={Icons.THUMBS_UP} /> 137 + </button> 138 + <button 139 + type="button" 140 + onClick={handleThumbsDown} 141 + className={classNames( 142 + "h-10 w-10 rounded-full flex items-center justify-center pointer-events-auto", 143 + "bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText", 144 + "scale-95 hover:scale-100 transition-all duration-200", 145 + )} 146 + aria-label="Thumbs down" 147 + > 148 + <Icon className="text-xl" icon={Icons.THUMBS_DOWN} /> 149 + </button> 150 + </div> 151 + </div> 152 + </Transition> 153 + </div> 154 + 155 + {showSubmissionModal && feedbackData && ( 156 + <SubmissionForm 157 + segment={feedbackData.segment} 158 + onSuccess={handleSubmissionSuccess} 159 + onCancel={handleSubmissionCancel} 160 + /> 161 + )} 162 + </> 163 + ); 164 + }
+1
src/components/player/atoms/index.ts
··· 19 19 export * from "./CastingNotification"; 20 20 export * from "./Captions"; 21 21 export * from "./SpeedChangedPopout"; 22 + export * from "./TIDBSubmissionSuccessPopout";
+10
src/hooks/useSettingsState.ts
··· 48 48 febboxKey: string | null, 49 49 debridToken: string | null, 50 50 debridService: string, 51 + tidbKey: string | null, 51 52 profile: 52 53 | { 53 54 colorA: string; ··· 98 99 _resetdebridService, 99 100 debridServiceChanged, 100 101 ] = useDerived(debridService); 102 + const [tidbKeyState, setTIDBKey, resetTIDBKey, tidbKeyChanged] = 103 + useDerived(tidbKey); 101 104 const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme); 102 105 const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme); 103 106 const resetPreviewTheme = useCallback( ··· 272 275 resetBackendUrl(); 273 276 resetFebboxKey(); 274 277 resetdebridToken(); 278 + resetTIDBKey(); 275 279 resetDeviceName(); 276 280 resetNickname(); 277 281 resetProfile(); ··· 312 316 febboxKeyChanged || 313 317 debridTokenChanged || 314 318 debridServiceChanged || 319 + tidbKeyChanged || 315 320 profileChanged || 316 321 enableThumbnailsChanged || 317 322 enableAutoplayChanged || ··· 390 395 state: debridServiceState, 391 396 set: setdebridService, 392 397 changed: debridServiceChanged, 398 + }, 399 + tidbKey: { 400 + state: tidbKeyState, 401 + set: setTIDBKey, 402 + changed: tidbKeyChanged, 393 403 }, 394 404 profile: { 395 405 state: profileState,
+8
src/pages/Settings.tsx
··· 385 385 const debridService = usePreferencesStore((s) => s.debridService); 386 386 const setdebridService = usePreferencesStore((s) => s.setdebridService); 387 387 388 + const tidbKey = usePreferencesStore((s) => s.tidbKey); 389 + const setTIDBKey = usePreferencesStore((s) => s.setTIDBKey); 390 + 388 391 const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails); 389 392 const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails); 390 393 ··· 551 554 febboxKey, 552 555 debridToken, 553 556 debridService, 557 + tidbKey, 554 558 account ? account.profile : undefined, 555 559 enableThumbnails, 556 560 enableAutoplay, ··· 718 722 setFebboxKey(state.febboxKey.state); 719 723 setdebridToken(state.debridToken.state); 720 724 setdebridService(state.debridService.state); 725 + setTIDBKey(state.tidbKey.state); 721 726 setProxyTmdb(state.proxyTmdb.state); 722 727 setEnableCarouselView(state.enableCarouselView.state); 723 728 setEnableMinimalCards(state.enableMinimalCards.state); ··· 761 766 setFebboxKey, 762 767 setdebridToken, 763 768 setdebridService, 769 + setTIDBKey, 764 770 setEnableAutoplay, 765 771 setEnableSkipCredits, 766 772 setEnableDiscover, ··· 929 935 setdebridToken={state.debridToken.set} 930 936 debridService={state.debridService.state} 931 937 setdebridService={state.debridService.set} 938 + tidbKey={state.tidbKey.state} 939 + setTIDBKey={state.tidbKey.set} 932 940 proxyTmdb={state.proxyTmdb.state} 933 941 setProxyTmdb={state.proxyTmdb.set} 934 942 />
+31 -2
src/pages/parts/player/PlayerPart.tsx
··· 1 - import { ReactNode, useRef, useState } from "react"; 1 + import { ReactNode, useCallback, useRef, useState } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 3 4 4 import { BrandPill } from "@/components/layout/BrandPill"; 5 5 import { Player } from "@/components/player"; 6 6 import { SkipSegmentButton } from "@/components/player/atoms/SkipSegmentButton"; 7 + import { ThumbsFeedback } from "@/components/player/atoms/ThumbsFeedback"; 7 8 import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay"; 8 9 import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus"; 9 10 import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; 10 - import { useSkipTime } from "@/components/player/hooks/useSkipTime"; 11 + import { 12 + SegmentData, 13 + useSkipTime, 14 + } from "@/components/player/hooks/useSkipTime"; 11 15 import { useIsMobile } from "@/hooks/useIsMobile"; 12 16 import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; 13 17 import { usePlayerStore } from "@/stores/player/store"; ··· 74 78 }, 1000); 75 79 }; 76 80 81 + // State for thumbs feedback 82 + const [thumbsFeedbackData, setThumbsFeedbackData] = useState<{ 83 + segment: SegmentData; 84 + skipTime: number; 85 + } | null>(null); 86 + 77 87 const segments = useSkipTime(); 88 + 89 + const handleSkipTriggered = useCallback( 90 + (segment: SegmentData, skipTime: number) => { 91 + setThumbsFeedbackData({ segment, skipTime }); 92 + }, 93 + [], 94 + ); 95 + 96 + const handleThumbsFeedback = useCallback(() => { 97 + setThumbsFeedbackData(null); 98 + }, []); 78 99 79 100 return ( 80 101 <Player.Container onLoad={props.onLoad} showingControls={showTargets}> ··· 238 259 <Player.VolumeChangedPopout /> 239 260 <Player.SubtitleDelayPopout /> 240 261 <Player.SpeedChangedPopout /> 262 + <Player.TIDBSubmissionSuccessPopout /> 241 263 <UnreleasedEpisodeOverlay /> 242 264 243 265 <Player.NextEpisodeButton ··· 251 273 segments={segments} 252 274 inControl={inControl} 253 275 onChangeMeta={props.onMetaChange} 276 + onSkipTriggered={handleSkipTriggered} 277 + /> 278 + 279 + <ThumbsFeedback 280 + controlsShowing={showTargets} 281 + feedbackData={thumbsFeedbackData} 282 + onAction={handleThumbsFeedback} 254 283 /> 255 284 </Player.Container> 256 285 );
+53 -1
src/pages/parts/settings/ConnectionsPart.tsx
··· 3 3 SetStateAction, 4 4 useCallback, 5 5 useEffect, 6 + useRef, 6 7 useState, 7 8 } from "react"; 8 9 import { Trans, useTranslation } from "react-i18next"; ··· 60 61 setdebridService: (value: string) => void; 61 62 // eslint-disable-next-line react/no-unused-prop-types 62 63 mode?: "onboarding" | "settings"; 64 + } 65 + 66 + interface TIDBKeyProps { 67 + tidbKey: string | null; 68 + setTIDBKey: (value: string | null) => void; 63 69 } 64 70 65 71 function ProxyEdit({ ··· 725 731 return null; 726 732 } 727 733 734 + export function TIDBEdit({ tidbKey, setTIDBKey }: TIDBKeyProps) { 735 + const { t } = useTranslation(); 736 + const preferences = usePreferencesStore(); 737 + const initializedRef = useRef(false); 738 + 739 + // Enable TIDB key when component loads 740 + useEffect(() => { 741 + if (!initializedRef.current && tidbKey === null && preferences.tidbKey) { 742 + initializedRef.current = true; 743 + setTIDBKey(preferences.tidbKey); 744 + } 745 + }, [tidbKey, preferences.tidbKey, setTIDBKey]); 746 + 747 + return ( 748 + <SettingsCard> 749 + <div className="my-3"> 750 + <p className="text-white font-bold mb-3">TheIntroDB</p> 751 + <p className="max-w-[40rem] font-medium mb-6"> 752 + <Trans i18nKey="settings.connections.tidb.description"> 753 + <MwLink to="https://theintrodb.org/" /> 754 + </Trans> 755 + </p> 756 + <p className="text-white font-bold mb-3"> 757 + {t("settings.connections.tidb.tokenLabel")} 758 + </p> 759 + <div className="flex items-center w-full"> 760 + <AuthInputBox 761 + onChange={(newToken) => { 762 + setTIDBKey(newToken); 763 + }} 764 + value={tidbKey ?? ""} 765 + placeholder="theintrodb:user..." 766 + passwordToggleable 767 + className="flex-grow" 768 + /> 769 + </div> 770 + </div> 771 + </SettingsCard> 772 + ); 773 + } 774 + 728 775 export function ConnectionsPart( 729 - props: BackendEditProps & ProxyEditProps & FebboxKeyProps & DebridProps, 776 + props: BackendEditProps & 777 + ProxyEditProps & 778 + FebboxKeyProps & 779 + DebridProps & 780 + TIDBKeyProps, 730 781 ) { 731 782 const { t } = useTranslation(); 732 783 return ( ··· 756 807 setdebridService={props.setdebridService} 757 808 mode="settings" 758 809 /> 810 + <TIDBEdit tidbKey={props.tidbKey} setTIDBKey={props.setTIDBKey} /> 759 811 </div> 760 812 </div> 761 813 );
+6 -1
src/stores/interface/overlayStack.ts
··· 3 3 import { create } from "zustand"; 4 4 import { immer } from "zustand/middleware/immer"; 5 5 6 - type OverlayType = "volume" | "subtitle" | "speed" | null; 6 + type OverlayType = 7 + | "volume" 8 + | "subtitle" 9 + | "speed" 10 + | "tidb-submission-success" 11 + | null; 7 12 8 13 interface ModalData { 9 14 id: number;
+8
src/stores/preferences/index.tsx
··· 29 29 febboxUseMp4: boolean; 30 30 debridToken: string | null; 31 31 debridService: string; 32 + tidbKey: string | null; 32 33 enableLowPerformanceMode: boolean; 33 34 enableNativeSubtitles: boolean; 34 35 enableHoldToBoost: boolean; ··· 59 60 setFebboxUseMp4(v: boolean): void; 60 61 setdebridToken(v: string | null): void; 61 62 setdebridService(v: string): void; 63 + setTIDBKey(v: string | null): void; 62 64 setEnableLowPerformanceMode(v: boolean): void; 63 65 setEnableNativeSubtitles(v: boolean): void; 64 66 setEnableHoldToBoost(v: boolean): void; ··· 93 95 febboxUseMp4: false, 94 96 debridToken: null, 95 97 debridService: "realdebrid", 98 + tidbKey: null, 96 99 enableLowPerformanceMode: false, 97 100 enableNativeSubtitles: false, 98 101 enableHoldToBoost: true, ··· 204 207 setdebridService(v) { 205 208 set((s) => { 206 209 s.debridService = v; 210 + }); 211 + }, 212 + setTIDBKey(v) { 213 + set((s) => { 214 + s.tidbKey = v; 207 215 }); 208 216 }, 209 217 setEnableLowPerformanceMode(v) {
+83
src/utils/tidb.ts
··· 1 + export type SegmentType = "intro" | "recap" | "credits"; 2 + 3 + export interface SubmissionRequest { 4 + tmdb_id: number; 5 + type: "movie" | "tv"; 6 + segment: SegmentType; 7 + season?: number; 8 + episode?: number; 9 + start_sec?: number | null; 10 + end_sec?: number | null; 11 + start_ms?: number | null; 12 + end_ms?: number | null; 13 + tvdb_id?: number; 14 + imdb_id?: string; 15 + } 16 + 17 + export interface SubmissionResponse { 18 + ok: boolean; 19 + submission?: { 20 + id: string; 21 + tmdbId: number; 22 + type: "movie" | "tv"; 23 + segment: SegmentType; 24 + season?: number; 25 + episode?: number; 26 + startMs?: number | null; 27 + endMs?: number | null; 28 + status: "pending" | "accepted" | "rejected"; 29 + weight: number; 30 + }; 31 + } 32 + 33 + export interface ErrorResponse { 34 + error: string; 35 + details?: string; 36 + } 37 + 38 + export class TIDBError extends Error { 39 + constructor( 40 + message: string, 41 + public statusCode?: number, 42 + public details?: string, 43 + ) { 44 + super(message); 45 + this.name = "TIDBError"; 46 + } 47 + } 48 + 49 + /** 50 + * Submit segment timestamps to TheIntroDB API 51 + */ 52 + export async function submitIntro( 53 + submission: SubmissionRequest, 54 + apiKey: string, 55 + ): Promise<SubmissionResponse> { 56 + const response = await fetch("https://api.theintrodb.org/v1/submit", { 57 + method: "POST", 58 + headers: { 59 + "Content-Type": "application/json", 60 + Authorization: `Bearer ${apiKey}`, 61 + }, 62 + body: JSON.stringify(submission), 63 + }); 64 + 65 + if (!response.ok) { 66 + let errorMessage = `HTTP ${response.status}`; 67 + let details: string | undefined; 68 + 69 + try { 70 + const errorData: ErrorResponse = await response.json(); 71 + errorMessage = errorData.error; 72 + details = errorData.details; 73 + } catch { 74 + // If we can't parse the error response, use the status text 75 + errorMessage = response.statusText || errorMessage; 76 + } 77 + 78 + throw new TIDBError(errorMessage, response.status, details); 79 + } 80 + 81 + const data: SubmissionResponse = await response.json(); 82 + return data; 83 + }