This commit is contained in:
vlOd 2026-01-07 16:07:02 +00:00 committed by GitHub
commit 2b772ae4ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 929 additions and 57 deletions

11
.vscode/settings.json vendored
View file

@ -5,7 +5,16 @@
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View file

@ -44,7 +44,7 @@ importers:
version: 1.8.0
'@p-stream/providers':
specifier: github:p-stream/providers#production
version: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0
version: https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103
'@plasmohq/messaging':
specifier: ^0.6.2
version: 0.6.2(react@18.3.1)
@ -1207,8 +1207,8 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0':
resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0}
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103':
resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103}
version: 3.2.0
'@pkgjs/parseargs@0.11.0':
@ -3750,8 +3750,8 @@ packages:
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
@ -5523,7 +5523,7 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0':
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103':
dependencies:
abort-controller: 3.0.0
cheerio: 1.0.0-rc.12
@ -5536,7 +5536,7 @@ snapshots:
json5: 2.2.3
nanoid: 3.3.11
node-fetch: 3.3.2
set-cookie-parser: 2.7.1
set-cookie-parser: 2.7.2
unpacker: 1.0.1
'@pkgjs/parseargs@0.11.0':
@ -8215,7 +8215,7 @@ snapshots:
dependencies:
randombytes: 2.1.0
set-cookie-parser@2.7.1: {}
set-cookie-parser@2.7.2: {}
set-function-length@1.2.2:
dependencies:

View file

@ -859,7 +859,10 @@
"useNativeSubtitles": "Native video subtitles",
"useNativeSubtitlesDescription": "Broadcast subtitles for native fullscreen and PiP",
"delayLate": "Heard audio",
"delayEarly": "Saw caption"
"delayEarly": "Saw caption",
"translate": {
"title": "Translate from {{language}}"
}
},
"watchparty": {
"watchpartyItem": "Watch Party",

View file

@ -83,6 +83,7 @@ export enum Icons {
RELOAD = "reload",
REPEAT = "repeat",
PLUS = "plus",
TRANSLATE = "translate",
THUMBS_UP = "thumbsUp",
THUMBS_DOWN = "thumbsDown",
}
@ -185,6 +186,7 @@ const iconList: Record<Icons, string> = {
reload: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M544.1 256L552 256C565.3 256 576 245.3 576 232L576 88C576 78.3 570.2 69.5 561.2 65.8C552.2 62.1 541.9 64.2 535 71L483.3 122.8C439 86.1 382 64 320 64C191 64 84.3 159.4 66.6 283.5C64.1 301 76.2 317.2 93.7 319.7C111.2 322.2 127.4 310 129.9 292.6C143.2 199.5 223.3 128 320 128C364.4 128 405.2 143 437.7 168.3L391 215C384.1 221.9 382.1 232.2 385.8 241.2C389.5 250.2 398.3 256 408 256L544.1 256zM573.5 356.5C576 339 563.8 322.8 546.4 320.3C529 317.8 512.7 330 510.2 347.4C496.9 440.4 416.8 511.9 320.1 511.9C275.7 511.9 234.9 496.9 202.4 471.6L249 425C255.9 418.1 257.9 407.8 254.2 398.8C250.5 389.8 241.7 384 232 384L88 384C74.7 384 64 394.7 64 408L64 552C64 561.7 69.8 570.5 78.8 574.2C87.8 577.9 98.1 575.8 105 569L156.8 517.2C201 553.9 258 576 320 576C449 576 555.7 480.6 573.4 356.5z"/></svg>`,
repeat: `<svg viewBox="0 0 24 24" width="1em" height="1em" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>`,
plus: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1em" height="1em" fill="currentColor"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M352 128C352 110.3 337.7 96 320 96C302.3 96 288 110.3 288 128L288 288L128 288C110.3 288 96 302.3 96 320C96 337.7 110.3 352 128 352L288 352L288 512C288 529.7 302.3 544 320 544C337.7 544 352 529.7 352 512L352 352L512 352C529.7 352 544 337.7 544 320C544 302.3 529.7 288 512 288L352 288L352 128z"/></svg>`,
translate: `<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 52 52" data-name="Layer 1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"><path d="M39,18.67H35.42l-4.2,11.12A29,29,0,0,1,20.6,24.91a28.76,28.76,0,0,0,7.11-14.49h5.21a2,2,0,0,0,0-4H19.67V2a2,2,0,1,0-4,0V6.42H2.41a2,2,0,0,0,0,4H7.63a28.73,28.73,0,0,0,7.1,14.49A29.51,29.51,0,0,1,3.27,30a2,2,0,0,0,.43,4,1.61,1.61,0,0,0,.44-.05,32.56,32.56,0,0,0,13.53-6.25,32,32,0,0,0,12.13,5.9L22.83,52H28l2.7-7.76H43.64L46.37,52h5.22Zm-15.3-8.25a23.76,23.76,0,0,1-6,11.86,23.71,23.71,0,0,1-6-11.86Zm8.68,29.15,4.83-13.83L42,39.57Z"/></svg>`,
thumbsUp: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M144 224C161.7 224 176 238.3 176 256L176 512C176 529.7 161.7 544 144 544L96 544C78.3 544 64 529.7 64 512L64 256C64 238.3 78.3 224 96 224L144 224zM334.6 80C361.9 80 384 102.1 384 129.4L384 133.6C384 140.4 382.7 147.2 380.2 153.5L352 224L512 224C538.5 224 560 245.5 560 272C560 291.7 548.1 308.6 531.1 316C548.1 323.4 560 340.3 560 360C560 383.4 543.2 402.9 521 407.1C525.4 414.4 528 422.9 528 432C528 454.2 513 472.8 492.6 478.3C494.8 483.8 496 489.8 496 496C496 522.5 474.5 544 448 544L360.1 544C323.8 544 288.5 531.6 260.2 508.9L248 499.2C232.8 487.1 224 468.7 224 449.2L224 262.6C224 247.7 227.5 233 234.1 219.7L290.3 107.3C298.7 90.6 315.8 80 334.6 80z"/></svg>`,
thumbsDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M448 96C474.5 96 496 117.5 496 144C496 150.3 494.7 156.2 492.6 161.7C513 167.2 528 185.8 528 208C528 217.1 525.4 225.6 521 232.9C543.2 237.1 560 256.6 560 280C560 299.7 548.1 316.6 531.1 324C548.1 331.4 560 348.3 560 368C560 394.5 538.5 416 512 416L352 416L380.2 486.4C382.7 492.7 384 499.5 384 506.3L384 510.5C384 537.8 361.9 559.9 334.6 559.9C315.9 559.9 298.8 549.3 290.4 532.6L234.1 420.3C227.4 407 224 392.3 224 377.4L224 190.8C224 171.4 232.9 153 248 140.8L260.2 131.1C288.6 108.4 323.8 96 360.1 96L448 96zM144 160C161.7 160 176 174.3 176 192L176 448C176 465.7 161.7 480 144 480L96 480C78.3 480 64 465.7 64 448L64 192C64 174.3 78.3 160 96 160L144 160z"/></svg>`,
};

View file

@ -2,6 +2,7 @@ import { useEffect } from "react";
import { Icons } from "@/components/Icon";
import { OverlayAnchor } from "@/components/overlays/OverlayAnchor";
import { useCaptions } from "@/components/player/hooks/useCaptions";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
@ -9,11 +10,28 @@ import { usePlayerStore } from "@/stores/player/store";
export function Captions() {
const router = useOverlayRouter("settings");
const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay);
const { setDirectCaption } = useCaptions();
const translateTask = usePlayerStore((s) => s.caption.translateTask);
useEffect(() => {
setHasOpenOverlay(router.isRouterActive);
}, [setHasOpenOverlay, router.isRouterActive]);
useEffect(() => {
if (!translateTask) {
return;
}
if (translateTask.done) {
const tCaption = translateTask.translatedCaption!;
setDirectCaption(tCaption, {
id: tCaption.id,
url: "",
language: tCaption.language,
needsProxy: false,
});
}
}, [translateTask, setDirectCaption]);
return (
<OverlayAnchor id={router.id}>
<VideoPlayerButton

View file

@ -12,6 +12,7 @@ import {
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { AudioView } from "./settings/AudioView";
@ -23,11 +24,14 @@ import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
import { QualityView } from "./settings/QualityView";
import { SettingsMenu } from "./settings/SettingsMenu";
import { TranscriptView } from "./settings/TranscriptView";
import { TranslateSubtitleView } from "./settings/TranslateSubtitleView";
import { WatchPartyView } from "./settings/WatchPartyView";
function SettingsOverlay({ id }: { id: string }) {
const [chosenSourceId, setChosenSourceId] = useState<string | null>(null);
const [chosenLanguage, setChosenLanguage] = useState<string | null>(null);
const [captionToTranslate, setCaptionToTranslate] =
useState<CaptionListItem | null>(null);
const router = useOverlayRouter(id);
// reset source id and language when going to home or closing overlay
@ -76,7 +80,7 @@ function SettingsOverlay({ id }: { id: string }) {
<OverlayPage
id={id}
path="/captionsOverlay/languagesOverlay"
width={343}
width={443}
height={452}
>
<Menu.CardWithScrollable>
@ -84,6 +88,23 @@ function SettingsOverlay({ id }: { id: string }) {
<LanguageSubtitlesView
id={id}
language={chosenLanguage}
onTranslateSubtitle={setCaptionToTranslate}
overlayBackLink
/>
)}
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage
id={id}
path="/captionsOverlay/translateSubtitleOverlay"
width={343}
height={452}
>
<Menu.CardWithScrollable>
{captionToTranslate && (
<TranslateSubtitleView
id={id}
caption={captionToTranslate}
overlayBackLink
/>
)}
@ -133,12 +154,28 @@ function SettingsOverlay({ id }: { id: string }) {
<OverlayPage
id={id}
path="/captions/languages"
width={343}
width={443}
height={452}
>
<Menu.CardWithScrollable>
{chosenLanguage && (
<LanguageSubtitlesView id={id} language={chosenLanguage} />
<LanguageSubtitlesView
id={id}
language={chosenLanguage}
onTranslateSubtitle={setCaptionToTranslate}
/>
)}
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage
id={id}
path="/captions/translateSubtitle"
width={343}
height={452}
>
<Menu.CardWithScrollable>
{captionToTranslate && (
<TranslateSubtitleView id={id} caption={captionToTranslate} />
)}
</Menu.CardWithScrollable>
</OverlayPage>

View file

@ -9,6 +9,7 @@ import { subtitleTypeList } from "@/backend/helpers/subs";
import { FileDropHandler } from "@/components/DropFile";
import { FlagIcon } from "@/components/FlagIcon";
import { Icon, Icons } from "@/components/Icon";
import { Spinner } from "@/components/layout/Spinner";
import { useCaptions } from "@/components/player/hooks/useCaptions";
import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
@ -26,14 +27,18 @@ import {
sortLangCodes,
} from "@/utils/language";
export function CaptionOption(props: {
/* eslint-disable react/no-unused-prop-types */
export interface CaptionOptionProps {
countryCode?: string;
children: React.ReactNode;
selected?: boolean;
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
error?: React.ReactNode;
flag?: boolean;
translatable?: boolean;
isTranslatedTarget?: boolean;
subtitleUrl?: string;
subtitleType?: string;
// subtitle details from wyzie
@ -41,7 +46,63 @@ export function CaptionOption(props: {
subtitleEncoding?: string;
isHearingImpaired?: boolean;
onDoubleClick?: () => void;
}) {
onTranslate?: () => void;
}
/* eslint-enable react/no-unused-prop-types */
function CaptionOptionRightSide(props: CaptionOptionProps) {
if (props.loading) {
// should override selected and error and not show translate button
return <Spinner className="text-lg" />;
}
function translateBtn(margin: boolean) {
return (
props.translatable && (
<span
className={classNames(
"text-buttons-secondaryText px-2 py-1 rounded bg-opacity-0",
{
"mr-1": margin,
"bg-opacity-100 bg-buttons-purpleHover": props.isTranslatedTarget,
},
"transition duration-300 ease-in-out",
"hover:bg-opacity-100 hover:bg-buttons-primaryHover",
"hover:text-buttons-primaryText",
)}
onClick={(e) => {
e.stopPropagation();
props.onTranslate?.();
}}
>
<Icon icon={Icons.TRANSLATE} className="text-lg" />
</span>
)
);
}
if (props.selected || props.error) {
return (
<div className="flex items-center">
{translateBtn(true)}
{props.error ? (
<span className="flex items-center text-video-context-error">
<Icon className="ml-2" icon={Icons.WARNING} />
</span>
) : (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
)}
</div>
);
}
return translateBtn(false);
}
export function CaptionOption(props: CaptionOptionProps) {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { t } = useTranslation();
@ -108,8 +169,10 @@ export function CaptionOption(props: {
selected={props.selected}
loading={props.loading}
error={props.error}
disabled={props.disabled}
onClick={props.onClick}
onDoubleClick={props.onDoubleClick}
rightSide={<CaptionOptionRightSide {...props} />}
>
<span
data-active-link={props.selected ? true : undefined}
@ -358,6 +421,7 @@ export function CaptionsView({
const { t } = useTranslation();
const router = useOverlayRouter(id);
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask);
const { disable, selectRandomCaptionFromLastUsedLanguage } = useCaptions();
const [isRandomSelecting, setIsRandomSelecting] = useState(false);
const [dragging, setDragging] = useState(false);
@ -646,7 +710,12 @@ export function CaptionsView({
({ language, languageName, captions: captionsForLang }) => (
<Menu.ChevronLink
key={language}
selected={selectedLanguage === language}
selected={
(!currentTranslateTask && selectedLanguage === language) ||
(!!currentTranslateTask &&
!currentTranslateTask.error &&
currentTranslateTask.targetCaption.language === language)
}
rightText={captionsForLang.length.toString()}
onClick={() => {
onChooseLanguage?.(language);

View file

@ -17,16 +17,19 @@ export interface LanguageSubtitlesViewProps {
id: string;
language: string;
overlayBackLink?: boolean;
onTranslateSubtitle?: (caption: CaptionListItem) => void;
}
export function LanguageSubtitlesView({
id,
language,
overlayBackLink,
onTranslateSubtitle,
}: LanguageSubtitlesViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask);
const { selectCaptionById } = useCaptions();
const [currentlyDownloading, setCurrentlyDownloading] = useState<
string | null
@ -122,16 +125,51 @@ export function LanguageSubtitlesView({
<CaptionOption
key={v.id}
countryCode={v.language}
selected={v.id === selectedCaptionId}
loading={v.id === currentlyDownloading && downloadReq.loading}
selected={
v.id === selectedCaptionId ||
(!!currentTranslateTask &&
!currentTranslateTask.error &&
v.id === currentTranslateTask.targetCaption.id)
}
disabled={
!!currentTranslateTask &&
!currentTranslateTask.done &&
!currentTranslateTask.error
}
loading={
(v.id === currentlyDownloading && downloadReq.loading) ||
(!!currentTranslateTask &&
v.id === currentTranslateTask.targetCaption.id &&
!currentTranslateTask.done &&
!currentTranslateTask.error)
}
error={
v.id === currentlyDownloading && downloadReq.error
? downloadReq.error.toString()
: undefined
}
onClick={() => startDownload(v.id)}
onClick={() =>
(!currentTranslateTask ||
currentTranslateTask.done ||
currentTranslateTask.error) &&
startDownload(v.id)
}
onTranslate={() => {
onTranslateSubtitle?.(v);
router.navigate(
overlayBackLink
? "/captionsOverlay/translateSubtitleOverlay"
: "/captions/translateSubtitle",
);
}}
isTranslatedTarget={
!!currentTranslateTask &&
!currentTranslateTask.error &&
v.id === currentTranslateTask.targetCaption.id
}
onDoubleClick={handleDoubleClick}
flag
translatable
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}

View file

@ -0,0 +1,175 @@
import { useTranslation } from "react-i18next";
import { FlagIcon } from "@/components/FlagIcon";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
import { CaptionOption } from "./CaptionsView";
import { useCaptions } from "../../hooks/useCaptions";
// https://developers.google.com/workspace/admin/directory/v1/languages
const availableLanguages: string[] = [
"am",
"ar",
"eu",
"bn",
"en-GB",
"pt-BR",
"bg",
"ca",
"chr",
"hr",
"cs",
"da",
"nl",
"en",
"et",
"fil",
"fi",
"fr",
"de",
"el",
"gu",
"iw",
"hi",
"hu",
"is",
"id",
"it",
"ja",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"no",
"pl",
"pt-PT",
"ro",
"ru",
"sr",
"zh-CN",
"sk",
"sl",
"es",
"sw",
"sv",
"ta",
"te",
"th",
"zh-TW",
"tr",
"ur",
"uk",
"vi",
"cy",
];
export interface TranslateSubtitlesViewProps {
id: string;
caption: CaptionListItem;
overlayBackLink?: boolean;
}
export function TranslateSubtitleView({
id,
caption,
overlayBackLink,
}: TranslateSubtitlesViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const { disable: disableCaptions } = useCaptions();
const translateTask = usePlayerStore((s) => s.caption.translateTask);
const translateCaption = usePlayerStore((s) => s.translateCaption);
const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask);
function renderTargetLang(langCode: string) {
const friendlyName = getPrettyLanguageNameFromLocale(langCode);
async function onClick() {
clearTranslateTask();
disableCaptions();
await translateCaption(caption, langCode);
}
return (
<CaptionOption
key={langCode}
countryCode={langCode}
disabled={
!!translateTask && !translateTask.done && !translateTask.error
}
loading={
!!translateTask &&
translateTask.targetCaption.id === caption.id &&
!translateTask.done &&
!translateTask.error &&
translateTask.targetLanguage === langCode
}
error={
!!translateTask &&
translateTask.targetCaption.id === caption.id &&
translateTask.error &&
translateTask.targetLanguage === langCode
}
selected={
!!translateTask &&
translateTask.targetCaption.id === caption.id &&
translateTask.done &&
translateTask.targetLanguage === langCode
}
onClick={() =>
!translateTask || translateTask.done || translateTask.error
? onClick()
: undefined
}
flag
>
{friendlyName}
</CaptionOption>
);
}
return (
<>
<Menu.BackLink
onClick={() =>
router.navigate(
overlayBackLink
? "/captionsOverlay/languagesOverlay"
: "/captions/languages",
)
}
>
<span className="flex items-center">
<FlagIcon langCode={caption.language} />
<span className="ml-3">
{t("player.menus.subtitles.translate.title", {
replace: {
language:
getPrettyLanguageNameFromLocale(caption.language) ??
caption.language,
},
})}
</span>
</span>
</Menu.BackLink>
<div className="!pt-1 mt-2 pb-3">
{availableLanguages
.filter(
(lang) =>
lang !== caption.language &&
!lang.includes(caption.language) &&
!caption.language.includes(lang),
)
.map(renderTargetLang)}
</div>
</>
);
}

View file

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from "react";
import subsrt from "subsrt-ts";
import { downloadCaption, downloadWebVTT } from "@/backend/helpers/subs";
import { Caption } from "@/stores/player/slices/source";
import { Caption, CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { useSubtitleStore } from "@/stores/subtitles";
@ -19,6 +19,7 @@ export function useCaptions() {
(s) => s.resetSubtitleSpecificSettings,
);
const setCaption = usePlayerStore((s) => s.setCaption);
const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask);
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage);
const setIsOpenSubtitles = useSubtitleStore((s) => s.setIsOpenSubtitles);
@ -42,6 +43,38 @@ export function useCaptions() {
[captionList, getHlsCaptionList],
);
const setDirectCaption = useCallback(
(caption: Caption, listItem: CaptionListItem) => {
setIsOpenSubtitles(!!listItem.opensubtitles);
setCaption(caption);
// Only reset subtitle settings if selecting a different caption
if (selectedCaption?.id !== caption.id) {
resetSubtitleSpecificSettings();
}
setLanguage(caption.language);
// Use native tracks for MP4 streams instead of custom rendering
if (source?.type === "file" && enableNativeSubtitles) {
setCaptionAsTrack(true);
} else {
// For HLS sources or when native subtitles are disabled, use custom rendering
setCaptionAsTrack(false);
}
},
[
setIsOpenSubtitles,
setLanguage,
setCaption,
resetSubtitleSpecificSettings,
source,
setCaptionAsTrack,
enableNativeSubtitles,
selectedCaption,
],
);
const selectCaptionById = useCallback(
async (captionId: string) => {
const caption = captions.find((v) => v.id === captionId);
@ -85,37 +118,9 @@ export function useCaptions() {
captionToSet.srtData = srtData;
}
setIsOpenSubtitles(!!caption.opensubtitles);
setCaption(captionToSet);
// Only reset subtitle settings if selecting a different caption
if (selectedCaption?.id !== caption.id) {
resetSubtitleSpecificSettings();
}
setLanguage(caption.language);
// Use native tracks for MP4 streams instead of custom rendering
if (source?.type === "file" && enableNativeSubtitles) {
setCaptionAsTrack(true);
} else {
// For HLS sources or when native subtitles are disabled, use custom rendering
setCaptionAsTrack(false);
}
setDirectCaption(captionToSet, caption);
},
[
setIsOpenSubtitles,
setLanguage,
captions,
setCaption,
resetSubtitleSpecificSettings,
getSubtitleTracks,
setSubtitlePreference,
source,
setCaptionAsTrack,
enableNativeSubtitles,
selectedCaption,
],
[captions, getSubtitleTracks, setSubtitlePreference, setDirectCaption],
);
const selectLanguage = useCallback(
@ -188,13 +193,23 @@ export function useCaptions() {
if (isCustomCaption) return;
const isSelectedCaptionStillAvailable = captions.some(
(caption) => caption.id === selectedCaption.id,
(caption) =>
caption.id ===
(currentTranslateTask
? currentTranslateTask.targetCaption
: selectedCaption
).id,
);
if (!isSelectedCaptionStillAvailable) {
// Try to find a caption with the same language
const sameLanguageCaption = captions.find(
(caption) => caption.language === selectedCaption.language,
(caption) =>
caption.language ===
(currentTranslateTask
? currentTranslateTask.targetCaption
: selectedCaption
).language,
);
if (sameLanguageCaption) {
@ -205,7 +220,13 @@ export function useCaptions() {
setCaption(null);
}
}
}, [captions, selectedCaption, setCaption, selectCaptionById]);
}, [
captions,
selectedCaption,
setCaption,
selectCaptionById,
currentTranslateTask,
]);
return {
selectLanguage,
@ -213,6 +234,7 @@ export function useCaptions() {
selectLastUsedLanguage,
toggleLastUsed,
selectLastUsedLanguageIfEnabled,
setDirectCaption,
selectCaptionById,
selectRandomCaptionFromLastUsedLanguage,
};

View file

@ -4,9 +4,11 @@ import { Button } from "@/components/buttons/Button";
// mostly empty view, add whatever you need
export default function TestView() {
const [val, setVal] = useState(false);
const [shouldCrash, setShouldCrash] = useState(false);
if (val) throw new Error("I crashed");
if (shouldCrash) {
throw new Error("I crashed");
}
return <Button onClick={() => setVal(true)}>Crash me!</Button>;
return <Button onClick={() => setShouldCrash(true)}>Crash me!</Button>;
}

View file

@ -36,7 +36,7 @@ const streamTypes: Record<StreamType, string> = {
};
export default function VideoTesterView() {
const { status, playMedia, setMeta } = usePlayer();
const { status, playMedia, setMeta, reset } = usePlayer();
const [selected, setSelected] = useState("mp4");
const [inputSource, setInputSource] = useState("");
const [extensionState, setExtensionState] =
@ -236,6 +236,14 @@ export default function VideoTesterView() {
}
}, [playMedia, setMeta, extensionState]);
// player meta and streams carry over, so reset on mount
useEffect(() => {
if (status !== playerStatus.IDLE) {
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<PlayerPart backUrl="/dev">
{status === playerStatus.IDLE ? (

View file

@ -1,6 +1,7 @@
/* eslint-disable no-console */
import { ScrapeMedia } from "@p-stream/providers";
import { downloadCaption } from "@/backend/helpers/subs";
import { MakeSlice } from "@/stores/player/slices/types";
import {
SourceQuality,
@ -8,6 +9,8 @@ import {
selectQuality,
} from "@/stores/player/utils/qualities";
import { useQualityStore } from "@/stores/quality";
import googletranslate from "@/utils/translation/googletranslate";
import { translate } from "@/utils/translation/index";
import { ValuesOf } from "@/utils/typeguard";
export const playerStatus = {
@ -73,6 +76,16 @@ export interface AudioTrack {
language: string;
}
export interface TranslateTask {
targetCaption: CaptionListItem;
fetchedTargetCaption?: Caption;
targetLanguage: string;
translatedCaption?: Caption;
done: boolean;
error: boolean;
cancel: () => void;
}
export interface SourceSlice {
status: PlayerStatus;
source: SourceSliceSource | null;
@ -87,6 +100,7 @@ export interface SourceSlice {
caption: {
selected: Caption | null;
asTrack: boolean;
translateTask: TranslateTask | null;
};
meta: PlayerMeta | null;
failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds
@ -106,6 +120,11 @@ export interface SourceSlice {
redisplaySource(startAt: number): void;
setCaptionAsTrack(asTrack: boolean): void;
addExternalSubtitles(): Promise<void>;
translateCaption(
targetCaption: CaptionListItem,
targetLanguage: string,
): Promise<void>;
clearTranslateTask(): void;
addFailedSource(sourceId: string): void;
addFailedEmbed(sourceId: string, embedId: string): void;
clearFailedSources(mediaKey?: string): void;
@ -174,6 +193,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
caption: {
selected: null,
asTrack: false,
translateTask: null,
},
setSourceId(id) {
set((s) => {
@ -218,6 +238,14 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
setCaption(caption) {
const store = get();
store.display?.setCaption(caption);
if (
!caption ||
(store.caption.translateTask &&
store.caption.translateTask.targetCaption.id !== caption?.id &&
store.caption.translateTask.translatedCaption?.id !== caption?.id)
) {
store.clearTranslateTask();
}
set((s) => {
s.caption.selected = caption;
});
@ -374,9 +402,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.meta = null;
s.failedSourcesPerMedia = {};
s.failedEmbedsPerMedia = {};
this.clearTranslateTask();
s.caption = {
selected: null,
asTrack: false,
translateTask: null,
};
});
},
@ -413,4 +443,102 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
});
}
},
clearTranslateTask() {
set((s) => {
if (s.caption.translateTask) {
s.caption.translateTask.cancel();
}
s.caption.translateTask = null;
});
},
async translateCaption(
targetCaption: CaptionListItem,
targetLanguage: string,
) {
let store = get();
if (store.caption.translateTask) {
console.warn("A translation task is already in progress");
return;
}
const abortController = new AbortController();
set((s) => {
s.caption.translateTask = {
targetCaption,
targetLanguage,
done: false,
error: false,
cancel() {
if (!this.done && !this.error) {
console.log("Translation task was cancelled");
}
abortController.abort();
},
};
});
function handleError(err: any) {
if (abortController.signal.aborted) {
return;
}
console.error("Translation task ran into an error", err);
set((s) => {
if (!s.caption.translateTask) return;
s.caption.translateTask.error = true;
});
}
try {
const srtData = await downloadCaption(targetCaption);
if (abortController.signal.aborted) {
return;
}
if (!srtData) {
throw new Error("Fetching failed");
}
set((s) => {
if (!s.caption.translateTask) return;
s.caption.translateTask.fetchedTargetCaption = {
id: targetCaption.id,
language: targetCaption.language,
srtData,
};
});
store = get();
} catch (err) {
handleError(err);
return;
}
try {
const result = await translate(
store.caption.translateTask!.fetchedTargetCaption!,
targetLanguage,
googletranslate,
abortController.signal,
);
if (abortController.signal.aborted) {
return;
}
if (!result) {
throw new Error("Translation failed");
}
set((s) => {
if (!s.caption.translateTask) return;
const translatedCaption: Caption = {
id: `${targetCaption.id}-translated-${targetLanguage}`,
language: targetLanguage,
srtData: result,
};
s.caption.translateTask.done = true;
s.caption.translateTask.translatedCaption = translatedCaption;
});
} catch (err) {
handleError(err);
}
},
});

View file

@ -0,0 +1,84 @@
import { TranslateService } from ".";
const SINGLE_API_URL =
"https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto";
const BATCH_API_URL = "https://translate-pa.googleapis.com/v1/translateHtml";
const BATCH_API_KEY = "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520";
export default {
getName() {
return "Google Translate";
},
getConfig() {
return {
single: {
batchSize: 250,
batchDelayMs: 1000,
},
multi: {
batchSize: 80,
batchDelayMs: 200,
},
maxRetryCount: 3,
};
},
async translate(str, targetLang, abortSignal) {
if (!str) {
return "";
}
str = str.replaceAll("\n", "<br />");
const response = await (
await fetch(
`${SINGLE_API_URL}&tl=${targetLang}&q=${encodeURIComponent(str)}`,
{
method: "GET",
signal: abortSignal,
headers: {
Accept: "application/json",
},
},
)
).json();
if (!response.sentences) {
console.warn("Invalid gt response", response);
throw new Error("Invalid response");
}
return (response.sentences as any[])
.map((s: any) => s.trans as string)
.join("")
.replaceAll("<br />", "\n");
},
async translateMulti(batch, targetLang, abortSignal) {
if (!batch || batch.length === 0) {
return [];
}
batch = batch.map((s) => s.replaceAll("\n", "<br />"));
const response = await (
await fetch(BATCH_API_URL, {
method: "POST",
signal: abortSignal,
headers: {
"Content-Type": "application/json+protobuf",
"X-goog-api-key": BATCH_API_KEY,
},
body: JSON.stringify([[batch, "auto", targetLang], "te"]),
})
).json();
if (!Array.isArray(response) || response.length < 1) {
console.warn("Invalid gt batch response", response);
throw new Error("Invalid response");
}
return response[0].map((s: any) =>
(s as string).replaceAll("<br />", "\n"),
);
},
} satisfies TranslateService;

View file

@ -0,0 +1,253 @@
/* eslint-disable no-console */
import subsrt from "subsrt-ts";
import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler";
import { Caption as PlayerCaption } from "@/stores/player/slices/source";
import { compressStr, decompressStr, sleep } from "./utils";
const CAPTIONS_CACHE: Map<string, ArrayBuffer> = new Map<string, ArrayBuffer>();
// single will not be used if multi-line is supported
export interface TranslateServiceConfig {
single: {
batchSize: number;
batchDelayMs: number;
};
multi?: {
batchSize: number;
batchDelayMs: number;
};
maxRetryCount: number;
}
export interface TranslateService {
getName(): string;
getConfig(): TranslateServiceConfig;
translate(
str: string,
targetLang: string,
abortSignal?: AbortSignal,
): Promise<string>;
translateMulti(
batch: string[],
targetLang: string,
abortSignal?: AbortSignal,
): Promise<string[]>;
}
class Translator {
private captions: Caption[];
private contentCaptions: ContentCaption[] = [];
private contentCache: Map<string, string> = new Map<string, string>();
private targetLang: string;
private service: TranslateService;
private serviceCfg: TranslateServiceConfig;
private abortSignal?: AbortSignal;
constructor(
srtData: string,
targetLang: string,
service: TranslateService,
abortSignal?: AbortSignal,
) {
this.captions = subsrt.parse(srtData);
this.targetLang = targetLang;
this.service = service;
this.serviceCfg = service.getConfig();
this.abortSignal = abortSignal;
for (const caption of this.captions) {
if (caption.type !== "caption") {
continue;
}
// Normalize line endings
caption.text = caption.text
.trim()
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n");
this.contentCaptions.push(caption);
}
}
fillContentFromCache(content: ContentCaption): boolean {
const text: string | undefined = this.contentCache.get(content.text);
if (text) {
content.text = text;
return true;
}
return false;
}
async translateContent(content: ContentCaption): Promise<boolean> {
let result;
let attempts = 0;
const errors: any[] = [];
while (!result && attempts < this.serviceCfg.maxRetryCount) {
try {
result = await this.service.translate(
content.text,
this.targetLang,
this.abortSignal,
);
} catch (err) {
if (this.abortSignal?.aborted) {
break;
}
console.warn("Translation attempt failed");
errors.push(err);
await sleep(500);
attempts += 1;
}
}
if (this.abortSignal?.aborted) {
return false;
}
if (!result) {
console.warn("Translation failed", errors);
return false;
}
this.contentCache.set(content.text, result);
content.text = result;
return true;
}
async translateContentBatch(batch: ContentCaption[]): Promise<boolean> {
try {
const result = await this.service.translateMulti(
batch.map((content) => content.text),
this.targetLang,
this.abortSignal,
);
if (result.length !== batch.length) {
console.warn(
"Batch translation size mismatch",
result.length,
batch.length,
);
return false;
}
for (let i = 0; i < batch.length; i += 1) {
this.contentCache.set(batch[i].text, result[i]);
batch[i].text = result[i];
}
return true;
} catch (err) {
if (this.abortSignal?.aborted) {
return false;
}
console.warn("Batch translation failed", err);
return false;
}
}
takeBatch(): ContentCaption[] {
const batch: ContentCaption[] = [];
const batchSize = !this.serviceCfg.multi
? this.serviceCfg.single.batchSize
: this.serviceCfg.multi!.batchSize;
let count = 0;
while (count < batchSize && this.contentCaptions.length > 0) {
const content: ContentCaption = this.contentCaptions.shift()!;
if (this.fillContentFromCache(content)) {
continue;
}
batch.push(content);
count += 1;
}
return batch;
}
async translate(): Promise<string | undefined> {
const batchDelay = !this.serviceCfg.multi
? this.serviceCfg.single.batchDelayMs
: this.serviceCfg.multi!.batchDelayMs;
console.info(
"Translating captions",
this.service.getName(),
this.contentCaptions.length,
batchDelay,
);
console.time("translation");
let batch: ContentCaption[] = this.takeBatch();
while (batch.length > 0) {
let result: boolean;
console.info("Translating batch", batch.length, batch);
if (!this.serviceCfg.multi) {
result = (
await Promise.all(
batch.map((content) => this.translateContent(content)),
)
).every((res) => res);
} else {
result = await this.translateContentBatch(batch);
}
if (this.abortSignal?.aborted) {
return undefined;
}
if (!result) {
console.error("Failed to translate batch", batch.length, batch);
return undefined;
}
batch = this.takeBatch();
await sleep(batchDelay);
}
if (this.abortSignal?.aborted) {
return undefined;
}
console.timeEnd("translation");
return subsrt.build(this.captions, { format: "srt" });
}
}
export async function translate(
caption: PlayerCaption,
targetLang: string,
service: TranslateService,
abortSignal?: AbortSignal,
): Promise<string | undefined> {
const cacheID = `${caption.id}_${targetLang}`;
const cachedData: ArrayBuffer | undefined = CAPTIONS_CACHE.get(cacheID);
if (cachedData) {
return decompressStr(cachedData);
}
const translator = new Translator(
caption.srtData,
targetLang,
service,
abortSignal,
);
const result = await translator.translate();
if (!result || abortSignal?.aborted) {
return undefined;
}
CAPTIONS_CACHE.set(cacheID, await compressStr(result));
return result;
}

View file

@ -0,0 +1,24 @@
export async function compressStr(string: string): Promise<ArrayBuffer> {
const byteArray = new TextEncoder().encode(string);
const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(byteArray);
writer.close();
return new Response(cs.readable).arrayBuffer();
}
export async function decompressStr(byteArray: ArrayBuffer): Promise<string> {
const cs = new DecompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(byteArray);
writer.close();
return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => {
return new TextDecoder().decode(arrayBuffer);
});
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}