diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index fdbdf4db..c23bc969 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -764,6 +764,7 @@ "SourceChoice": "Source Subtitles", "OpenSubtitlesChoice": "External Subtitles", "loadingExternal": "Loading external subtitles...", + "transcriptChoice": "Transcript", "settings": { "backlink": "Custom subtitles", "delay": "Subtitle delay", diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index b8aad79e..a7bad320 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -21,6 +21,7 @@ import { DownloadRoutes } from "./settings/Downloads"; import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; import { QualityView } from "./settings/QualityView"; import { SettingsMenu } from "./settings/SettingsMenu"; +import { TranscriptView } from "./settings/TranscriptView"; import { WatchPartyView } from "./settings/WatchPartyView"; function SettingsOverlay({ id }: { id: string }) { @@ -95,6 +96,16 @@ function SettingsOverlay({ id }: { id: string }) { + + + + + diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 16040b65..d15d64cb 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -541,6 +541,14 @@ export function CaptionsView({ selected={selectedCaptionId === "pasted-caption"} /> + {selectedCaptionId && ( + router.navigate("/captions/transcript")} + > + {t("player.menus.subtitles.transcriptChoice")} + + )} +
{/* Search input */} diff --git a/src/components/player/atoms/settings/TranscriptView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx new file mode 100644 index 00000000..8f31412e --- /dev/null +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -0,0 +1,267 @@ +import classNames from "classnames"; +import Fuse from "fuse.js"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Menu } from "@/components/player/internals/ContextMenu"; +import { Input } from "@/components/player/internals/ContextMenu/Input"; +import { Link } from "@/components/player/internals/ContextMenu/Links"; +import { + captionIsVisible, + makeQueId, + parseSubtitles, + sanitize, +} from "@/components/player/utils/captions"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { usePlayerStore } from "@/stores/player/store"; +import { useSubtitleStore } from "@/stores/subtitles"; +import { durationExceedsHour, formatSeconds } from "@/utils/formatSeconds"; + +import { wordOverrides } from "../../Player"; + +export function TranscriptView({ id }: { id: string }) { + const { t } = useTranslation(); + const router = useOverlayRouter(id); + const display = usePlayerStore((s) => s.display); + const srtData = usePlayerStore((s) => s.caption.selected?.srtData); + const language = usePlayerStore((s) => s.caption.selected?.language); + const delay = useSubtitleStore((s) => s.delay); + const { duration: timeDuration, time } = usePlayerStore((s) => s.progress); + + const [searchQuery, setSearchQuery] = useState(""); + const [isAtTop, setIsAtTop] = useState(true); + const [isAtBottom, setIsAtBottom] = useState(false); + const carouselRef = useRef(null); + + const parsedCaptions = useMemo( + () => (srtData ? parseSubtitles(srtData, language) : []), + [srtData, language], + ); + + const showHours = useMemo(() => { + return durationExceedsHour(timeDuration); + }, [timeDuration]); + + const transcriptItems = useMemo( + () => + parsedCaptions.map(({ start, end, content: raw }, i) => { + const delayedStart = start / 1000 + delay; + const delayedEnd = end / 1000 + delay; + + const textWithNewlines = (raw || "") + .split(" ") + .map((word) => wordOverrides[word] ?? word) + .join(" ") + .replaceAll(/ i'/g, " I'") + .replaceAll(/\r?\n/g, " "); + + return { + key: makeQueId(i, start, end), + startMs: start, + endMs: end, + start: delayedStart, + end: delayedEnd, + raw: textWithNewlines, + }; + }), + [parsedCaptions, delay], + ); + + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) return transcriptItems; + const fuse = new Fuse(transcriptItems, { + includeScore: true, + isCaseSensitive: false, + shouldSort: false, + threshold: 0.2, + keys: ["raw"], + }); + return fuse.search(searchQuery).map((r) => r.item); + }, [transcriptItems, searchQuery]); + + // Determine currently visible caption to highlight + const { activeKey, nextKey } = useMemo(() => { + if (parsedCaptions.length === 0) + return { + activeKey: null as string | null, + nextKey: null as string | null, + }; + + const visibleIdx = parsedCaptions.findIndex(({ start, end }) => + captionIsVisible(start, end, delay, time), + ); + + // Next upcoming caption (first with start > now) + const startsSec = parsedCaptions.map((c) => c.start / 1000 + delay); + const nextIdx = startsSec.findIndex((s) => s > time); + + const key = + visibleIdx !== -1 + ? makeQueId( + visibleIdx, + parsedCaptions[visibleIdx]!.start, + parsedCaptions[visibleIdx]!.end, + ) + : null; // Show nothing during gaps + + let nextKeyLocal: string | null = null; + if (nextIdx !== -1) { + const n = parsedCaptions[nextIdx]!; + nextKeyLocal = makeQueId(nextIdx, n.start, n.end); + } + + return { activeKey: key, nextKey: nextKeyLocal }; + }, [parsedCaptions, delay, time]); + + const scrollTargetKey = useMemo(() => { + if (searchQuery.trim()) { + const nextFiltered = filteredItems.find((it) => it.start > time); + if (nextFiltered) return nextFiltered.key; + + const hasActive = filteredItems.some((it) => it.key === activeKey); + if (hasActive) return activeKey; + return null; + } + return nextKey ?? activeKey; + }, [filteredItems, searchQuery, time, nextKey, activeKey]); + + const checkScrollPosition = () => { + const container = carouselRef.current; + if (!container) return; + + setIsAtTop(container.scrollTop <= 0); + setIsAtBottom( + Math.abs( + container.scrollHeight - container.scrollTop - container.clientHeight, + ) < 2, + ); + }; + + useEffect(() => { + const container = carouselRef.current; + if (!container) return; + + container.addEventListener("scroll", checkScrollPosition); + checkScrollPosition(); // Check initial position + + return () => { + container.removeEventListener("scroll", checkScrollPosition); + }; + }, []); + + // Autoscroll with delay to prevent clashing with menu animation + const [didFirstScroll, setDidFirstScroll] = useState(false); + useEffect(() => { + if (!scrollTargetKey) return; + const scrollToStablePoint = (target: HTMLElement) => { + const container = carouselRef.current; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + + const containerHeight = container.clientHeight || 288; // 18rem = 288px + const desiredOffsetFromTop = Math.floor(containerHeight * 0.6); // half of the container height + + // Current absolute position of target center within container's scroll space + const targetCenterAbs = + container.scrollTop + + (targetRect.top - containerRect.top) + + targetRect.height / 2; + + // Desired scrollTop so that the target center sits at desired offset + let nextScrollTop = targetCenterAbs - desiredOffsetFromTop; + + const maxScrollTop = Math.max( + 0, + container.scrollHeight - containerHeight, + ); + nextScrollTop = Math.max(0, Math.min(nextScrollTop, maxScrollTop)); + + container.scrollTo({ top: nextScrollTop, behavior: "smooth" }); + }; + + const doScroll = () => { + const el = document.querySelector( + `[data-que-id="${scrollTargetKey}"]`, + ); + if (el) scrollToStablePoint(el); + }; + + if (!didFirstScroll) { + const timeout = setTimeout(() => { + doScroll(); + setDidFirstScroll(true); + }, 100); + return () => clearTimeout(timeout); + } + doScroll(); + }, [scrollTargetKey, didFirstScroll]); + + const handleSeek = (seconds: number) => { + display?.setTime(seconds); + }; + + return ( + <> + router.navigate("/captions")}> + {t("player.menus.subtitles.transcriptChoice")} + + + + +
+
+ {filteredItems.map((item) => { + const html = sanitize(item.raw.replaceAll(/\r?\n/g, "
"), { + ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"], + ADD_TAGS: ["v", "lang"], + ALLOWED_ATTR: ["title", "lang"], + }); + + const isActive = activeKey === item.key; + + return ( +
+ handleSeek(item.start)} + clickable + className="items-start" + active={isActive} + > + + {item.start < 0 || item.start > timeDuration + ? "N/A" + : formatSeconds(item.start, showHours)} + + + + + +
+ ); + })} +
+
+ + ); +} diff --git a/src/components/player/base/SubtitleView.tsx b/src/components/player/base/SubtitleView.tsx index b526806a..a9ea6978 100644 --- a/src/components/player/base/SubtitleView.tsx +++ b/src/components/player/base/SubtitleView.tsx @@ -11,7 +11,7 @@ import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles"; -const wordOverrides: Record = { +export const wordOverrides: Record = { // Example: i: "I", but in polish "i" is "and" so this is disabled. }; @@ -104,7 +104,7 @@ export function CaptionCue({ }} > parsedCaptions.filter(({ start, end }) => captionIsVisible(start, end, delay, videoTime), @@ -138,7 +138,7 @@ export function SubtitleRenderer() { return (
- {visibileCaptions.map(({ start, end, content }, i) => ( + {visibleCaptions.map(({ start, end, content }, i) => (