Compare commits

...

41 commits

Author SHA1 Message Date
Pas
02a179b1d8 add Find Next Source button
replace edit order button
2026-01-10 18:29:00 -07:00
Pas
45e5abd00e routing fixes 2026-01-10 18:11:07 -07:00
Pas
0f05b7aef3 Merge branch 'pr/77' into production 2026-01-10 18:07:15 -07:00
Pas
d591bc9e4e show season and ep on mobile by title 2026-01-10 18:03:30 -07:00
Pas
050c4cc3a8 Update InfoButton.tsx 2026-01-10 17:58:40 -07:00
vlOd
fedd414629
Merge branch 'production' into production 2026-01-10 14:30:59 +02:00
vlOd
c483a9ed66
Merge branch 'p-stream:production' into production 2026-01-07 18:06:58 +02:00
vlOd
7716ec0058
Merge branch 'production' into production 2026-01-03 15:55:09 +02:00
vlOd
b2cd8ab19e
Merge branch 'p-stream:production' into production 2025-12-31 17:44:08 +02:00
vlOd
d7e5754384
Merge branch 'p-stream:production' into production 2025-12-30 19:02:16 +02:00
vlOd2
f8b9f663be Merge branch 'production' of https://github.com/vlOd2/p-stream into production 2025-12-29 00:19:45 +02:00
vlOd2
d69344f251 Fix cache 2025-12-29 00:19:43 +02:00
vlOd
9d63404783
Update src/utils/translation/index.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 00:17:08 +02:00
vlOd2
ceecfc7a22 Revert testview to how it was before 2025-12-29 00:13:15 +02:00
vlOd2
ca61d3b966 Remove unused import 2025-12-29 00:02:32 +02:00
vlOd
6b276d32fe
Merge branch 'p-stream:production' into substranslate 2025-12-28 23:45:52 +02:00
vlOd2
b8a972f9ac Add support for aborting and new lines 2025-12-28 23:44:36 +02:00
vlOd2
81f1272f7d Refactor translator service to be less jank and more modular 2025-12-28 22:36:47 +02:00
vlOd2
5539061ae4 Fix translated caption not applying when menu is closed 2025-12-28 15:14:52 +02:00
vlOd
44618524dd
Merge branch 'p-stream:production' into substranslate 2025-12-28 14:14:57 +02:00
vlOd2
49f956918d Merge branch 'substranslate' of https://github.com/vlOd2/p-stream into substranslate 2025-12-27 01:25:21 +02:00
vlOd2
17c0e7a768 Apply copilot fixes 2025-12-27 01:25:19 +02:00
vlOd
bdf5cba98b
Update src/stores/player/utils/captionstranslation.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-27 01:21:40 +02:00
vlOd
071bdeca0d
Update src/stores/player/utils/captionstranslation.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-27 01:21:29 +02:00
vlOd
96b74aa169
Update src/stores/player/utils/captionstranslation.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-27 01:18:17 +02:00
vlOd
eab6113a0f
Update src/pages/developer/VideoTesterView.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-27 01:15:53 +02:00
vlOd2
b797200c3a increase language overlay width 2025-12-27 00:28:30 +02:00
vlOd2
ed51c6d1e8 remove subtitles testing from testview 2025-12-26 23:25:34 +02:00
vlOd2
9ad28631e9 Cleanup translation console logs 2025-12-26 23:23:17 +02:00
vlOd2
edb9cad8dc adjust margin for translate button 2025-12-26 23:14:45 +02:00
vlOd2
48f72c228e Refactor translation task handling to improve error management and state updates 2025-12-26 22:57:59 +02:00
vlOd2
f71b659a70 Improve UX for translated subtitles 2025-12-26 22:35:38 +02:00
vlOd2
0d6aca41d0 actually set captions and also tweak main view to show translated from language as highlighted 2025-12-26 22:13:56 +02:00
vlOd
e02af4931c
Merge branch 'p-stream:production' into substranslate 2025-12-26 21:28:44 +02:00
vlOd2
a3dd8512bd implement translation task 2025-12-26 21:27:30 +02:00
vlOd2
f72c6214e8 Add languages to translation UI 2025-12-26 04:40:10 +02:00
vlOd2
6bc4907399 Begin work on translate subtitle view 2025-12-26 01:00:09 +02:00
vlOd2
50216a10d9 Add reset functionality to VideoTesterView on mount 2025-12-25 23:08:42 +02:00
vlOd
a2446cf931
Merge branch 'p-stream:production' into substranslate 2025-12-25 22:56:02 +02:00
Pas
9a846a8181 hide WatchPartyInputLink if backend requirement isnt met 2025-12-25 22:53:39 +02:00
vlOd2
5aea772477 Prepare for captions translation 2025-12-25 22:52:50 +02:00
21 changed files with 1031 additions and 72 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

@ -823,7 +823,7 @@
},
"title": "Sources",
"unknownOption": "Unknown",
"editOrder": "Edit order"
"findNextSource": "Find next source"
},
"subtitles": {
"customChoice": "Drop or upload file",
@ -855,7 +855,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,9 @@ export enum Icons {
RELOAD = "reload",
REPEAT = "repeat",
PLUS = "plus",
TRANSLATE = "translate",
THUMBS_UP = "thumbsUp",
THUMBS_DOWN = "thumbsDown",
}
export interface IconProps {
@ -183,6 +186,9 @@ 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>`,
};
export const Icon = memo((props: IconProps) => {

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/languagesOverlay/translateSubtitleOverlay"
width={443}
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/languages/translateSubtitleOverlay"
width={443}
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/languagesOverlay/translateSubtitleOverlay"
: "/captions/languages/translateSubtitleOverlay",
);
}}
isTranslatedTarget={
!!currentTranslateTask &&
!currentTranslateTask.error &&
v.id === currentTranslateTask.targetCaption.id
}
onDoubleClick={handleDoubleClick}
flag
translatable
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}

View file

@ -10,6 +10,7 @@ import {
import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
@ -156,6 +157,8 @@ export function SourceSelectionView({
const router = useOverlayRouter(id);
const metaType = usePlayerStore((s) => s.meta?.type);
const currentSourceId = usePlayerStore((s) => s.sourceId);
const setResumeFromSourceId = usePlayerStore((s) => s.setResumeFromSourceId);
const setStatus = usePlayerStore((s) => s.setStatus);
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
const lastSuccessfulSource = usePreferencesStore(
@ -164,6 +167,9 @@ export function SourceSelectionView({
const enableLastSuccessfulSource = usePreferencesStore(
(s) => s.enableLastSuccessfulSource,
);
const manualSourceSelection = usePreferencesStore(
(s) => s.manualSourceSelection,
);
const sources = useMemo(() => {
if (!metaType) return [];
@ -221,20 +227,32 @@ export function SourceSelectionView({
enableLastSuccessfulSource,
]);
const handleFindNextSource = () => {
if (!currentSourceId) return;
// Set the resume source ID in the store
setResumeFromSourceId(currentSourceId);
// Close the settings overlay
router.close();
// Set status to SCRAPING to trigger scraping from next source
setStatus(playerStatus.SCRAPING);
};
return (
<>
<Menu.BackLink
onClick={() => router.navigate("/")}
rightSide={
<button
type="button"
onClick={() => {
window.location.href = "/settings#source-order";
}}
className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10"
>
{t("player.menus.sources.editOrder")}
</button>
<div className="flex items-center gap-2">
{currentSourceId && !manualSourceSelection && (
<button
type="button"
onClick={handleFindNextSource}
className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10"
>
{t("player.menus.sources.findNextSource")}
</button>
)}
</div>
}
>
{t("player.menus.sources.title")}

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

@ -33,11 +33,20 @@ export function InfoButton() {
return null;
}
// Don't render button if meta, tmdbId, or type is missing/invalid
if (
!meta?.tmdbId ||
!meta.type ||
(meta.type !== "movie" && meta.type !== "show")
) {
return null;
}
return (
<VideoPlayerButton
icon={Icons.CIRCLE_QUESTION}
iconSizeClass="text-base"
className="p-2 !-mr-2"
className="p-2 !-mr-2 relative z-10"
onClick={handleClick}
/>
);

View file

@ -211,7 +211,12 @@ export function useScrape() {
}
// If we have a last successful source and the feature is enabled, prioritize it
if (enableLastSuccessfulSource && lastSuccessfulSource) {
// BUT only if we're not resuming from a specific source (to preserve custom order)
if (
enableLastSuccessfulSource &&
lastSuccessfulSource &&
!startFromSourceId
) {
const lastSourceIndex = baseSourceOrder.indexOf(lastSuccessfulSource);
if (lastSourceIndex !== -1) {
baseSourceOrder = [
@ -222,6 +227,7 @@ export function useScrape() {
}
// If starting from a specific source ID, filter the order to start AFTER that source
// This preserves the custom order while starting from the next source
let filteredSourceOrder = baseSourceOrder;
if (startFromSourceId) {
const startIndex = filteredSourceOrder.indexOf(startFromSourceId);

View file

@ -47,6 +47,10 @@ export function RealPlayerView() {
const [resumeFromSourceId, setResumeFromSourceId] = useState<string | null>(
null,
);
const storeResumeFromSourceId = usePlayerStore((s) => s.resumeFromSourceId);
const setResumeFromSourceIdInStore = usePlayerStore(
(s) => s.setResumeFromSourceId,
);
const [startAtParam] = useQueryParam("t");
const {
status,
@ -77,6 +81,14 @@ export function RealPlayerView() {
};
}, [setLastSuccessfulSource]);
// Reset resume from source ID when leaving the player
useEffect(() => {
return () => {
setResumeFromSourceId(null);
setResumeFromSourceIdInStore(null);
};
}, [setResumeFromSourceIdInStore]);
const paramsData = JSON.stringify({
media: params.media,
season: params.season,
@ -169,14 +181,28 @@ export function RealPlayerView() {
(startFromSourceId: string) => {
// Set resume source first
setResumeFromSourceId(startFromSourceId);
setResumeFromSourceIdInStore(startFromSourceId);
// Then change status in next tick to ensure re-render
setTimeout(() => {
setStatus(playerStatus.SCRAPING);
}, 0);
},
[setStatus],
[setStatus, setResumeFromSourceIdInStore],
);
// Sync store value to local state when it changes (e.g., from settings)
// or when status changes to SCRAPING
useEffect(() => {
if (storeResumeFromSourceId && status === playerStatus.SCRAPING) {
if (
!resumeFromSourceId ||
resumeFromSourceId !== storeResumeFromSourceId
) {
setResumeFromSourceId(storeResumeFromSourceId);
}
}
}, [storeResumeFromSourceId, resumeFromSourceId, status]);
const playAfterScrape = useCallback(
(out: RunOutput | null) => {
if (!out) return;
@ -223,9 +249,11 @@ export function RealPlayerView() {
<SourceSelectPart media={scrapeMedia} />
) : (
<ScrapingPart
key={`scraping-${resumeFromSourceId || "default"}`}
key={`scraping-${resumeFromSourceId || storeResumeFromSourceId || "default"}`}
media={scrapeMedia}
startFromSourceId={resumeFromSourceId || undefined}
startFromSourceId={
resumeFromSourceId || storeResumeFromSourceId || undefined
}
onResult={(sources, sourceOrder) => {
setErrorData({
sourceOrder,
@ -234,6 +262,7 @@ export function RealPlayerView() {
setScrapeNotFound();
// Clear resume state after scraping
setResumeFromSourceId(null);
setResumeFromSourceIdInStore(null);
}}
onGetStream={playAfterScrape}
/>

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,4 +1,5 @@
import { ReactNode, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player";
@ -31,6 +32,8 @@ export function PlayerPart(props: PlayerPartProps) {
);
const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading);
const { isHost, enabled } = useWatchPartyStore();
const { t } = useTranslation();
const meta = usePlayerStore((s) => s.meta);
const inControl = !enabled || isHost;
@ -118,6 +121,15 @@ export function PlayerPart(props: PlayerPartProps) {
<span className="text mx-3 text-type-secondary">/</span>
<Player.Title />
{isMobile && meta?.type === "show" && (
<span className="text-type-secondary text-sm whitespace-nowrap flex-shrink-0">
{t("media.episodeDisplay", {
season: meta?.season?.number,
episode: meta?.episode?.number,
})}
</span>
)}
<Player.InfoButton />
<Player.BookmarkButton />

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,10 +100,12 @@ export interface SourceSlice {
caption: {
selected: Caption | null;
asTrack: boolean;
translateTask: TranslateTask | null;
};
meta: PlayerMeta | null;
failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds
failedEmbedsPerMedia: Record<string, Record<string, string[]>>; // mediaKey -> sourceId -> array of failed embedIds
resumeFromSourceId: string | null;
setStatus(status: PlayerStatus): void;
setSource(
stream: SourceSliceSource,
@ -106,10 +121,16 @@ 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;
clearFailedEmbeds(mediaKey?: string): void;
setResumeFromSourceId(sourceId: string | null): void;
reset(): void;
}
@ -171,9 +192,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
meta: null,
failedSourcesPerMedia: {},
failedEmbedsPerMedia: {},
resumeFromSourceId: null,
caption: {
selected: null,
asTrack: false,
translateTask: null,
},
setSourceId(id) {
set((s) => {
@ -218,6 +241,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;
});
@ -359,6 +390,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
}
});
},
setResumeFromSourceId(sourceId: string | null) {
set((s) => {
s.resumeFromSourceId = sourceId;
});
},
reset() {
set((s) => {
s.source = null;
@ -374,9 +410,12 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.meta = null;
s.failedSourcesPerMedia = {};
s.failedEmbedsPerMedia = {};
s.resumeFromSourceId = null;
this.clearTranslateTask();
s.caption = {
selected: null,
asTrack: false,
translateTask: null,
};
});
},
@ -413,4 +452,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);
});
}