pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'pr/77' into production

Pas 0f05b7ae d591bc9e

+933 -57
+10 -1
.vscode/settings.json
··· 5 5 "[json]": { 6 6 "editor.defaultFormatter": "esbenp.prettier-vscode" 7 7 }, 8 + "[jsonc]": { 9 + "editor.defaultFormatter": "esbenp.prettier-vscode" 10 + }, 11 + "[typescript]": { 12 + "editor.defaultFormatter": "esbenp.prettier-vscode" 13 + }, 14 + "[javascript]": { 15 + "editor.defaultFormatter": "esbenp.prettier-vscode" 16 + }, 8 17 "[typescriptreact]": { 9 - "editor.defaultFormatter": "dbaeumer.vscode-eslint" 18 + "editor.defaultFormatter": "esbenp.prettier-vscode" 10 19 } 11 20 }
+8 -8
pnpm-lock.yaml
··· 44 44 version: 1.8.0 45 45 '@p-stream/providers': 46 46 specifier: github:p-stream/providers#production 47 - version: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0 47 + version: https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103 48 48 '@plasmohq/messaging': 49 49 specifier: ^0.6.2 50 50 version: 0.6.2(react@18.3.1) ··· 1207 1207 resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} 1208 1208 engines: {node: '>=12.4.0'} 1209 1209 1210 - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0': 1211 - resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0} 1210 + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103': 1211 + resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103} 1212 1212 version: 3.2.0 1213 1213 1214 1214 '@pkgjs/parseargs@0.11.0': ··· 3750 3750 serialize-javascript@6.0.2: 3751 3751 resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} 3752 3752 3753 - set-cookie-parser@2.7.1: 3754 - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} 3753 + set-cookie-parser@2.7.2: 3754 + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} 3755 3755 3756 3756 set-function-length@1.2.2: 3757 3757 resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} ··· 5523 5523 5524 5524 '@nolyfill/is-core-module@1.0.39': {} 5525 5525 5526 - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0': 5526 + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103': 5527 5527 dependencies: 5528 5528 abort-controller: 3.0.0 5529 5529 cheerio: 1.0.0-rc.12 ··· 5536 5536 json5: 2.2.3 5537 5537 nanoid: 3.3.11 5538 5538 node-fetch: 3.3.2 5539 - set-cookie-parser: 2.7.1 5539 + set-cookie-parser: 2.7.2 5540 5540 unpacker: 1.0.1 5541 5541 5542 5542 '@pkgjs/parseargs@0.11.0': ··· 8215 8215 dependencies: 8216 8216 randombytes: 2.1.0 8217 8217 8218 - set-cookie-parser@2.7.1: {} 8218 + set-cookie-parser@2.7.2: {} 8219 8219 8220 8220 set-function-length@1.2.2: 8221 8221 dependencies:
+4 -1
src/assets/locales/en.json
··· 855 855 "useNativeSubtitles": "Native video subtitles", 856 856 "useNativeSubtitlesDescription": "Broadcast subtitles for native fullscreen and PiP", 857 857 "delayLate": "Heard audio", 858 - "delayEarly": "Saw caption" 858 + "delayEarly": "Saw caption", 859 + "translate": { 860 + "title": "Translate from {{language}}" 861 + } 859 862 }, 860 863 "watchparty": { 861 864 "watchpartyItem": "Watch Party",
+6
src/components/Icon.tsx
··· 83 83 RELOAD = "reload", 84 84 REPEAT = "repeat", 85 85 PLUS = "plus", 86 + TRANSLATE = "translate", 87 + THUMBS_UP = "thumbsUp", 88 + THUMBS_DOWN = "thumbsDown", 86 89 } 87 90 88 91 export interface IconProps { ··· 183 186 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>`, 184 187 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>`, 185 188 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>`, 189 + 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>`, 190 + 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>`, 191 + 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>`, 186 192 }; 187 193 188 194 export const Icon = memo((props: IconProps) => {
+18
src/components/player/atoms/Captions.tsx
··· 2 2 3 3 import { Icons } from "@/components/Icon"; 4 4 import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; 5 + import { useCaptions } from "@/components/player/hooks/useCaptions"; 5 6 import { VideoPlayerButton } from "@/components/player/internals/Button"; 6 7 import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 7 8 import { usePlayerStore } from "@/stores/player/store"; ··· 9 10 export function Captions() { 10 11 const router = useOverlayRouter("settings"); 11 12 const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay); 13 + const { setDirectCaption } = useCaptions(); 14 + const translateTask = usePlayerStore((s) => s.caption.translateTask); 12 15 13 16 useEffect(() => { 14 17 setHasOpenOverlay(router.isRouterActive); 15 18 }, [setHasOpenOverlay, router.isRouterActive]); 19 + 20 + useEffect(() => { 21 + if (!translateTask) { 22 + return; 23 + } 24 + if (translateTask.done) { 25 + const tCaption = translateTask.translatedCaption!; 26 + setDirectCaption(tCaption, { 27 + id: tCaption.id, 28 + url: "", 29 + language: tCaption.language, 30 + needsProxy: false, 31 + }); 32 + } 33 + }, [translateTask, setDirectCaption]); 16 34 17 35 return ( 18 36 <OverlayAnchor id={router.id}>
+40 -3
src/components/player/atoms/Settings.tsx
··· 12 12 import { VideoPlayerButton } from "@/components/player/internals/Button"; 13 13 import { Menu } from "@/components/player/internals/ContextMenu"; 14 14 import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 15 + import { CaptionListItem } from "@/stores/player/slices/source"; 15 16 import { usePlayerStore } from "@/stores/player/store"; 16 17 17 18 import { AudioView } from "./settings/AudioView"; ··· 23 24 import { QualityView } from "./settings/QualityView"; 24 25 import { SettingsMenu } from "./settings/SettingsMenu"; 25 26 import { TranscriptView } from "./settings/TranscriptView"; 27 + import { TranslateSubtitleView } from "./settings/TranslateSubtitleView"; 26 28 import { WatchPartyView } from "./settings/WatchPartyView"; 27 29 28 30 function SettingsOverlay({ id }: { id: string }) { 29 31 const [chosenSourceId, setChosenSourceId] = useState<string | null>(null); 30 32 const [chosenLanguage, setChosenLanguage] = useState<string | null>(null); 33 + const [captionToTranslate, setCaptionToTranslate] = 34 + useState<CaptionListItem | null>(null); 31 35 const router = useOverlayRouter(id); 32 36 33 37 // reset source id and language when going to home or closing overlay ··· 76 80 <OverlayPage 77 81 id={id} 78 82 path="/captionsOverlay/languagesOverlay" 79 - width={343} 83 + width={443} 80 84 height={452} 81 85 > 82 86 <Menu.CardWithScrollable> ··· 84 88 <LanguageSubtitlesView 85 89 id={id} 86 90 language={chosenLanguage} 91 + onTranslateSubtitle={setCaptionToTranslate} 92 + overlayBackLink 93 + /> 94 + )} 95 + </Menu.CardWithScrollable> 96 + </OverlayPage> 97 + <OverlayPage 98 + id={id} 99 + path="/captionsOverlay/translateSubtitleOverlay" 100 + width={343} 101 + height={452} 102 + > 103 + <Menu.CardWithScrollable> 104 + {captionToTranslate && ( 105 + <TranslateSubtitleView 106 + id={id} 107 + caption={captionToTranslate} 87 108 overlayBackLink 88 109 /> 89 110 )} ··· 133 154 <OverlayPage 134 155 id={id} 135 156 path="/captions/languages" 136 - width={343} 157 + width={443} 137 158 height={452} 138 159 > 139 160 <Menu.CardWithScrollable> 140 161 {chosenLanguage && ( 141 - <LanguageSubtitlesView id={id} language={chosenLanguage} /> 162 + <LanguageSubtitlesView 163 + id={id} 164 + language={chosenLanguage} 165 + onTranslateSubtitle={setCaptionToTranslate} 166 + /> 167 + )} 168 + </Menu.CardWithScrollable> 169 + </OverlayPage> 170 + <OverlayPage 171 + id={id} 172 + path="/captions/translateSubtitle" 173 + width={343} 174 + height={452} 175 + > 176 + <Menu.CardWithScrollable> 177 + {captionToTranslate && ( 178 + <TranslateSubtitleView id={id} caption={captionToTranslate} /> 142 179 )} 143 180 </Menu.CardWithScrollable> 144 181 </OverlayPage>
+72 -3
src/components/player/atoms/settings/CaptionsView.tsx
··· 9 9 import { FileDropHandler } from "@/components/DropFile"; 10 10 import { FlagIcon } from "@/components/FlagIcon"; 11 11 import { Icon, Icons } from "@/components/Icon"; 12 + import { Spinner } from "@/components/layout/Spinner"; 12 13 import { useCaptions } from "@/components/player/hooks/useCaptions"; 13 14 import { Menu } from "@/components/player/internals/ContextMenu"; 14 15 import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; ··· 26 27 sortLangCodes, 27 28 } from "@/utils/language"; 28 29 29 - export function CaptionOption(props: { 30 + /* eslint-disable react/no-unused-prop-types */ 31 + export interface CaptionOptionProps { 30 32 countryCode?: string; 31 33 children: React.ReactNode; 32 34 selected?: boolean; 35 + disabled?: boolean; 33 36 loading?: boolean; 34 37 onClick?: () => void; 35 38 error?: React.ReactNode; 36 39 flag?: boolean; 40 + translatable?: boolean; 41 + isTranslatedTarget?: boolean; 37 42 subtitleUrl?: string; 38 43 subtitleType?: string; 39 44 // subtitle details from wyzie ··· 41 46 subtitleEncoding?: string; 42 47 isHearingImpaired?: boolean; 43 48 onDoubleClick?: () => void; 44 - }) { 49 + onTranslate?: () => void; 50 + } 51 + /* eslint-enable react/no-unused-prop-types */ 52 + 53 + function CaptionOptionRightSide(props: CaptionOptionProps) { 54 + if (props.loading) { 55 + // should override selected and error and not show translate button 56 + return <Spinner className="text-lg" />; 57 + } 58 + 59 + function translateBtn(margin: boolean) { 60 + return ( 61 + props.translatable && ( 62 + <span 63 + className={classNames( 64 + "text-buttons-secondaryText px-2 py-1 rounded bg-opacity-0", 65 + { 66 + "mr-1": margin, 67 + "bg-opacity-100 bg-buttons-purpleHover": props.isTranslatedTarget, 68 + }, 69 + "transition duration-300 ease-in-out", 70 + "hover:bg-opacity-100 hover:bg-buttons-primaryHover", 71 + "hover:text-buttons-primaryText", 72 + )} 73 + onClick={(e) => { 74 + e.stopPropagation(); 75 + props.onTranslate?.(); 76 + }} 77 + > 78 + <Icon icon={Icons.TRANSLATE} className="text-lg" /> 79 + </span> 80 + ) 81 + ); 82 + } 83 + 84 + if (props.selected || props.error) { 85 + return ( 86 + <div className="flex items-center"> 87 + {translateBtn(true)} 88 + {props.error ? ( 89 + <span className="flex items-center text-video-context-error"> 90 + <Icon className="ml-2" icon={Icons.WARNING} /> 91 + </span> 92 + ) : ( 93 + <Icon 94 + icon={Icons.CIRCLE_CHECK} 95 + className="text-xl text-video-context-type-accent" 96 + /> 97 + )} 98 + </div> 99 + ); 100 + } 101 + 102 + return translateBtn(false); 103 + } 104 + 105 + export function CaptionOption(props: CaptionOptionProps) { 45 106 const [showTooltip, setShowTooltip] = useState(false); 46 107 const tooltipTimeoutRef = useRef<NodeJS.Timeout | null>(null); 47 108 const { t } = useTranslation(); ··· 108 169 selected={props.selected} 109 170 loading={props.loading} 110 171 error={props.error} 172 + disabled={props.disabled} 111 173 onClick={props.onClick} 112 174 onDoubleClick={props.onDoubleClick} 175 + rightSide={<CaptionOptionRightSide {...props} />} 113 176 > 114 177 <span 115 178 data-active-link={props.selected ? true : undefined} ··· 358 421 const { t } = useTranslation(); 359 422 const router = useOverlayRouter(id); 360 423 const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); 424 + const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); 361 425 const { disable, selectRandomCaptionFromLastUsedLanguage } = useCaptions(); 362 426 const [isRandomSelecting, setIsRandomSelecting] = useState(false); 363 427 const [dragging, setDragging] = useState(false); ··· 646 710 ({ language, languageName, captions: captionsForLang }) => ( 647 711 <Menu.ChevronLink 648 712 key={language} 649 - selected={selectedLanguage === language} 713 + selected={ 714 + (!currentTranslateTask && selectedLanguage === language) || 715 + (!!currentTranslateTask && 716 + !currentTranslateTask.error && 717 + currentTranslateTask.targetCaption.language === language) 718 + } 650 719 rightText={captionsForLang.length.toString()} 651 720 onClick={() => { 652 721 onChooseLanguage?.(language);
+41 -3
src/components/player/atoms/settings/LanguageSubtitlesView.tsx
··· 17 17 id: string; 18 18 language: string; 19 19 overlayBackLink?: boolean; 20 + onTranslateSubtitle?: (caption: CaptionListItem) => void; 20 21 } 21 22 22 23 export function LanguageSubtitlesView({ 23 24 id, 24 25 language, 25 26 overlayBackLink, 27 + onTranslateSubtitle, 26 28 }: LanguageSubtitlesViewProps) { 27 29 const { t } = useTranslation(); 28 30 const router = useOverlayRouter(id); 29 31 const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); 32 + const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); 30 33 const { selectCaptionById } = useCaptions(); 31 34 const [currentlyDownloading, setCurrentlyDownloading] = useState< 32 35 string | null ··· 122 125 <CaptionOption 123 126 key={v.id} 124 127 countryCode={v.language} 125 - selected={v.id === selectedCaptionId} 126 - loading={v.id === currentlyDownloading && downloadReq.loading} 128 + selected={ 129 + v.id === selectedCaptionId || 130 + (!!currentTranslateTask && 131 + !currentTranslateTask.error && 132 + v.id === currentTranslateTask.targetCaption.id) 133 + } 134 + disabled={ 135 + !!currentTranslateTask && 136 + !currentTranslateTask.done && 137 + !currentTranslateTask.error 138 + } 139 + loading={ 140 + (v.id === currentlyDownloading && downloadReq.loading) || 141 + (!!currentTranslateTask && 142 + v.id === currentTranslateTask.targetCaption.id && 143 + !currentTranslateTask.done && 144 + !currentTranslateTask.error) 145 + } 127 146 error={ 128 147 v.id === currentlyDownloading && downloadReq.error 129 148 ? downloadReq.error.toString() 130 149 : undefined 131 150 } 132 - onClick={() => startDownload(v.id)} 151 + onClick={() => 152 + (!currentTranslateTask || 153 + currentTranslateTask.done || 154 + currentTranslateTask.error) && 155 + startDownload(v.id) 156 + } 157 + onTranslate={() => { 158 + onTranslateSubtitle?.(v); 159 + router.navigate( 160 + overlayBackLink 161 + ? "/captionsOverlay/translateSubtitleOverlay" 162 + : "/captions/translateSubtitle", 163 + ); 164 + }} 165 + isTranslatedTarget={ 166 + !!currentTranslateTask && 167 + !currentTranslateTask.error && 168 + v.id === currentTranslateTask.targetCaption.id 169 + } 133 170 onDoubleClick={handleDoubleClick} 134 171 flag 172 + translatable 135 173 subtitleUrl={v.url} 136 174 subtitleType={v.type} 137 175 subtitleSource={v.source}
+175
src/components/player/atoms/settings/TranslateSubtitleView.tsx
··· 1 + import { useTranslation } from "react-i18next"; 2 + 3 + import { FlagIcon } from "@/components/FlagIcon"; 4 + import { Menu } from "@/components/player/internals/ContextMenu"; 5 + import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 6 + import { CaptionListItem } from "@/stores/player/slices/source"; 7 + import { usePlayerStore } from "@/stores/player/store"; 8 + import { getPrettyLanguageNameFromLocale } from "@/utils/language"; 9 + 10 + import { CaptionOption } from "./CaptionsView"; 11 + import { useCaptions } from "../../hooks/useCaptions"; 12 + 13 + // https://developers.google.com/workspace/admin/directory/v1/languages 14 + const availableLanguages: string[] = [ 15 + "am", 16 + "ar", 17 + "eu", 18 + "bn", 19 + "en-GB", 20 + "pt-BR", 21 + "bg", 22 + "ca", 23 + "chr", 24 + "hr", 25 + "cs", 26 + "da", 27 + "nl", 28 + "en", 29 + "et", 30 + "fil", 31 + "fi", 32 + "fr", 33 + "de", 34 + "el", 35 + "gu", 36 + "iw", 37 + "hi", 38 + "hu", 39 + "is", 40 + "id", 41 + "it", 42 + "ja", 43 + "kn", 44 + "ko", 45 + "lv", 46 + "lt", 47 + "ms", 48 + "ml", 49 + "mr", 50 + "no", 51 + "pl", 52 + "pt-PT", 53 + "ro", 54 + "ru", 55 + "sr", 56 + "zh-CN", 57 + "sk", 58 + "sl", 59 + "es", 60 + "sw", 61 + "sv", 62 + "ta", 63 + "te", 64 + "th", 65 + "zh-TW", 66 + "tr", 67 + "ur", 68 + "uk", 69 + "vi", 70 + "cy", 71 + ]; 72 + 73 + export interface TranslateSubtitlesViewProps { 74 + id: string; 75 + caption: CaptionListItem; 76 + overlayBackLink?: boolean; 77 + } 78 + 79 + export function TranslateSubtitleView({ 80 + id, 81 + caption, 82 + overlayBackLink, 83 + }: TranslateSubtitlesViewProps) { 84 + const { t } = useTranslation(); 85 + const router = useOverlayRouter(id); 86 + const { disable: disableCaptions } = useCaptions(); 87 + const translateTask = usePlayerStore((s) => s.caption.translateTask); 88 + const translateCaption = usePlayerStore((s) => s.translateCaption); 89 + const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); 90 + 91 + function renderTargetLang(langCode: string) { 92 + const friendlyName = getPrettyLanguageNameFromLocale(langCode); 93 + 94 + async function onClick() { 95 + clearTranslateTask(); 96 + disableCaptions(); 97 + await translateCaption(caption, langCode); 98 + } 99 + 100 + return ( 101 + <CaptionOption 102 + key={langCode} 103 + countryCode={langCode} 104 + disabled={ 105 + !!translateTask && !translateTask.done && !translateTask.error 106 + } 107 + loading={ 108 + !!translateTask && 109 + translateTask.targetCaption.id === caption.id && 110 + !translateTask.done && 111 + !translateTask.error && 112 + translateTask.targetLanguage === langCode 113 + } 114 + error={ 115 + !!translateTask && 116 + translateTask.targetCaption.id === caption.id && 117 + translateTask.error && 118 + translateTask.targetLanguage === langCode 119 + } 120 + selected={ 121 + !!translateTask && 122 + translateTask.targetCaption.id === caption.id && 123 + translateTask.done && 124 + translateTask.targetLanguage === langCode 125 + } 126 + onClick={() => 127 + !translateTask || translateTask.done || translateTask.error 128 + ? onClick() 129 + : undefined 130 + } 131 + flag 132 + > 133 + {friendlyName} 134 + </CaptionOption> 135 + ); 136 + } 137 + 138 + return ( 139 + <> 140 + <Menu.BackLink 141 + onClick={() => 142 + router.navigate( 143 + overlayBackLink 144 + ? "/captionsOverlay/languagesOverlay" 145 + : "/captions/languages", 146 + ) 147 + } 148 + > 149 + <span className="flex items-center"> 150 + <FlagIcon langCode={caption.language} /> 151 + <span className="ml-3"> 152 + {t("player.menus.subtitles.translate.title", { 153 + replace: { 154 + language: 155 + getPrettyLanguageNameFromLocale(caption.language) ?? 156 + caption.language, 157 + }, 158 + })} 159 + </span> 160 + </span> 161 + </Menu.BackLink> 162 + 163 + <div className="!pt-1 mt-2 pb-3"> 164 + {availableLanguages 165 + .filter( 166 + (lang) => 167 + lang !== caption.language && 168 + !lang.includes(caption.language) && 169 + !caption.language.includes(lang), 170 + ) 171 + .map(renderTargetLang)} 172 + </div> 173 + </> 174 + ); 175 + }
+56 -34
src/components/player/hooks/useCaptions.ts
··· 2 2 import subsrt from "subsrt-ts"; 3 3 4 4 import { downloadCaption, downloadWebVTT } from "@/backend/helpers/subs"; 5 - import { Caption } from "@/stores/player/slices/source"; 5 + import { Caption, CaptionListItem } from "@/stores/player/slices/source"; 6 6 import { usePlayerStore } from "@/stores/player/store"; 7 7 import { usePreferencesStore } from "@/stores/preferences"; 8 8 import { useSubtitleStore } from "@/stores/subtitles"; ··· 19 19 (s) => s.resetSubtitleSpecificSettings, 20 20 ); 21 21 const setCaption = usePlayerStore((s) => s.setCaption); 22 + const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); 22 23 const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); 23 24 const setIsOpenSubtitles = useSubtitleStore((s) => s.setIsOpenSubtitles); 24 25 ··· 42 43 [captionList, getHlsCaptionList], 43 44 ); 44 45 46 + const setDirectCaption = useCallback( 47 + (caption: Caption, listItem: CaptionListItem) => { 48 + setIsOpenSubtitles(!!listItem.opensubtitles); 49 + setCaption(caption); 50 + 51 + // Only reset subtitle settings if selecting a different caption 52 + if (selectedCaption?.id !== caption.id) { 53 + resetSubtitleSpecificSettings(); 54 + } 55 + 56 + setLanguage(caption.language); 57 + 58 + // Use native tracks for MP4 streams instead of custom rendering 59 + if (source?.type === "file" && enableNativeSubtitles) { 60 + setCaptionAsTrack(true); 61 + } else { 62 + // For HLS sources or when native subtitles are disabled, use custom rendering 63 + setCaptionAsTrack(false); 64 + } 65 + }, 66 + [ 67 + setIsOpenSubtitles, 68 + setLanguage, 69 + setCaption, 70 + resetSubtitleSpecificSettings, 71 + source, 72 + setCaptionAsTrack, 73 + enableNativeSubtitles, 74 + selectedCaption, 75 + ], 76 + ); 77 + 45 78 const selectCaptionById = useCallback( 46 79 async (captionId: string) => { 47 80 const caption = captions.find((v) => v.id === captionId); ··· 85 118 captionToSet.srtData = srtData; 86 119 } 87 120 88 - setIsOpenSubtitles(!!caption.opensubtitles); 89 - setCaption(captionToSet); 90 - 91 - // Only reset subtitle settings if selecting a different caption 92 - if (selectedCaption?.id !== caption.id) { 93 - resetSubtitleSpecificSettings(); 94 - } 95 - 96 - setLanguage(caption.language); 97 - 98 - // Use native tracks for MP4 streams instead of custom rendering 99 - if (source?.type === "file" && enableNativeSubtitles) { 100 - setCaptionAsTrack(true); 101 - } else { 102 - // For HLS sources or when native subtitles are disabled, use custom rendering 103 - setCaptionAsTrack(false); 104 - } 121 + setDirectCaption(captionToSet, caption); 105 122 }, 106 - [ 107 - setIsOpenSubtitles, 108 - setLanguage, 109 - captions, 110 - setCaption, 111 - resetSubtitleSpecificSettings, 112 - getSubtitleTracks, 113 - setSubtitlePreference, 114 - source, 115 - setCaptionAsTrack, 116 - enableNativeSubtitles, 117 - selectedCaption, 118 - ], 123 + [captions, getSubtitleTracks, setSubtitlePreference, setDirectCaption], 119 124 ); 120 125 121 126 const selectLanguage = useCallback( ··· 188 193 if (isCustomCaption) return; 189 194 190 195 const isSelectedCaptionStillAvailable = captions.some( 191 - (caption) => caption.id === selectedCaption.id, 196 + (caption) => 197 + caption.id === 198 + (currentTranslateTask 199 + ? currentTranslateTask.targetCaption 200 + : selectedCaption 201 + ).id, 192 202 ); 193 203 194 204 if (!isSelectedCaptionStillAvailable) { 195 205 // Try to find a caption with the same language 196 206 const sameLanguageCaption = captions.find( 197 - (caption) => caption.language === selectedCaption.language, 207 + (caption) => 208 + caption.language === 209 + (currentTranslateTask 210 + ? currentTranslateTask.targetCaption 211 + : selectedCaption 212 + ).language, 198 213 ); 199 214 200 215 if (sameLanguageCaption) { ··· 205 220 setCaption(null); 206 221 } 207 222 } 208 - }, [captions, selectedCaption, setCaption, selectCaptionById]); 223 + }, [ 224 + captions, 225 + selectedCaption, 226 + setCaption, 227 + selectCaptionById, 228 + currentTranslateTask, 229 + ]); 209 230 210 231 return { 211 232 selectLanguage, ··· 213 234 selectLastUsedLanguage, 214 235 toggleLastUsed, 215 236 selectLastUsedLanguageIfEnabled, 237 + setDirectCaption, 216 238 selectCaptionById, 217 239 selectRandomCaptionFromLastUsedLanguage, 218 240 };
+5 -3
src/pages/developer/TestView.tsx
··· 4 4 5 5 // mostly empty view, add whatever you need 6 6 export default function TestView() { 7 - const [val, setVal] = useState(false); 7 + const [shouldCrash, setShouldCrash] = useState(false); 8 8 9 - if (val) throw new Error("I crashed"); 9 + if (shouldCrash) { 10 + throw new Error("I crashed"); 11 + } 10 12 11 - return <Button onClick={() => setVal(true)}>Crash me!</Button>; 13 + return <Button onClick={() => setShouldCrash(true)}>Crash me!</Button>; 12 14 }
+9 -1
src/pages/developer/VideoTesterView.tsx
··· 36 36 }; 37 37 38 38 export default function VideoTesterView() { 39 - const { status, playMedia, setMeta } = usePlayer(); 39 + const { status, playMedia, setMeta, reset } = usePlayer(); 40 40 const [selected, setSelected] = useState("mp4"); 41 41 const [inputSource, setInputSource] = useState(""); 42 42 const [extensionState, setExtensionState] = ··· 235 235 alert(`Failed to parse CLI data: ${errorMessage}`); 236 236 } 237 237 }, [playMedia, setMeta, extensionState]); 238 + 239 + // player meta and streams carry over, so reset on mount 240 + useEffect(() => { 241 + if (status !== playerStatus.IDLE) { 242 + reset(); 243 + } 244 + // eslint-disable-next-line react-hooks/exhaustive-deps 245 + }, []); 238 246 239 247 return ( 240 248 <PlayerPart backUrl="/dev">
+128
src/stores/player/slices/source.ts
··· 1 1 /* eslint-disable no-console */ 2 2 import { ScrapeMedia } from "@p-stream/providers"; 3 3 4 + import { downloadCaption } from "@/backend/helpers/subs"; 4 5 import { MakeSlice } from "@/stores/player/slices/types"; 5 6 import { 6 7 SourceQuality, ··· 8 9 selectQuality, 9 10 } from "@/stores/player/utils/qualities"; 10 11 import { useQualityStore } from "@/stores/quality"; 12 + import googletranslate from "@/utils/translation/googletranslate"; 13 + import { translate } from "@/utils/translation/index"; 11 14 import { ValuesOf } from "@/utils/typeguard"; 12 15 13 16 export const playerStatus = { ··· 73 76 language: string; 74 77 } 75 78 79 + export interface TranslateTask { 80 + targetCaption: CaptionListItem; 81 + fetchedTargetCaption?: Caption; 82 + targetLanguage: string; 83 + translatedCaption?: Caption; 84 + done: boolean; 85 + error: boolean; 86 + cancel: () => void; 87 + } 88 + 76 89 export interface SourceSlice { 77 90 status: PlayerStatus; 78 91 source: SourceSliceSource | null; ··· 87 100 caption: { 88 101 selected: Caption | null; 89 102 asTrack: boolean; 103 + translateTask: TranslateTask | null; 90 104 }; 91 105 meta: PlayerMeta | null; 92 106 failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds ··· 106 120 redisplaySource(startAt: number): void; 107 121 setCaptionAsTrack(asTrack: boolean): void; 108 122 addExternalSubtitles(): Promise<void>; 123 + translateCaption( 124 + targetCaption: CaptionListItem, 125 + targetLanguage: string, 126 + ): Promise<void>; 127 + clearTranslateTask(): void; 109 128 addFailedSource(sourceId: string): void; 110 129 addFailedEmbed(sourceId: string, embedId: string): void; 111 130 clearFailedSources(mediaKey?: string): void; ··· 174 193 caption: { 175 194 selected: null, 176 195 asTrack: false, 196 + translateTask: null, 177 197 }, 178 198 setSourceId(id) { 179 199 set((s) => { ··· 218 238 setCaption(caption) { 219 239 const store = get(); 220 240 store.display?.setCaption(caption); 241 + if ( 242 + !caption || 243 + (store.caption.translateTask && 244 + store.caption.translateTask.targetCaption.id !== caption?.id && 245 + store.caption.translateTask.translatedCaption?.id !== caption?.id) 246 + ) { 247 + store.clearTranslateTask(); 248 + } 221 249 set((s) => { 222 250 s.caption.selected = caption; 223 251 }); ··· 374 402 s.meta = null; 375 403 s.failedSourcesPerMedia = {}; 376 404 s.failedEmbedsPerMedia = {}; 405 + this.clearTranslateTask(); 377 406 s.caption = { 378 407 selected: null, 379 408 asTrack: false, 409 + translateTask: null, 380 410 }; 381 411 }); 382 412 }, ··· 411 441 set((s) => { 412 442 s.isLoadingExternalSubtitles = false; 413 443 }); 444 + } 445 + }, 446 + 447 + clearTranslateTask() { 448 + set((s) => { 449 + if (s.caption.translateTask) { 450 + s.caption.translateTask.cancel(); 451 + } 452 + s.caption.translateTask = null; 453 + }); 454 + }, 455 + 456 + async translateCaption( 457 + targetCaption: CaptionListItem, 458 + targetLanguage: string, 459 + ) { 460 + let store = get(); 461 + 462 + if (store.caption.translateTask) { 463 + console.warn("A translation task is already in progress"); 464 + return; 465 + } 466 + 467 + const abortController = new AbortController(); 468 + 469 + set((s) => { 470 + s.caption.translateTask = { 471 + targetCaption, 472 + targetLanguage, 473 + done: false, 474 + error: false, 475 + cancel() { 476 + if (!this.done && !this.error) { 477 + console.log("Translation task was cancelled"); 478 + } 479 + abortController.abort(); 480 + }, 481 + }; 482 + }); 483 + 484 + function handleError(err: any) { 485 + if (abortController.signal.aborted) { 486 + return; 487 + } 488 + console.error("Translation task ran into an error", err); 489 + set((s) => { 490 + if (!s.caption.translateTask) return; 491 + s.caption.translateTask.error = true; 492 + }); 493 + } 494 + 495 + try { 496 + const srtData = await downloadCaption(targetCaption); 497 + if (abortController.signal.aborted) { 498 + return; 499 + } 500 + if (!srtData) { 501 + throw new Error("Fetching failed"); 502 + } 503 + set((s) => { 504 + if (!s.caption.translateTask) return; 505 + s.caption.translateTask.fetchedTargetCaption = { 506 + id: targetCaption.id, 507 + language: targetCaption.language, 508 + srtData, 509 + }; 510 + }); 511 + store = get(); 512 + } catch (err) { 513 + handleError(err); 514 + return; 515 + } 516 + 517 + try { 518 + const result = await translate( 519 + store.caption.translateTask!.fetchedTargetCaption!, 520 + targetLanguage, 521 + googletranslate, 522 + abortController.signal, 523 + ); 524 + if (abortController.signal.aborted) { 525 + return; 526 + } 527 + if (!result) { 528 + throw new Error("Translation failed"); 529 + } 530 + set((s) => { 531 + if (!s.caption.translateTask) return; 532 + const translatedCaption: Caption = { 533 + id: `${targetCaption.id}-translated-${targetLanguage}`, 534 + language: targetLanguage, 535 + srtData: result, 536 + }; 537 + s.caption.translateTask.done = true; 538 + s.caption.translateTask.translatedCaption = translatedCaption; 539 + }); 540 + } catch (err) { 541 + handleError(err); 414 542 } 415 543 }, 416 544 });
+84
src/utils/translation/googletranslate.ts
··· 1 + import { TranslateService } from "."; 2 + 3 + const SINGLE_API_URL = 4 + "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto"; 5 + const BATCH_API_URL = "https://translate-pa.googleapis.com/v1/translateHtml"; 6 + const BATCH_API_KEY = "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520"; 7 + 8 + export default { 9 + getName() { 10 + return "Google Translate"; 11 + }, 12 + 13 + getConfig() { 14 + return { 15 + single: { 16 + batchSize: 250, 17 + batchDelayMs: 1000, 18 + }, 19 + multi: { 20 + batchSize: 80, 21 + batchDelayMs: 200, 22 + }, 23 + maxRetryCount: 3, 24 + }; 25 + }, 26 + 27 + async translate(str, targetLang, abortSignal) { 28 + if (!str) { 29 + return ""; 30 + } 31 + str = str.replaceAll("\n", "<br />"); 32 + 33 + const response = await ( 34 + await fetch( 35 + `${SINGLE_API_URL}&tl=${targetLang}&q=${encodeURIComponent(str)}`, 36 + { 37 + method: "GET", 38 + signal: abortSignal, 39 + headers: { 40 + Accept: "application/json", 41 + }, 42 + }, 43 + ) 44 + ).json(); 45 + 46 + if (!response.sentences) { 47 + console.warn("Invalid gt response", response); 48 + throw new Error("Invalid response"); 49 + } 50 + 51 + return (response.sentences as any[]) 52 + .map((s: any) => s.trans as string) 53 + .join("") 54 + .replaceAll("<br />", "\n"); 55 + }, 56 + 57 + async translateMulti(batch, targetLang, abortSignal) { 58 + if (!batch || batch.length === 0) { 59 + return []; 60 + } 61 + batch = batch.map((s) => s.replaceAll("\n", "<br />")); 62 + 63 + const response = await ( 64 + await fetch(BATCH_API_URL, { 65 + method: "POST", 66 + signal: abortSignal, 67 + headers: { 68 + "Content-Type": "application/json+protobuf", 69 + "X-goog-api-key": BATCH_API_KEY, 70 + }, 71 + body: JSON.stringify([[batch, "auto", targetLang], "te"]), 72 + }) 73 + ).json(); 74 + 75 + if (!Array.isArray(response) || response.length < 1) { 76 + console.warn("Invalid gt batch response", response); 77 + throw new Error("Invalid response"); 78 + } 79 + 80 + return response[0].map((s: any) => 81 + (s as string).replaceAll("<br />", "\n"), 82 + ); 83 + }, 84 + } satisfies TranslateService;
+253
src/utils/translation/index.ts
··· 1 + /* eslint-disable no-console */ 2 + import subsrt from "subsrt-ts"; 3 + import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler"; 4 + 5 + import { Caption as PlayerCaption } from "@/stores/player/slices/source"; 6 + 7 + import { compressStr, decompressStr, sleep } from "./utils"; 8 + 9 + const CAPTIONS_CACHE: Map<string, ArrayBuffer> = new Map<string, ArrayBuffer>(); 10 + 11 + // single will not be used if multi-line is supported 12 + export interface TranslateServiceConfig { 13 + single: { 14 + batchSize: number; 15 + batchDelayMs: number; 16 + }; 17 + multi?: { 18 + batchSize: number; 19 + batchDelayMs: number; 20 + }; 21 + maxRetryCount: number; 22 + } 23 + 24 + export interface TranslateService { 25 + getName(): string; 26 + getConfig(): TranslateServiceConfig; 27 + translate( 28 + str: string, 29 + targetLang: string, 30 + abortSignal?: AbortSignal, 31 + ): Promise<string>; 32 + translateMulti( 33 + batch: string[], 34 + targetLang: string, 35 + abortSignal?: AbortSignal, 36 + ): Promise<string[]>; 37 + } 38 + 39 + class Translator { 40 + private captions: Caption[]; 41 + 42 + private contentCaptions: ContentCaption[] = []; 43 + 44 + private contentCache: Map<string, string> = new Map<string, string>(); 45 + 46 + private targetLang: string; 47 + 48 + private service: TranslateService; 49 + 50 + private serviceCfg: TranslateServiceConfig; 51 + 52 + private abortSignal?: AbortSignal; 53 + 54 + constructor( 55 + srtData: string, 56 + targetLang: string, 57 + service: TranslateService, 58 + abortSignal?: AbortSignal, 59 + ) { 60 + this.captions = subsrt.parse(srtData); 61 + this.targetLang = targetLang; 62 + this.service = service; 63 + this.serviceCfg = service.getConfig(); 64 + this.abortSignal = abortSignal; 65 + 66 + for (const caption of this.captions) { 67 + if (caption.type !== "caption") { 68 + continue; 69 + } 70 + // Normalize line endings 71 + caption.text = caption.text 72 + .trim() 73 + .replaceAll("\r\n", "\n") 74 + .replaceAll("\r", "\n"); 75 + this.contentCaptions.push(caption); 76 + } 77 + } 78 + 79 + fillContentFromCache(content: ContentCaption): boolean { 80 + const text: string | undefined = this.contentCache.get(content.text); 81 + if (text) { 82 + content.text = text; 83 + return true; 84 + } 85 + return false; 86 + } 87 + 88 + async translateContent(content: ContentCaption): Promise<boolean> { 89 + let result; 90 + let attempts = 0; 91 + const errors: any[] = []; 92 + 93 + while (!result && attempts < this.serviceCfg.maxRetryCount) { 94 + try { 95 + result = await this.service.translate( 96 + content.text, 97 + this.targetLang, 98 + this.abortSignal, 99 + ); 100 + } catch (err) { 101 + if (this.abortSignal?.aborted) { 102 + break; 103 + } 104 + console.warn("Translation attempt failed"); 105 + errors.push(err); 106 + await sleep(500); 107 + attempts += 1; 108 + } 109 + } 110 + 111 + if (this.abortSignal?.aborted) { 112 + return false; 113 + } 114 + 115 + if (!result) { 116 + console.warn("Translation failed", errors); 117 + return false; 118 + } 119 + 120 + this.contentCache.set(content.text, result); 121 + content.text = result; 122 + return true; 123 + } 124 + 125 + async translateContentBatch(batch: ContentCaption[]): Promise<boolean> { 126 + try { 127 + const result = await this.service.translateMulti( 128 + batch.map((content) => content.text), 129 + this.targetLang, 130 + this.abortSignal, 131 + ); 132 + 133 + if (result.length !== batch.length) { 134 + console.warn( 135 + "Batch translation size mismatch", 136 + result.length, 137 + batch.length, 138 + ); 139 + return false; 140 + } 141 + 142 + for (let i = 0; i < batch.length; i += 1) { 143 + this.contentCache.set(batch[i].text, result[i]); 144 + batch[i].text = result[i]; 145 + } 146 + 147 + return true; 148 + } catch (err) { 149 + if (this.abortSignal?.aborted) { 150 + return false; 151 + } 152 + console.warn("Batch translation failed", err); 153 + return false; 154 + } 155 + } 156 + 157 + takeBatch(): ContentCaption[] { 158 + const batch: ContentCaption[] = []; 159 + const batchSize = !this.serviceCfg.multi 160 + ? this.serviceCfg.single.batchSize 161 + : this.serviceCfg.multi!.batchSize; 162 + 163 + let count = 0; 164 + while (count < batchSize && this.contentCaptions.length > 0) { 165 + const content: ContentCaption = this.contentCaptions.shift()!; 166 + if (this.fillContentFromCache(content)) { 167 + continue; 168 + } 169 + batch.push(content); 170 + count += 1; 171 + } 172 + 173 + return batch; 174 + } 175 + 176 + async translate(): Promise<string | undefined> { 177 + const batchDelay = !this.serviceCfg.multi 178 + ? this.serviceCfg.single.batchDelayMs 179 + : this.serviceCfg.multi!.batchDelayMs; 180 + 181 + console.info( 182 + "Translating captions", 183 + this.service.getName(), 184 + this.contentCaptions.length, 185 + batchDelay, 186 + ); 187 + console.time("translation"); 188 + 189 + let batch: ContentCaption[] = this.takeBatch(); 190 + while (batch.length > 0) { 191 + let result: boolean; 192 + console.info("Translating batch", batch.length, batch); 193 + 194 + if (!this.serviceCfg.multi) { 195 + result = ( 196 + await Promise.all( 197 + batch.map((content) => this.translateContent(content)), 198 + ) 199 + ).every((res) => res); 200 + } else { 201 + result = await this.translateContentBatch(batch); 202 + } 203 + 204 + if (this.abortSignal?.aborted) { 205 + return undefined; 206 + } 207 + 208 + if (!result) { 209 + console.error("Failed to translate batch", batch.length, batch); 210 + return undefined; 211 + } 212 + 213 + batch = this.takeBatch(); 214 + await sleep(batchDelay); 215 + } 216 + 217 + if (this.abortSignal?.aborted) { 218 + return undefined; 219 + } 220 + 221 + console.timeEnd("translation"); 222 + return subsrt.build(this.captions, { format: "srt" }); 223 + } 224 + } 225 + 226 + export async function translate( 227 + caption: PlayerCaption, 228 + targetLang: string, 229 + service: TranslateService, 230 + abortSignal?: AbortSignal, 231 + ): Promise<string | undefined> { 232 + const cacheID = `${caption.id}_${targetLang}`; 233 + 234 + const cachedData: ArrayBuffer | undefined = CAPTIONS_CACHE.get(cacheID); 235 + if (cachedData) { 236 + return decompressStr(cachedData); 237 + } 238 + 239 + const translator = new Translator( 240 + caption.srtData, 241 + targetLang, 242 + service, 243 + abortSignal, 244 + ); 245 + 246 + const result = await translator.translate(); 247 + if (!result || abortSignal?.aborted) { 248 + return undefined; 249 + } 250 + 251 + CAPTIONS_CACHE.set(cacheID, await compressStr(result)); 252 + return result; 253 + }
+24
src/utils/translation/utils.ts
··· 1 + export async function compressStr(string: string): Promise<ArrayBuffer> { 2 + const byteArray = new TextEncoder().encode(string); 3 + const cs = new CompressionStream("deflate"); 4 + const writer = cs.writable.getWriter(); 5 + writer.write(byteArray); 6 + writer.close(); 7 + return new Response(cs.readable).arrayBuffer(); 8 + } 9 + 10 + export async function decompressStr(byteArray: ArrayBuffer): Promise<string> { 11 + const cs = new DecompressionStream("deflate"); 12 + const writer = cs.writable.getWriter(); 13 + writer.write(byteArray); 14 + writer.close(); 15 + return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => { 16 + return new TextDecoder().decode(arrayBuffer); 17 + }); 18 + } 19 + 20 + export function sleep(ms: number): Promise<void> { 21 + return new Promise((resolve) => { 22 + setTimeout(resolve, ms); 23 + }); 24 + }