mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
Compare commits
41 commits
6331e69a2f
...
02a179b1d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a179b1d8 | ||
|
|
45e5abd00e | ||
|
|
0f05b7aef3 | ||
|
|
d591bc9e4e | ||
|
|
050c4cc3a8 | ||
|
|
fedd414629 | ||
|
|
c483a9ed66 | ||
|
|
7716ec0058 | ||
|
|
b2cd8ab19e | ||
|
|
d7e5754384 | ||
|
|
f8b9f663be | ||
|
|
d69344f251 | ||
|
|
9d63404783 | ||
|
|
ceecfc7a22 | ||
|
|
ca61d3b966 | ||
|
|
6b276d32fe | ||
|
|
b8a972f9ac | ||
|
|
81f1272f7d | ||
|
|
5539061ae4 | ||
|
|
44618524dd | ||
|
|
49f956918d | ||
|
|
17c0e7a768 | ||
|
|
bdf5cba98b | ||
|
|
071bdeca0d | ||
|
|
96b74aa169 | ||
|
|
eab6113a0f | ||
|
|
b797200c3a | ||
|
|
ed51c6d1e8 | ||
|
|
9ad28631e9 | ||
|
|
edb9cad8dc | ||
|
|
48f72c228e | ||
|
|
f71b659a70 | ||
|
|
0d6aca41d0 | ||
|
|
e02af4931c | ||
|
|
a3dd8512bd | ||
|
|
f72c6214e8 | ||
|
|
6bc4907399 | ||
|
|
50216a10d9 | ||
|
|
a2446cf931 | ||
|
|
9a846a8181 | ||
|
|
5aea772477 |
21 changed files with 1031 additions and 72 deletions
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
175
src/components/player/atoms/settings/TranslateSubtitleView.tsx
Normal file
175
src/components/player/atoms/settings/TranslateSubtitleView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
84
src/utils/translation/googletranslate.ts
Normal file
84
src/utils/translation/googletranslate.ts
Normal 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;
|
||||
253
src/utils/translation/index.ts
Normal file
253
src/utils/translation/index.ts
Normal 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;
|
||||
}
|
||||
24
src/utils/translation/utils.ts
Normal file
24
src/utils/translation/utils.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue