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

add caption match score

Subtract from 100% if the caption dialogue overlaps with a TIDB provided credit sequence

Pas 41bd5cc4 7de37b96

+154 -48
+86 -48
src/components/player/atoms/settings/CaptionsView.tsx
··· 27 27 sortLangCodes, 28 28 } from "@/utils/language"; 29 29 30 + import { useCaptionMatchScore } from "../../hooks/useCaptionMatchScore"; 31 + 30 32 /* eslint-disable react/no-unused-prop-types */ 31 33 export interface CaptionOptionProps { 32 34 countryCode?: string; ··· 41 43 isTranslatedTarget?: boolean; 42 44 subtitleUrl?: string; 43 45 subtitleType?: string; 44 - // subtitle details from wyzie 45 46 subtitleSource?: string; 46 47 subtitleEncoding?: string; 47 48 isHearingImpaired?: boolean; 48 49 onDoubleClick?: () => void; 49 50 onTranslate?: () => void; 51 + matchScore?: number | null; 50 52 } 51 53 /* eslint-enable react/no-unused-prop-types */ 52 54 ··· 128 130 parts.push(`URL: ${props.subtitleUrl}`); 129 131 } 130 132 133 + if (props.matchScore !== undefined && props.matchScore !== null) { 134 + parts.push(`Match Score: ${props.matchScore}%`); 135 + } 136 + 131 137 return parts.join("\n"); 132 138 }, [ 133 139 props.subtitleUrl, 134 140 props.subtitleSource, 135 141 props.subtitleEncoding, 136 142 props.isHearingImpaired, 143 + props.matchScore, 137 144 ]); 138 145 139 146 const handleMouseEnter = () => { ··· 176 183 > 177 184 <span 178 185 data-active-link={props.selected ? true : undefined} 179 - className="flex items-center" 186 + className="flex flex-col items-start" 180 187 > 181 - {props.flag ? ( 182 - <span data-code={props.countryCode} className="mr-3 inline-flex"> 183 - <FlagIcon langCode={props.countryCode} /> 184 - </span> 185 - ) : null} 186 - <span 187 - className={ 188 - props.flag || props.subtitleUrl || props.subtitleSource 189 - ? "truncate max-w-[100px]" 190 - : "" 191 - } 192 - > 193 - {props.children} 194 - </span> 195 - {props.subtitleType && ( 196 - <span className="ml-2 px-2 py-0.5 rounded bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-xs font-semibold"> 197 - {props.subtitleType.toUpperCase()} 198 - </span> 199 - )} 200 - {props.subtitleSource && ( 188 + <div className="flex items-center"> 189 + {props.flag ? ( 190 + <span data-code={props.countryCode} className="mr-3 inline-flex"> 191 + <FlagIcon langCode={props.countryCode} /> 192 + </span> 193 + ) : null} 201 194 <span 202 - className={classNames( 203 - "ml-2 px-2 py-0.5 rounded text-white text-xs font-semibold overflow-hidden text-ellipsis whitespace-nowrap", 204 - { 205 - "bg-blue-500": props.subtitleSource.includes("wyzie"), 206 - "bg-orange-500": props.subtitleSource === "opensubs", 207 - "bg-purple-500": props.subtitleSource === "febbox", 208 - "bg-green-500": props.subtitleSource === "granite", 209 - }, 210 - )} 195 + className={ 196 + props.flag || props.subtitleUrl || props.subtitleSource 197 + ? "truncate max-w-[100px]" 198 + : "" 199 + } 211 200 > 212 - {props.subtitleSource.toUpperCase()} 201 + {props.children} 213 202 </span> 214 - )} 215 - {props.isHearingImpaired && ( 216 - <Icon icon={Icons.EAR} className="ml-2" /> 217 - )} 203 + </div> 204 + <div className="flex items-center"> 205 + {props.subtitleType && ( 206 + <span className="px-2 py-0.5 mt-2 rounded bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-xs font-semibold"> 207 + {props.subtitleType.toUpperCase()} 208 + </span> 209 + )} 210 + {props.subtitleSource && ( 211 + <span 212 + className={classNames( 213 + "ml-2 px-2 py-0.5 mt-2 rounded text-white text-xs font-semibold overflow-hidden text-ellipsis whitespace-nowrap", 214 + { 215 + "bg-blue-500": props.subtitleSource.includes("wyzie"), 216 + "bg-orange-500": props.subtitleSource === "opensubs", 217 + "bg-purple-500": props.subtitleSource === "febbox", 218 + "bg-green-500": props.subtitleSource === "granite", 219 + }, 220 + )} 221 + > 222 + {props.subtitleSource.toUpperCase()} 223 + </span> 224 + )} 225 + {props.isHearingImpaired && ( 226 + <Icon icon={Icons.EAR} className="ml-2 mt-2" /> 227 + )} 228 + {props.matchScore !== undefined && props.matchScore !== null && ( 229 + <span 230 + className={classNames( 231 + "text-xs font-bold ml-2 mt-2 whitespace-nowrap", 232 + { 233 + "text-video-context-type-accent": props.matchScore >= 80, 234 + "text-yellow-500": 235 + props.matchScore >= 50 && props.matchScore < 80, 236 + "text-video-context-error": props.matchScore < 50, 237 + }, 238 + )} 239 + > 240 + ~{props.matchScore}% match 241 + </span> 242 + )} 243 + </div> 218 244 </span> 219 245 </SelectableLink> 220 246 {tooltipContent && showTooltip && ( ··· 420 446 }: CaptionsViewProps) { 421 447 const { t } = useTranslation(); 422 448 const router = useOverlayRouter(id); 423 - const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); 449 + const selectedCaption = usePlayerStore((s) => s.caption.selected); 424 450 const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); 425 451 const { disable, selectRandomCaptionFromLastUsedLanguage } = useCaptions(); 426 452 const [isRandomSelecting, setIsRandomSelecting] = useState(false); ··· 447 473 const delay = useSubtitleStore((s) => s.delay); 448 474 const appLanguage = useLanguageStore((s) => s.language); 449 475 const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs); 476 + const matchScore = useCaptionMatchScore(); 450 477 451 478 // Get combined caption list 452 479 const captions = useMemo( ··· 512 539 513 540 // Get current subtitle text preview 514 541 const currentSubtitleText = useMemo(() => { 515 - if (!srtData || !selectedCaptionId) return null; 542 + if (!srtData || !selectedCaption) return null; 516 543 const parsedCaptions = parseSubtitles(srtData, selectedLanguage); 517 544 const visibleCaption = parsedCaptions.find(({ start, end }) => 518 545 captionIsVisible(start, end, delay, videoTime), 519 546 ); 520 547 return visibleCaption?.content; 521 - }, [srtData, selectedLanguage, delay, videoTime, selectedCaptionId]); 548 + }, [srtData, selectedLanguage, delay, videoTime, selectedCaption]); 522 549 523 550 function onDrop(event: DragEvent<HTMLDivElement>) { 524 551 event.preventDefault(); ··· 614 641 onDrop={(event) => onDrop(event)} 615 642 > 616 643 {/* Current subtitle preview */} 617 - {selectedCaptionId && ( 644 + {selectedCaption && ( 618 645 <div className="mt-3 p-2 rounded-xl bg-video-context-light bg-opacity-10 text-center sm:hidden"> 619 646 <div className="text-sm text-video-context-type-secondary mb-1"> 620 647 {t("player.menus.subtitles.previewLabel")} ··· 641 668 642 669 <Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3"> 643 670 {/* Off button */} 644 - <CaptionOption 645 - onClick={() => disable()} 646 - selected={!selectedCaptionId} 647 - > 671 + <CaptionOption onClick={() => disable()} selected={!selectedCaption}> 648 672 {t("player.menus.subtitles.offChoice")} 649 673 </CaptionOption> 650 674 ··· 652 676 {captions.length > 0 && ( 653 677 <CaptionOption 654 678 onClick={() => handleRandomSelect()} 655 - selected={!!selectedCaptionId} 679 + selected={!!selectedCaption} 656 680 loading={isRandomSelecting} 657 681 > 658 682 <div className="flex flex-col"> 659 683 {t("player.menus.subtitles.autoSelectChoice")} 660 - {selectedCaptionId && ( 684 + {selectedCaption && ( 661 685 <span className="text-video-context-type-secondary text-xs"> 662 686 {t("player.menus.subtitles.autoSelectDifferentChoice")} 663 687 </span> 664 688 )} 689 + {matchScore !== undefined && matchScore !== null && ( 690 + <span 691 + className={classNames( 692 + "text-xs font-bold mt-2 whitespace-nowrap", 693 + { 694 + "text-video-context-type-accent": matchScore >= 80, 695 + "text-yellow-500": matchScore >= 50 && matchScore < 80, 696 + "text-video-context-error": matchScore < 50, 697 + }, 698 + )} 699 + > 700 + ~{matchScore}% match 701 + </span> 702 + )} 665 703 </div> 666 704 </CaptionOption> 667 705 )} ··· 671 709 672 710 {/* Paste subtitle option */} 673 711 <PasteCaptionOption 674 - selected={selectedCaptionId === "pasted-caption"} 712 + selected={selectedCaption?.id === "pasted-caption"} 675 713 /> 676 714 677 - {selectedCaptionId && ( 715 + {selectedCaption && ( 678 716 <Menu.ChevronLink 679 717 onClick={() => router.navigate("/captions/transcript")} 680 718 >
+3
src/components/player/atoms/settings/LanguageSubtitlesView.tsx
··· 12 12 import { getPrettyLanguageNameFromLocale } from "@/utils/language"; 13 13 14 14 import { CaptionOption } from "./CaptionsView"; 15 + import { useCaptionMatchScore } from "../../hooks/useCaptionMatchScore"; 15 16 16 17 export interface LanguageSubtitlesViewProps { 17 18 id: string; ··· 36 37 >(null); 37 38 const [scrollTrigger, setScrollTrigger] = useState(0); 38 39 const captionList = usePlayerStore((s) => s.captionList); 40 + const matchScore = useCaptionMatchScore(); 39 41 40 42 // Trigger scroll when selected caption changes 41 43 useEffect(() => { ··· 175 177 subtitleSource={v.source} 176 178 subtitleEncoding={v.encoding} 177 179 isHearingImpaired={v.isHearingImpaired} 180 + matchScore={v.id === selectedCaptionId ? matchScore : undefined} 178 181 > 179 182 {v.display || v.id} 180 183 </CaptionOption>
+65
src/components/player/hooks/useCaptionMatchScore.ts
··· 1 + import { useMemo } from "react"; 2 + 3 + import { useSkipTime } from "@/components/player/hooks/useSkipTime"; 4 + import { parseSubtitles } from "@/components/player/utils/captions"; 5 + import { usePlayerStore } from "@/stores/player/store"; 6 + 7 + export function useCaptionMatchScore() { 8 + const segments = useSkipTime(); 9 + const videoDuration = usePlayerStore((s) => s.progress.duration); 10 + const srtData = usePlayerStore((s) => s.caption.selected?.srtData); 11 + 12 + const matchScore = useMemo(() => { 13 + if (!srtData || !segments.length) return null; 14 + const credits = segments.find((s) => s.type === "credits"); 15 + if (!credits || !credits.start_ms) return null; 16 + 17 + const startMs = credits.start_ms; 18 + const endMs = credits.end_ms ?? videoDuration * 1000; 19 + const durationMs = endMs - startMs; 20 + 21 + if (durationMs <= 0) return null; 22 + 23 + const cues = parseSubtitles(srtData); 24 + const intervals: [number, number][] = []; 25 + 26 + cues.forEach((cue) => { 27 + const cueStart = cue.start; 28 + const cueEnd = cue.end; 29 + 30 + const overlapStart = Math.max(startMs, cueStart); 31 + const overlapEnd = Math.min(endMs, cueEnd); 32 + 33 + if (overlapEnd > overlapStart) { 34 + intervals.push([overlapStart, overlapEnd]); 35 + } 36 + }); 37 + 38 + if (intervals.length === 0) return 100; 39 + 40 + intervals.sort((a, b) => a[0] - b[0]); 41 + 42 + const merged: [number, number][] = []; 43 + let current = intervals[0]; 44 + 45 + for (let i = 1; i < intervals.length; i += 1) { 46 + const next = intervals[i]; 47 + if (next[0] <= current[1]) { 48 + current[1] = Math.max(current[1], next[1]); 49 + } else { 50 + merged.push(current); 51 + current = next; 52 + } 53 + } 54 + merged.push(current); 55 + 56 + const overlapMs = merged.reduce( 57 + (acc, range) => acc + (range[1] - range[0]), 58 + 0, 59 + ); 60 + const percentage = (overlapMs / durationMs) * 100; 61 + return Math.round(100 - percentage); 62 + }, [srtData, segments, videoDuration]); 63 + 64 + return matchScore; 65 + }