diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index b8aad79e..e5856bc5 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 { TranscriptSettingsView } from "./settings/TranscriptSettingsView"; import { WatchPartyView } from "./settings/WatchPartyView"; function SettingsOverlay({ id }: { id: string }) { @@ -95,6 +96,11 @@ function SettingsOverlay({ id }: { id: string }) { + + + + + diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index 0a412dd4..ce2fd6ff 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -151,6 +151,9 @@ export function SettingsMenu({ id }: { id: string }) { router.navigate("/playback")}> {t("player.menus.settings.playbackItem")} + router.navigate("/transcript")}> + Transcript + ); diff --git a/src/components/player/atoms/settings/TranscriptSettingsView.tsx b/src/components/player/atoms/settings/TranscriptSettingsView.tsx new file mode 100644 index 00000000..6e1997a1 --- /dev/null +++ b/src/components/player/atoms/settings/TranscriptSettingsView.tsx @@ -0,0 +1,204 @@ +import Fuse from "fuse.js"; +import { useEffect, useMemo, useState } from "react"; + +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 TranscriptSettingsView({ id }: { id: string }) { + 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 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("\n", ""); + + 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 with small buffer + 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), + ); + + const startsSec = parsedCaptions.map((c) => c.start / 1000 + delay); + const endsSec = parsedCaptions.map((c) => c.end / 1000 + delay); + + // Next upcoming caption (first with start > now) + const nextIdx = startsSec.findIndex((s) => s > time); + const prevIdx = + nextIdx === -1 ? parsedCaptions.length - 1 : Math.max(0, nextIdx - 1); + + let key: string | null = null; + + if (visibleIdx !== -1) { + const v = parsedCaptions[visibleIdx]!; + key = makeQueId(visibleIdx, v.start, v.end); + } else if (nextIdx !== -1) { + // If the gap between previous end and next start is < 2s, immediately jump to the next subtitle + const gap = startsSec[nextIdx]! - endsSec[Math.max(0, nextIdx - 1)]!; + if (nextIdx > 0 && gap < 2) { + const n = parsedCaptions[nextIdx]!; + key = makeQueId(nextIdx, n.start, n.end); + } else { + // Otherwise keep previous highlighted + const p = parsedCaptions[prevIdx]!; + key = makeQueId(prevIdx, p.start, p.end); + } + } else { + // No next item, so keep last highlighted + const lastIdx = parsedCaptions.length - 1; + const last = parsedCaptions[lastIdx]!; + key = makeQueId(lastIdx, last.start, last.end); + } + + 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]); + + // Auto-scroll + useEffect(() => { + if (!scrollTargetKey) return; + const el = document.querySelector( + `[data-que-id="${scrollTargetKey}"]`, + ); + if (el) el.scrollIntoView({ block: "nearest", behavior: "instant" }); + }, [scrollTargetKey]); + + const handleSeek = (seconds: number) => { + display?.setTime(seconds); + }; + + return ( + <> + router.navigate("/")}> + Transcript + + +
+ +
+ +
+ {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} + > + + {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) => (