From 41bd5cc4b78532f4a4debdb41c978826fa5f470b Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:50:46 -0700 Subject: [PATCH] add caption match score Subtract from 100% if the caption dialogue overlaps with a TIDB provided credit sequence --- .../player/atoms/settings/CaptionsView.tsx | 134 +++++++++++------- .../atoms/settings/LanguageSubtitlesView.tsx | 3 + .../player/hooks/useCaptionMatchScore.ts | 65 +++++++++ 3 files changed, 154 insertions(+), 48 deletions(-) create mode 100644 src/components/player/hooks/useCaptionMatchScore.ts diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 3f4fb649..1c6365ef 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -27,6 +27,8 @@ import { sortLangCodes, } from "@/utils/language"; +import { useCaptionMatchScore } from "../../hooks/useCaptionMatchScore"; + /* eslint-disable react/no-unused-prop-types */ export interface CaptionOptionProps { countryCode?: string; @@ -41,12 +43,12 @@ export interface CaptionOptionProps { isTranslatedTarget?: boolean; subtitleUrl?: string; subtitleType?: string; - // subtitle details from wyzie subtitleSource?: string; subtitleEncoding?: string; isHearingImpaired?: boolean; onDoubleClick?: () => void; onTranslate?: () => void; + matchScore?: number | null; } /* eslint-enable react/no-unused-prop-types */ @@ -128,12 +130,17 @@ export function CaptionOption(props: CaptionOptionProps) { parts.push(`URL: ${props.subtitleUrl}`); } + if (props.matchScore !== undefined && props.matchScore !== null) { + parts.push(`Match Score: ${props.matchScore}%`); + } + return parts.join("\n"); }, [ props.subtitleUrl, props.subtitleSource, props.subtitleEncoding, props.isHearingImpaired, + props.matchScore, ]); const handleMouseEnter = () => { @@ -176,45 +183,64 @@ export function CaptionOption(props: CaptionOptionProps) { > - {props.flag ? ( - - - - ) : null} - - {props.children} - - {props.subtitleType && ( - - {props.subtitleType.toUpperCase()} - - )} - {props.subtitleSource && ( +
+ {props.flag ? ( + + + + ) : null} - {props.subtitleSource.toUpperCase()} + {props.children} - )} - {props.isHearingImpaired && ( - - )} +
+
+ {props.subtitleType && ( + + {props.subtitleType.toUpperCase()} + + )} + {props.subtitleSource && ( + + {props.subtitleSource.toUpperCase()} + + )} + {props.isHearingImpaired && ( + + )} + {props.matchScore !== undefined && props.matchScore !== null && ( + = 80, + "text-yellow-500": + props.matchScore >= 50 && props.matchScore < 80, + "text-video-context-error": props.matchScore < 50, + }, + )} + > + ~{props.matchScore}% match + + )} +
{tooltipContent && showTooltip && ( @@ -420,7 +446,7 @@ export function CaptionsView({ }: CaptionsViewProps) { const { t } = useTranslation(); const router = useOverlayRouter(id); - const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); + const selectedCaption = usePlayerStore((s) => s.caption.selected); const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); const { disable, selectRandomCaptionFromLastUsedLanguage } = useCaptions(); const [isRandomSelecting, setIsRandomSelecting] = useState(false); @@ -447,6 +473,7 @@ export function CaptionsView({ const delay = useSubtitleStore((s) => s.delay); const appLanguage = useLanguageStore((s) => s.language); const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs); + const matchScore = useCaptionMatchScore(); // Get combined caption list const captions = useMemo( @@ -512,13 +539,13 @@ export function CaptionsView({ // Get current subtitle text preview const currentSubtitleText = useMemo(() => { - if (!srtData || !selectedCaptionId) return null; + if (!srtData || !selectedCaption) return null; const parsedCaptions = parseSubtitles(srtData, selectedLanguage); const visibleCaption = parsedCaptions.find(({ start, end }) => captionIsVisible(start, end, delay, videoTime), ); return visibleCaption?.content; - }, [srtData, selectedLanguage, delay, videoTime, selectedCaptionId]); + }, [srtData, selectedLanguage, delay, videoTime, selectedCaption]); function onDrop(event: DragEvent) { event.preventDefault(); @@ -614,7 +641,7 @@ export function CaptionsView({ onDrop={(event) => onDrop(event)} > {/* Current subtitle preview */} - {selectedCaptionId && ( + {selectedCaption && (
{t("player.menus.subtitles.previewLabel")} @@ -641,10 +668,7 @@ export function CaptionsView({ {/* Off button */} - disable()} - selected={!selectedCaptionId} - > + disable()} selected={!selectedCaption}> {t("player.menus.subtitles.offChoice")} @@ -652,16 +676,30 @@ export function CaptionsView({ {captions.length > 0 && ( handleRandomSelect()} - selected={!!selectedCaptionId} + selected={!!selectedCaption} loading={isRandomSelecting} >
{t("player.menus.subtitles.autoSelectChoice")} - {selectedCaptionId && ( + {selectedCaption && ( {t("player.menus.subtitles.autoSelectDifferentChoice")} )} + {matchScore !== undefined && matchScore !== null && ( + = 80, + "text-yellow-500": matchScore >= 50 && matchScore < 80, + "text-video-context-error": matchScore < 50, + }, + )} + > + ~{matchScore}% match + + )}
)} @@ -671,10 +709,10 @@ export function CaptionsView({ {/* Paste subtitle option */} - {selectedCaptionId && ( + {selectedCaption && ( router.navigate("/captions/transcript")} > diff --git a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx index a5ea394d..8103e6e5 100644 --- a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -12,6 +12,7 @@ import { usePlayerStore } from "@/stores/player/store"; import { getPrettyLanguageNameFromLocale } from "@/utils/language"; import { CaptionOption } from "./CaptionsView"; +import { useCaptionMatchScore } from "../../hooks/useCaptionMatchScore"; export interface LanguageSubtitlesViewProps { id: string; @@ -36,6 +37,7 @@ export function LanguageSubtitlesView({ >(null); const [scrollTrigger, setScrollTrigger] = useState(0); const captionList = usePlayerStore((s) => s.captionList); + const matchScore = useCaptionMatchScore(); // Trigger scroll when selected caption changes useEffect(() => { @@ -175,6 +177,7 @@ export function LanguageSubtitlesView({ subtitleSource={v.source} subtitleEncoding={v.encoding} isHearingImpaired={v.isHearingImpaired} + matchScore={v.id === selectedCaptionId ? matchScore : undefined} > {v.display || v.id}
diff --git a/src/components/player/hooks/useCaptionMatchScore.ts b/src/components/player/hooks/useCaptionMatchScore.ts new file mode 100644 index 00000000..9863a19c --- /dev/null +++ b/src/components/player/hooks/useCaptionMatchScore.ts @@ -0,0 +1,65 @@ +import { useMemo } from "react"; + +import { useSkipTime } from "@/components/player/hooks/useSkipTime"; +import { parseSubtitles } from "@/components/player/utils/captions"; +import { usePlayerStore } from "@/stores/player/store"; + +export function useCaptionMatchScore() { + const segments = useSkipTime(); + const videoDuration = usePlayerStore((s) => s.progress.duration); + const srtData = usePlayerStore((s) => s.caption.selected?.srtData); + + const matchScore = useMemo(() => { + if (!srtData || !segments.length) return null; + const credits = segments.find((s) => s.type === "credits"); + if (!credits || !credits.start_ms) return null; + + const startMs = credits.start_ms; + const endMs = credits.end_ms ?? videoDuration * 1000; + const durationMs = endMs - startMs; + + if (durationMs <= 0) return null; + + const cues = parseSubtitles(srtData); + const intervals: [number, number][] = []; + + cues.forEach((cue) => { + const cueStart = cue.start; + const cueEnd = cue.end; + + const overlapStart = Math.max(startMs, cueStart); + const overlapEnd = Math.min(endMs, cueEnd); + + if (overlapEnd > overlapStart) { + intervals.push([overlapStart, overlapEnd]); + } + }); + + if (intervals.length === 0) return 100; + + intervals.sort((a, b) => a[0] - b[0]); + + const merged: [number, number][] = []; + let current = intervals[0]; + + for (let i = 1; i < intervals.length; i += 1) { + const next = intervals[i]; + if (next[0] <= current[1]) { + current[1] = Math.max(current[1], next[1]); + } else { + merged.push(current); + current = next; + } + } + merged.push(current); + + const overlapMs = merged.reduce( + (acc, range) => acc + (range[1] - range[0]), + 0, + ); + const percentage = (overlapMs / durationMs) * 100; + return Math.round(100 - percentage); + }, [srtData, segments, videoDuration]); + + return matchScore; +}