From 7c588744ef9f3be421951ab523ecfd2ca513abd8 Mon Sep 17 00:00:00 2001 From: Isra Date: Thu, 30 Oct 2025 11:32:52 +0800 Subject: [PATCH 01/13] Use 12 hour clock + box around time --- src/components/player/atoms/ProgressBar.tsx | 25 +++++++++++---------- src/components/player/atoms/Time.tsx | 7 +++++- src/utils/uses12HourClock.ts | 7 ++++++ 3 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 src/utils/uses12HourClock.ts diff --git a/src/components/player/atoms/ProgressBar.tsx b/src/components/player/atoms/ProgressBar.tsx index 685ef603..5187df49 100644 --- a/src/components/player/atoms/ProgressBar.tsx +++ b/src/components/player/atoms/ProgressBar.tsx @@ -22,7 +22,7 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) { offscreenLeft: 0, offscreenRight: 0, }); - const ref = useRef(null); + const ref = useRef(null); useEffect(() => { if (!ref.current) return; @@ -37,19 +37,23 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) { }); }, [props.at]); + // Keep time label width consistent and avoid recomputing + const formattedTime = useMemo( + () => formatSeconds(Math.max(props.at, 0), durationExceedsHour(props.at)), + [props.at], + ); + const transformX = + offsets.offscreenLeft > 0 ? offsets.offscreenLeft : -offsets.offscreenRight; + if (!props.show) return null; return (
-
+
}>
0 - ? offsets.offscreenLeft - : -offsets.offscreenRight - }px)`, + transform: `translateX(${transformX}px)`, }} > {currentThumbnail && ( @@ -58,11 +62,8 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) { className="h-24 border rounded-xl border-gray-800" /> )} -

- {formatSeconds( - Math.max(props.at, 0), - durationExceedsHour(props.at), - )} +

+ {formattedTime}

diff --git a/src/components/player/atoms/Time.tsx b/src/components/player/atoms/Time.tsx index cc48031c..5561c4a4 100644 --- a/src/components/player/atoms/Time.tsx +++ b/src/components/player/atoms/Time.tsx @@ -4,6 +4,7 @@ import { VideoPlayerButton } from "@/components/player/internals/Button"; import { VideoPlayerTimeFormat } from "@/stores/player/slices/interface"; import { usePlayerStore } from "@/stores/player/store"; import { durationExceedsHour, formatSeconds } from "@/utils/formatSeconds"; +import { uses12HourClock } from "@/utils/uses12HourClock"; export function Time(props: { short?: boolean }) { const timeFormat = usePlayerStore((s) => s.interface.timeFormat); @@ -63,7 +64,11 @@ export function Time(props: { short?: boolean }) { timeLeft, duration, formatParams: { - timeFinished: { hour: "numeric", minute: "numeric" }, + timeFinished: { + hour: "numeric", + minute: "numeric", + hour12: uses12HourClock(), + }, }, })} diff --git a/src/utils/uses12HourClock.ts b/src/utils/uses12HourClock.ts new file mode 100644 index 00000000..dc18e848 --- /dev/null +++ b/src/utils/uses12HourClock.ts @@ -0,0 +1,7 @@ +export function uses12HourClock() { + const parts = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + }).formatToParts(new Date()); + // If a dayPeriod ("AM"/"PM" or localized equivalent) appears, it's 12-hour + return parts.some((p) => p.type === "dayPeriod"); +} From b312405285b8d50f3adb9ef13b4ef347605b46d8 Mon Sep 17 00:00:00 2001 From: Isra Date: Thu, 30 Oct 2025 11:39:30 +0800 Subject: [PATCH 02/13] Fix ref --- src/components/player/atoms/ProgressBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/player/atoms/ProgressBar.tsx b/src/components/player/atoms/ProgressBar.tsx index 5187df49..90b91dcc 100644 --- a/src/components/player/atoms/ProgressBar.tsx +++ b/src/components/player/atoms/ProgressBar.tsx @@ -22,7 +22,7 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) { offscreenLeft: 0, offscreenRight: 0, }); - const ref = useRef(null); + const ref = useRef(null); useEffect(() => { if (!ref.current) return; @@ -50,7 +50,7 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) { return (
-
}> +
Date: Sun, 9 Nov 2025 13:45:24 +0800 Subject: [PATCH 03/13] Handle backtick earlier + Ignore commands when cmd/atl is pressed + prevent -0.0 as subtitle delay --- .../player/atoms/settings/CaptionSettingsView.tsx | 6 +++++- src/hooks/useGlobalKeyboardEvents.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/player/atoms/settings/CaptionSettingsView.tsx b/src/components/player/atoms/settings/CaptionSettingsView.tsx index 82569c74..c310bc66 100644 --- a/src/components/player/atoms/settings/CaptionSettingsView.tsx +++ b/src/components/player/atoms/settings/CaptionSettingsView.tsx @@ -181,7 +181,11 @@ export function CaptionDelay(props: { setIsFocused(true); }} > - {textTransformer(props.value.toFixed(props.decimalsAllowed ?? 0))} + {textTransformer( + props.value.toFixed(props.decimalsAllowed ?? 0) === "-0.0" + ? "0.0" + : props.value.toFixed(props.decimalsAllowed ?? 0), + )} )} diff --git a/src/hooks/useGlobalKeyboardEvents.ts b/src/hooks/useGlobalKeyboardEvents.ts index 68a401fc..6ca70cfa 100644 --- a/src/hooks/useGlobalKeyboardEvents.ts +++ b/src/hooks/useGlobalKeyboardEvents.ts @@ -29,6 +29,9 @@ export function useGlobalKeyboardEvents() { return; } + // Cancel if command or alt is pressed + if (event.metaKey || event.altKey) return; + // Handle backtick (`) key hold for keyboard commands if (event.key === "`") { // Prevent default browser behavior (console opening in some browsers) @@ -40,7 +43,7 @@ export function useGlobalKeyboardEvents() { // Show modal after 500ms hold holdTimeoutRef.current = setTimeout(() => { showKeyboardCommands(); - }, 500); + }, 150); } } From 8ee39a2774b01290f8cb8f56ca3a224712cce8fb Mon Sep 17 00:00:00 2001 From: Isra Date: Tue, 11 Nov 2025 09:26:31 +0800 Subject: [PATCH 04/13] Exclude thumbnail from fade effect --- src/assets/css/index.css | 9 +++------ src/components/player/atoms/ProgressBar.tsx | 2 +- src/components/player/internals/KeyboardEvents.tsx | 4 ++-- src/setup/imageFadeIn.ts | 4 ++-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/assets/css/index.css b/src/assets/css/index.css index f345dfcc..3c704d1a 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -428,18 +428,15 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower { @apply brightness-[500]; } -/* Image fade-in on load */ -img:not([src=""]) { +img:not(.no-fade):not([src=""]) { opacity: 0; transition: opacity 0.8s ease-in-out; } -/* Fade in when image has loaded class */ -img.loaded { +img.loaded:not(.no-fade) { opacity: 1; } -/* For images that are already cached/loaded, show them immediately */ -img[complete]:not([src=""]) { +img[complete]:not(.no-fade):not([src=""]) { opacity: 1; } diff --git a/src/components/player/atoms/ProgressBar.tsx b/src/components/player/atoms/ProgressBar.tsx index 90b91dcc..0c15fd82 100644 --- a/src/components/player/atoms/ProgressBar.tsx +++ b/src/components/player/atoms/ProgressBar.tsx @@ -59,7 +59,7 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) { {currentThumbnail && ( )}

diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx index 3fcb6404..627c3edb 100644 --- a/src/components/player/internals/KeyboardEvents.tsx +++ b/src/components/player/internals/KeyboardEvents.tsx @@ -178,7 +178,7 @@ export function KeyboardEvents() { if (evt.repeat) { return; } - + // Skip if a button is targeted if ( evt.target && @@ -243,7 +243,7 @@ export function KeyboardEvents() { if (evt.repeat) { return; } - + // Skip if a button is targeted if ( evt.target && diff --git a/src/setup/imageFadeIn.ts b/src/setup/imageFadeIn.ts index 79e779d9..158f2031 100644 --- a/src/setup/imageFadeIn.ts +++ b/src/setup/imageFadeIn.ts @@ -5,7 +5,7 @@ export function initializeImageFadeIn() { // Handle images that are already loaded (cached) const handleExistingImages = () => { - const images = document.querySelectorAll("img:not(.loaded)"); + const images = document.querySelectorAll(`img:not(.no-fade):not([src=""]`); images.forEach((img) => { const htmlImg = img as HTMLImageElement; if (htmlImg.complete && htmlImg.naturalHeight !== 0) { @@ -35,7 +35,7 @@ export function initializeImageFadeIn() { // Also check periodically for images that might have loaded // This handles edge cases where the load event might not fire const checkInterval = setInterval(() => { - const images = document.querySelectorAll("img:not(.loaded)"); + const images = document.querySelectorAll(`img:not(.no-fade):not([src=""]`); if (images.length === 0) { clearInterval(checkInterval); return; From 2718de34eb9a7efd24623db39b56ec609ce470e8 Mon Sep 17 00:00:00 2001 From: Isra Date: Wed, 12 Nov 2025 13:26:07 +0800 Subject: [PATCH 05/13] Bring back comments --- src/assets/css/index.css | 3 +++ src/components/player/atoms/settings/CaptionsView.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assets/css/index.css b/src/assets/css/index.css index 3c704d1a..cf68b39e 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -428,15 +428,18 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower { @apply brightness-[500]; } +/* Image fade-in on load */ img:not(.no-fade):not([src=""]) { opacity: 0; transition: opacity 0.8s ease-in-out; } +/* Fade in when image has loaded class */ img.loaded:not(.no-fade) { opacity: 1; } +/* For images that are already cached/loaded, show them immediately */ img[complete]:not(.no-fade):not([src=""]) { opacity: 1; } diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index a9ff591c..16040b65 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -411,7 +411,7 @@ export function CaptionsView({ }; try { - await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2)); + await navigator.clipboard.writeText(JSON.stringify(copyData)); // Could add a toast notification here if needed } catch (err) { console.error("Failed to copy subtitle data:", err); From a6ecf826a65c13d2d34fe1ada4789b3625e970d0 Mon Sep 17 00:00:00 2001 From: Isra Date: Thu, 13 Nov 2025 10:28:44 +0800 Subject: [PATCH 06/13] Trancript view (incomplete) --- src/components/player/atoms/Settings.tsx | 6 + .../player/atoms/settings/SettingsMenu.tsx | 3 + .../atoms/settings/TranscriptSettingsView.tsx | 204 ++++++++++++++++++ src/components/player/base/SubtitleView.tsx | 8 +- 4 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 src/components/player/atoms/settings/TranscriptSettingsView.tsx 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) => ( Date: Mon, 24 Nov 2025 17:23:17 +0800 Subject: [PATCH 07/13] Proper scrolling --- src/components/player/atoms/Settings.tsx | 4 +- ...iptSettingsView.tsx => TranscriptView.tsx} | 112 ++++++++++++------ 2 files changed, 76 insertions(+), 40 deletions(-) rename src/components/player/atoms/settings/{TranscriptSettingsView.tsx => TranscriptView.tsx} (67%) diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index e5856bc5..f5f72ff9 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -21,7 +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 { TranscriptView } from "./settings/TranscriptView"; import { WatchPartyView } from "./settings/WatchPartyView"; function SettingsOverlay({ id }: { id: string }) { @@ -98,7 +98,7 @@ function SettingsOverlay({ id }: { id: string }) { - + diff --git a/src/components/player/atoms/settings/TranscriptSettingsView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx similarity index 67% rename from src/components/player/atoms/settings/TranscriptSettingsView.tsx rename to src/components/player/atoms/settings/TranscriptView.tsx index 6e1997a1..ae73ede0 100644 --- a/src/components/player/atoms/settings/TranscriptSettingsView.tsx +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -17,7 +17,7 @@ import { durationExceedsHour, formatSeconds } from "@/utils/formatSeconds"; import { wordOverrides } from "../../Player"; -export function TranscriptSettingsView({ id }: { id: string }) { +export function TranscriptView({ id }: { id: string }) { const router = useOverlayRouter(id); const display = usePlayerStore((s) => s.display); const srtData = usePlayerStore((s) => s.caption.selected?.srtData); @@ -47,7 +47,7 @@ export function TranscriptSettingsView({ id }: { id: string }) { .map((word) => wordOverrides[word] ?? word) .join(" ") .replaceAll(/ i'/g, " I'") - .replaceAll("\n", ""); + .replaceAll(/\r?\n/g, ""); return { key: makeQueId(i, start, end), @@ -73,7 +73,7 @@ export function TranscriptSettingsView({ id }: { id: string }) { return fuse.search(searchQuery).map((r) => r.item); }, [transcriptItems, searchQuery]); - // Determine currently visible caption with small buffer + // Determine currently visible caption to highlight const { activeKey, nextKey } = useMemo(() => { if (parsedCaptions.length === 0) return { @@ -85,36 +85,18 @@ export function TranscriptSettingsView({ id }: { id: string }) { 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 startsSec = parsedCaptions.map((c) => c.start / 1000 + delay); 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); - } + 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) { @@ -137,14 +119,66 @@ export function TranscriptSettingsView({ id }: { id: string }) { return nextKey ?? activeKey; }, [filteredItems, searchQuery, time, nextKey, activeKey]); - // Auto-scroll + // Autoscroll with delay to prevent clashing with menu animation + const [didFirstScroll, setDidFirstScroll] = useState(false); useEffect(() => { if (!scrollTargetKey) return; - const el = document.querySelector( - `[data-que-id="${scrollTargetKey}"]`, - ); - if (el) el.scrollIntoView({ block: "nearest", behavior: "instant" }); - }, [scrollTargetKey]); + // Find nearest scrollable parent + const getScrollableParent = (node: HTMLElement | null): HTMLElement => { + let el: HTMLElement | null = node; + while (el && el.parentElement) { + const style = window.getComputedStyle(el); + if (/(auto|scroll)/.test(style.overflowY)) return el; + el = el.parentElement as HTMLElement; + } + return ( + (document.scrollingElement as HTMLElement) || + (document.documentElement as HTMLElement) + ); + }; + + const scrollToStablePoint = (target: HTMLElement) => { + const container = getScrollableParent(target); + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + + const containerHeight = container.clientHeight || 452; + const desiredOffsetFromTop = Math.floor(containerHeight * 0.85); // ~3/4 down + + // 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 t = setTimeout(() => { + doScroll(); + setDidFirstScroll(true); + }, 100); + return () => clearTimeout(t); + } + doScroll(); + }, [scrollTargetKey, didFirstScroll]); const handleSeek = (seconds: number) => { display?.setTime(seconds); @@ -156,7 +190,7 @@ export function TranscriptSettingsView({ id }: { id: string }) { Transcript -
+
@@ -179,7 +213,9 @@ export function TranscriptSettingsView({ id }: { id: string }) { active={isActive} > - {formatSeconds(item.start, showHours)} + {item.start < 0 || item.start > timeDuration + ? "N/A" + : formatSeconds(item.start, showHours)} Date: Tue, 25 Nov 2025 09:11:28 +0800 Subject: [PATCH 08/13] Move to correct menu --- src/components/player/atoms/Settings.tsx | 9 +++++++-- .../player/atoms/settings/PlaybackSettingsView.tsx | 5 +++++ src/components/player/atoms/settings/SettingsMenu.tsx | 3 --- src/components/player/atoms/settings/TranscriptView.tsx | 4 ++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index f5f72ff9..e82c4563 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -91,12 +91,17 @@ function SettingsOverlay({ id }: { id: string }) { - + - + diff --git a/src/components/player/atoms/settings/PlaybackSettingsView.tsx b/src/components/player/atoms/settings/PlaybackSettingsView.tsx index 0d38840b..e46eebdc 100644 --- a/src/components/player/atoms/settings/PlaybackSettingsView.tsx +++ b/src/components/player/atoms/settings/PlaybackSettingsView.tsx @@ -308,6 +308,11 @@ export function PlaybackSettingsView({ id }: { id: string }) { {t("settings.preferences.thumbnailLabel")} )} + router.navigate("/playback/transcript")} + > + Transcript +
diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index ce2fd6ff..0a412dd4 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -151,9 +151,6 @@ 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/TranscriptView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx index ae73ede0..d824df48 100644 --- a/src/components/player/atoms/settings/TranscriptView.tsx +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -47,7 +47,7 @@ export function TranscriptView({ id }: { id: string }) { .map((word) => wordOverrides[word] ?? word) .join(" ") .replaceAll(/ i'/g, " I'") - .replaceAll(/\r?\n/g, ""); + .replaceAll(/\r?\n/g, " "); return { key: makeQueId(i, start, end), @@ -190,7 +190,7 @@ export function TranscriptView({ id }: { id: string }) { Transcript -
+
From c74fbe385d408471a42071fba7d47f97567ac144 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:23:19 -0700 Subject: [PATCH 09/13] Revert "Move to correct menu" This reverts commit 5caae343dfffb14887ca558508bffae65cdfbda0. --- src/components/player/atoms/Settings.tsx | 9 ++------- .../player/atoms/settings/PlaybackSettingsView.tsx | 5 ----- src/components/player/atoms/settings/SettingsMenu.tsx | 3 +++ src/components/player/atoms/settings/TranscriptView.tsx | 4 ++-- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index e82c4563..f5f72ff9 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -91,17 +91,12 @@ function SettingsOverlay({ id }: { id: string }) { - + - + diff --git a/src/components/player/atoms/settings/PlaybackSettingsView.tsx b/src/components/player/atoms/settings/PlaybackSettingsView.tsx index e46eebdc..0d38840b 100644 --- a/src/components/player/atoms/settings/PlaybackSettingsView.tsx +++ b/src/components/player/atoms/settings/PlaybackSettingsView.tsx @@ -308,11 +308,6 @@ export function PlaybackSettingsView({ id }: { id: string }) { {t("settings.preferences.thumbnailLabel")} )} - router.navigate("/playback/transcript")} - > - Transcript -
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/TranscriptView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx index d824df48..ae73ede0 100644 --- a/src/components/player/atoms/settings/TranscriptView.tsx +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -47,7 +47,7 @@ export function TranscriptView({ id }: { id: string }) { .map((word) => wordOverrides[word] ?? word) .join(" ") .replaceAll(/ i'/g, " I'") - .replaceAll(/\r?\n/g, " "); + .replaceAll(/\r?\n/g, ""); return { key: makeQueId(i, start, end), @@ -190,7 +190,7 @@ export function TranscriptView({ id }: { id: string }) { Transcript -
+
From 19af76031755c24931d6d73565a9a13325b27ae5 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:26:00 -0700 Subject: [PATCH 10/13] Update TranscriptView.tsx --- src/components/player/atoms/settings/TranscriptView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/player/atoms/settings/TranscriptView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx index ae73ede0..d824df48 100644 --- a/src/components/player/atoms/settings/TranscriptView.tsx +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -47,7 +47,7 @@ export function TranscriptView({ id }: { id: string }) { .map((word) => wordOverrides[word] ?? word) .join(" ") .replaceAll(/ i'/g, " I'") - .replaceAll(/\r?\n/g, ""); + .replaceAll(/\r?\n/g, " "); return { key: makeQueId(i, start, end), @@ -190,7 +190,7 @@ export function TranscriptView({ id }: { id: string }) { Transcript -
+
From 64abb255601d90ecb53d12346a0945fba0e25d24 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:33:24 -0700 Subject: [PATCH 11/13] move to captions view --- src/assets/locales/en.json | 1 + src/components/player/atoms/settings/CaptionsView.tsx | 6 ++++++ src/components/player/atoms/settings/SettingsMenu.tsx | 3 --- .../player/atoms/settings/TranscriptView.tsx | 10 ++++++---- 4 files changed, 13 insertions(+), 7 deletions(-) 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/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 16040b65..902f9d69 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -541,6 +541,12 @@ export function CaptionsView({ selected={selectedCaptionId === "pasted-caption"} /> + {selectedCaptionId && ( + router.navigate("/transcript")}> + {t("player.menus.subtitles.transcriptChoice")} + + )} +
{/* Search input */} diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index ce2fd6ff..0a412dd4 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -151,9 +151,6 @@ 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/TranscriptView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx index d824df48..b9c4b689 100644 --- a/src/components/player/atoms/settings/TranscriptView.tsx +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -1,5 +1,6 @@ import Fuse from "fuse.js"; import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Menu } from "@/components/player/internals/ContextMenu"; import { Input } from "@/components/player/internals/ContextMenu/Input"; @@ -18,6 +19,7 @@ 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); @@ -171,11 +173,11 @@ export function TranscriptView({ id }: { id: string }) { }; if (!didFirstScroll) { - const t = setTimeout(() => { + const timeout = setTimeout(() => { doScroll(); setDidFirstScroll(true); }, 100); - return () => clearTimeout(t); + return () => clearTimeout(timeout); } doScroll(); }, [scrollTargetKey, didFirstScroll]); @@ -186,8 +188,8 @@ export function TranscriptView({ id }: { id: string }) { return ( <> - router.navigate("/")}> - Transcript + router.navigate("/captions")}> + {t("player.menus.subtitles.transcriptChoice")}
From 4151b2fdec57378b1428ffa5952fd4b3f8353283 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:52:41 -0700 Subject: [PATCH 12/13] add gradient to top and bottom --- .../player/atoms/settings/TranscriptView.tsx | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/components/player/atoms/settings/TranscriptView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx index b9c4b689..c30ede83 100644 --- a/src/components/player/atoms/settings/TranscriptView.tsx +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -1,5 +1,6 @@ +import classNames from "classnames"; import Fuse from "fuse.js"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Menu } from "@/components/player/internals/ContextMenu"; @@ -28,6 +29,9 @@ export function TranscriptView({ id }: { id: string }) { 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) : []), @@ -121,6 +125,30 @@ export function TranscriptView({ id }: { id: string }) { 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(() => { @@ -192,11 +220,20 @@ export function TranscriptView({ id }: { id: string }) { {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"], @@ -236,7 +273,7 @@ export function TranscriptView({ id }: { id: string }) { ); })}
- +
); } From c6182e6ebbc49981a6f4274b13c5e177dee6a269 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:59:40 -0700 Subject: [PATCH 13/13] simplify scrolling and cleanup some bugs --- src/components/player/atoms/Settings.tsx | 7 +++++- .../player/atoms/settings/CaptionsView.tsx | 4 +++- .../player/atoms/settings/TranscriptView.tsx | 22 +++++-------------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index f5f72ff9..a7bad320 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -96,7 +96,12 @@ function SettingsOverlay({ id }: { id: string }) { - + diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 902f9d69..d15d64cb 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -542,7 +542,9 @@ export function CaptionsView({ /> {selectedCaptionId && ( - router.navigate("/transcript")}> + router.navigate("/captions/transcript")} + > {t("player.menus.subtitles.transcriptChoice")} )} diff --git a/src/components/player/atoms/settings/TranscriptView.tsx b/src/components/player/atoms/settings/TranscriptView.tsx index c30ede83..8f31412e 100644 --- a/src/components/player/atoms/settings/TranscriptView.tsx +++ b/src/components/player/atoms/settings/TranscriptView.tsx @@ -153,27 +153,15 @@ export function TranscriptView({ id }: { id: string }) { const [didFirstScroll, setDidFirstScroll] = useState(false); useEffect(() => { if (!scrollTargetKey) return; - // Find nearest scrollable parent - const getScrollableParent = (node: HTMLElement | null): HTMLElement => { - let el: HTMLElement | null = node; - while (el && el.parentElement) { - const style = window.getComputedStyle(el); - if (/(auto|scroll)/.test(style.overflowY)) return el; - el = el.parentElement as HTMLElement; - } - return ( - (document.scrollingElement as HTMLElement) || - (document.documentElement as HTMLElement) - ); - }; - const scrollToStablePoint = (target: HTMLElement) => { - const container = getScrollableParent(target); + const container = carouselRef.current; + if (!container) return; + const containerRect = container.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); - const containerHeight = container.clientHeight || 452; - const desiredOffsetFromTop = Math.floor(containerHeight * 0.85); // ~3/4 down + 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 =