mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 02:42:26 +00:00
commit
2252ab9fed
5 changed files with 291 additions and 4 deletions
|
|
@ -764,6 +764,7 @@
|
||||||
"SourceChoice": "Source Subtitles",
|
"SourceChoice": "Source Subtitles",
|
||||||
"OpenSubtitlesChoice": "External Subtitles",
|
"OpenSubtitlesChoice": "External Subtitles",
|
||||||
"loadingExternal": "Loading external subtitles...",
|
"loadingExternal": "Loading external subtitles...",
|
||||||
|
"transcriptChoice": "Transcript",
|
||||||
"settings": {
|
"settings": {
|
||||||
"backlink": "Custom subtitles",
|
"backlink": "Custom subtitles",
|
||||||
"delay": "Subtitle delay",
|
"delay": "Subtitle delay",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { DownloadRoutes } from "./settings/Downloads";
|
||||||
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
|
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
|
||||||
import { QualityView } from "./settings/QualityView";
|
import { QualityView } from "./settings/QualityView";
|
||||||
import { SettingsMenu } from "./settings/SettingsMenu";
|
import { SettingsMenu } from "./settings/SettingsMenu";
|
||||||
|
import { TranscriptView } from "./settings/TranscriptView";
|
||||||
import { WatchPartyView } from "./settings/WatchPartyView";
|
import { WatchPartyView } from "./settings/WatchPartyView";
|
||||||
|
|
||||||
function SettingsOverlay({ id }: { id: string }) {
|
function SettingsOverlay({ id }: { id: string }) {
|
||||||
|
|
@ -95,6 +96,16 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
<PlaybackSettingsView id={id} />
|
<PlaybackSettingsView id={id} />
|
||||||
</Menu.Card>
|
</Menu.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
|
<OverlayPage
|
||||||
|
id={id}
|
||||||
|
path="/captions/transcript"
|
||||||
|
width={343}
|
||||||
|
height={452}
|
||||||
|
>
|
||||||
|
<Menu.CardWithScrollable>
|
||||||
|
<TranscriptView id={id} />
|
||||||
|
</Menu.CardWithScrollable>
|
||||||
|
</OverlayPage>
|
||||||
<DownloadRoutes id={id} />
|
<DownloadRoutes id={id} />
|
||||||
<OverlayPage id={id} path="/watchparty" width={343} height={455}>
|
<OverlayPage id={id} path="/watchparty" width={343} height={455}>
|
||||||
<Menu.CardWithScrollable>
|
<Menu.CardWithScrollable>
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,14 @@ export function CaptionsView({
|
||||||
selected={selectedCaptionId === "pasted-caption"}
|
selected={selectedCaptionId === "pasted-caption"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{selectedCaptionId && (
|
||||||
|
<Menu.ChevronLink
|
||||||
|
onClick={() => router.navigate("/captions/transcript")}
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.transcriptChoice")}
|
||||||
|
</Menu.ChevronLink>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="h-1" />
|
<div className="h-1" />
|
||||||
|
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
|
|
|
||||||
267
src/components/player/atoms/settings/TranscriptView.tsx
Normal file
267
src/components/player/atoms/settings/TranscriptView.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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<HTMLElement>(
|
||||||
|
`[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 (
|
||||||
|
<>
|
||||||
|
<Menu.BackLink onClick={() => router.navigate("/captions")}>
|
||||||
|
{t("player.menus.subtitles.transcriptChoice")}
|
||||||
|
</Menu.BackLink>
|
||||||
|
<Menu.Section>
|
||||||
|
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||||
|
</Menu.Section>
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className={classNames(
|
||||||
|
"max-h-[18rem] overflow-y-auto",
|
||||||
|
"vertical-carousel-container",
|
||||||
|
{
|
||||||
|
"hide-top-gradient": isAtTop,
|
||||||
|
"hide-bottom-gradient": isAtBottom,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1 pb-4">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const html = sanitize(item.raw.replaceAll(/\r?\n/g, "<br />"), {
|
||||||
|
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
|
||||||
|
ADD_TAGS: ["v", "lang"],
|
||||||
|
ALLOWED_ATTR: ["title", "lang"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isActive = activeKey === item.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.key} data-que-id={item.key}>
|
||||||
|
<Link
|
||||||
|
onClick={() => handleSeek(item.start)}
|
||||||
|
clickable
|
||||||
|
className="items-start"
|
||||||
|
active={isActive}
|
||||||
|
>
|
||||||
|
<span className="mr-3 flex-none w-[4.5rem] h-[1.75rem] flex items-center justify-center px-0 leading-tight rounded-md bg-video-context-light bg-opacity-20 text-video-context-type-main font-normal whitespace-nowrap overflow-hidden text-sm">
|
||||||
|
{item.start < 0 || item.start > timeDuration
|
||||||
|
? "N/A"
|
||||||
|
: formatSeconds(item.start, showHours)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "flex-1 text-white font-semibold text-sm"
|
||||||
|
: "flex-1 text-video-context-type-main text-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }} // eslint-disable-line react/no-danger
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ import { usePlayerStore } from "@/stores/player/store";
|
||||||
import { usePreferencesStore } from "@/stores/preferences";
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
|
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
|
||||||
|
|
||||||
const wordOverrides: Record<string, string> = {
|
export const wordOverrides: Record<string, string> = {
|
||||||
// Example: i: "I", but in polish "i" is "and" so this is disabled.
|
// Example: i: "I", but in polish "i" is "and" so this is disabled.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ export function CaptionCue({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
// its sanitised a few lines up
|
// Sanitised a few lines up
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: parsedHtml,
|
__html: parsedHtml,
|
||||||
|
|
@ -128,7 +128,7 @@ export function SubtitleRenderer() {
|
||||||
[srtData, language],
|
[srtData, language],
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibileCaptions = useMemo(
|
const visibleCaptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
parsedCaptions.filter(({ start, end }) =>
|
parsedCaptions.filter(({ start, end }) =>
|
||||||
captionIsVisible(start, end, delay, videoTime),
|
captionIsVisible(start, end, delay, videoTime),
|
||||||
|
|
@ -138,7 +138,7 @@ export function SubtitleRenderer() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{visibileCaptions.map(({ start, end, content }, i) => (
|
{visibleCaptions.map(({ start, end, content }, i) => (
|
||||||
<CaptionCue
|
<CaptionCue
|
||||||
key={makeQueId(i, start, end)}
|
key={makeQueId(i, start, end)}
|
||||||
text={content}
|
text={content}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue