Localize the rest of everything

This commit is contained in:
mrjvs 2023-11-28 21:11:46 +01:00
parent 7537ebb56c
commit a4808415db
25 changed files with 313 additions and 158 deletions

View File

@ -72,13 +72,25 @@
"title": "Goo goo gaa gaa", "title": "Goo goo gaa gaa",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖", "text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home" "homeButton": "Go home"
},
"items": {
"pending": "Checking for videos...",
"notFound": "Doesn't have the video",
"failure": "Error occured"
} }
}, },
"playbackError": { "playbackError": {
"badge": "Not found", "badge": "Not found",
"title": "Goo goo gaa gaa", "title": "Goo goo gaa gaa",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖", "text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home" "homeButton": "Go home",
"errors": {
"errorAborted": "The fetching of the associated resource was aborted by the user's request.",
"errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
"errorDecode": "Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.",
"errorNotSupported": "The associated resource or media provider object has been found to be unsuitable.",
"errorGenericMedia": "Unknown media error occured"
}
}, },
"metadata": { "metadata": {
"notFound": { "notFound": {
@ -93,6 +105,97 @@
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖", "text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home" "homeButton": "Go home"
} }
},
"back": {
"default": "Back to home",
"short": "Back"
},
"time": {
"short": "-{{timeLeft}}",
"regular": "{{timeWatched}} / {{duration}}",
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}"
},
"nextEpisode": {
"next": "Next episode",
"cancel": "Cancel"
},
"menus": {
"settings": {
"videoSection": "Video settings",
"experienceSection": "Viewing Experience",
"enableCaptions": "Enable Captions",
"captionItem": "Caption settings",
"sourceItem": "Video sources",
"playbackItem": "Playback settings",
"downloadItem": "Download",
"qualityItem": "Quality"
},
"episodes": {
"button": "Episodes",
"loadingTitle": "Loading...",
"loadingList": "Loading...",
"loadingError": "Error loading season",
"emptyState": "There are no episodes in this season, check back later!",
"episodeBadge": "E{{episode}}"
},
"sources": {
"title": "Sources",
"unknownOption": "Unknown",
"noStream": {
"title": "No stream",
"text": "This source has no streams for this movie or show."
},
"noEmbeds": {
"title": "No embeds found",
"text": "We were unable to find any embeds for this source, please try another."
},
"failed": {
"title": "Failed to scrape",
"text": "We were unable to find any videos for this source. Don't come bitchin' to us about it, just try another source."
}
},
"captions": {
"title": "Captions",
"customizeLabel": "Customize",
"settings": {
"fixCapitals": "Fix capitalization",
"delay": "Caption delay"
},
"customChoice": "Upload captions",
"offChoice": "Off",
"unknownLanguage": "Unknown"
},
"downloads": {
"title": "Download",
"disclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided.",
"hlsExplanation": "Insert explanation for why you can't download HLS here",
"downloadVideo": "Download video",
"downloadCaption": "Download current caption",
"onPc": {
"title": "Downloading on PC",
"shortTitle": "Download / PC",
"1": "On PC, right click the video and select <bold>Save video as</bold>"
},
"onAndroid": {
"title": "Downloading on Android",
"shortTitle": "Download / Android",
"1": "To download on Android, <bold>tap and hold</bold> on the video, then select <bold>save</bold>."
},
"onIos": {
"title": "Downloading on iOS",
"shortTitle": "Download / iOS",
"1": "To download on iOS, click <bold><ios_share /></bold>, then <bold>Save to Files <ios_files /></bold>. All that's left to do now is to pick a nice and cozy folder for your video!"
}
},
"playback": {
"title": "Playback settings",
"speedLabel": "Playback speed"
},
"quality": {
"title": "Quality",
"automaticLabel": "Automatic quality",
"hint": "You can try <0>switching source</0> to get different quality options."
}
} }
}, },
"home": { "home": {

View File

@ -11,7 +11,7 @@ export function EpisodeTitle() {
return ( return (
<div> <div>
<span className="text-white font-medium mr-3"> <span className="text-white font-medium mr-3">
{t("seasons.seasonAndEpisode", { {t("media.episodeDisplay", {
season: meta?.season?.number, season: meta?.season?.number,
episode: meta?.episode?.number, episode: meta?.episode?.number,
})} })}

View File

@ -50,6 +50,7 @@ function SeasonsView({
selectedSeason: string; selectedSeason: string;
setSeason: (id: string) => void; setSeason: (id: string) => void;
}) { }) {
const { t } = useTranslation();
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
const [loadingState, seasons] = useSeasonData( const [loadingState, seasons] = useSeasonData(
meta?.tmdbId ?? "", meta?.tmdbId ?? "",
@ -73,13 +74,19 @@ function SeasonsView({
</Menu.Section> </Menu.Section>
); );
} else if (loadingState.error) } else if (loadingState.error)
content = <CenteredText>Error loading season</CenteredText>; content = (
<CenteredText>{t("player.menus.episodes.loadingError")}</CenteredText>
);
else if (loadingState.loading) else if (loadingState.loading)
content = <CenteredText>Loading...</CenteredText>; content = (
<CenteredText>{t("player.menus.episodes.loadingList")}</CenteredText>
);
return ( return (
<Menu.CardWithScrollable> <Menu.CardWithScrollable>
<Menu.Title>{meta?.title}</Menu.Title> <Menu.Title>
{meta?.title ?? t("player.menus.episodes.loadingTitle")}
</Menu.Title>
{content} {content}
</Menu.CardWithScrollable> </Menu.CardWithScrollable>
); );
@ -120,15 +127,19 @@ function EpisodesView({
let content: ReactNode = null; let content: ReactNode = null;
if (loadingState.error) if (loadingState.error)
content = <CenteredText>Error loading season</CenteredText>; content = (
<CenteredText>{t("player.menus.episodes.loadingError")}</CenteredText>
);
else if (loadingState.loading) else if (loadingState.loading)
content = <CenteredText>Loading...</CenteredText>; content = (
<CenteredText>{t("player.menus.episodes.loadingList")}</CenteredText>
);
else if (loadingState.value) { else if (loadingState.value) {
content = ( content = (
<Menu.ScrollToActiveSection className="pb-6"> <Menu.ScrollToActiveSection className="pb-6">
{loadingState.value.season.episodes.length === 0 ? ( {loadingState.value.season.episodes.length === 0 ? (
<Menu.TextDisplay title="No episodes found"> <Menu.TextDisplay title="No episodes found">
There are no episodes in this season, check back later! {t("player.menus.episodes.emptyState")}
</Menu.TextDisplay> </Menu.TextDisplay>
) : null} ) : null}
{loadingState.value.season.episodes.map((ep) => { {loadingState.value.season.episodes.map((ep) => {
@ -167,7 +178,9 @@ function EpisodesView({
: "bg-opacity-50" : "bg-opacity-50"
)} )}
> >
E{ep.number} {t("player.menus.episodes.episodeBadge", {
episode: ep.number,
})}
</span> </span>
<span className="line-clamp-1 break-all">{ep.title}</span> <span className="line-clamp-1 break-all">{ep.title}</span>
</div> </div>
@ -182,7 +195,8 @@ function EpisodesView({
return ( return (
<Menu.CardWithScrollable> <Menu.CardWithScrollable>
<Menu.BackLink onClick={goBack}> <Menu.BackLink onClick={goBack}>
{loadingState?.value?.season.title || t("videoPlayer.loading")} {loadingState?.value?.season.title ||
t("player.menus.episodes.loadingTitle")}
</Menu.BackLink> </Menu.BackLink>
{content} {content}
</Menu.CardWithScrollable> </Menu.CardWithScrollable>
@ -261,7 +275,7 @@ export function Episodes() {
onClick={() => router.open("/episodes")} onClick={() => router.open("/episodes")}
icon={Icons.EPISODES} icon={Icons.EPISODES}
> >
{t("videoPlayer.buttons.episodes")} {t("player.menus.episodes.button")}
</VideoPlayerButton> </VideoPlayerButton>
</OverlayAnchor> </OverlayAnchor>
); );

View File

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
@ -41,6 +42,7 @@ export function NextEpisodeButton(props: {
controlsShowing: boolean; controlsShowing: boolean;
onChange?: (meta: PlayerMeta) => void; onChange?: (meta: PlayerMeta) => void;
}) { }) {
const { t } = useTranslation();
const duration = usePlayerStore((s) => s.progress.duration); const duration = usePlayerStore((s) => s.progress.duration);
const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn); const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn);
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
@ -96,14 +98,14 @@ export function NextEpisodeButton(props: {
className="py-px box-content bg-buttons-secondary hover:bg-buttons-secondaryHover bg-opacity-90 text-buttons-secondaryText" className="py-px box-content bg-buttons-secondary hover:bg-buttons-secondaryHover bg-opacity-90 text-buttons-secondaryText"
onClick={hideNextEpisodeButton} onClick={hideNextEpisodeButton}
> >
Cancel {t("player.nextEpisode.cancel")}
</Button> </Button>
<Button <Button
onClick={() => loadNextEpisode()} onClick={() => loadNextEpisode()}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
> >
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} /> <Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
Next episode {t("player.nextEpisode.next")}
</Button> </Button>
</div> </div>
</Transition> </Transition>

View File

@ -14,7 +14,7 @@ export function SkipForward(props: { iconSizeClass?: string }) {
return ( return (
<VideoPlayerButton <VideoPlayerButton
iconSizeClass={props.iconSizeClass || ""} iconSizeClass={props.iconSizeClass}
onClick={commit} onClick={commit}
icon={Icons.SKIP_FORWARD} icon={Icons.SKIP_FORWARD}
/> />
@ -31,7 +31,7 @@ export function SkipBackward(props: { iconSizeClass?: string }) {
return ( return (
<VideoPlayerButton <VideoPlayerButton
iconSizeClass={props.iconSizeClass || ""} iconSizeClass={props.iconSizeClass}
onClick={commit} onClick={commit}
icon={Icons.SKIP_BACKWARD} icon={Icons.SKIP_BACKWARD}
/> />

View File

@ -9,10 +9,14 @@ export function Time(props: { short?: boolean }) {
const timeFormat = usePlayerStore((s) => s.interface.timeFormat); const timeFormat = usePlayerStore((s) => s.interface.timeFormat);
const setTimeFormat = usePlayerStore((s) => s.setTimeFormat); const setTimeFormat = usePlayerStore((s) => s.setTimeFormat);
const { duration, time, draggingTime } = usePlayerStore((s) => s.progress); const {
duration: timeDuration,
time,
draggingTime,
} = usePlayerStore((s) => s.progress);
const { isSeeking } = usePlayerStore((s) => s.interface); const { isSeeking } = usePlayerStore((s) => s.interface);
const { t } = useTranslation(); const { t } = useTranslation();
const hasHours = durationExceedsHour(duration); const hasHours = durationExceedsHour(timeDuration);
function toggleMode() { function toggleMode() {
setTimeFormat( setTimeFormat(
@ -24,47 +28,36 @@ export function Time(props: { short?: boolean }) {
const currentTime = Math.min( const currentTime = Math.min(
Math.max(isSeeking ? draggingTime : time, 0), Math.max(isSeeking ? draggingTime : time, 0),
duration timeDuration
); );
const secondsRemaining = Math.abs(currentTime - duration); const secondsRemaining = Math.abs(currentTime - timeDuration);
const timeLeft = formatSeconds(
secondsRemaining,
durationExceedsHour(secondsRemaining)
);
const timeWatched = formatSeconds(currentTime, hasHours);
const timeFinished = new Date(Date.now() + secondsRemaining * 1e3); const timeFinished = new Date(Date.now() + secondsRemaining * 1e3);
const duration = formatSeconds(timeDuration, hasHours);
const formattedTimeFinished = t("videoPlayer.finishAt", { let localizationKey = "regular";
timeFinished, if (props.short) localizationKey = "short";
formatParams: { else if (timeFormat === VideoPlayerTimeFormat.REMAINING)
timeFinished: { hour: "numeric", minute: "numeric" }, localizationKey = "remaining";
},
});
let timeString;
let timeFinishedString;
if (props.short) {
timeString = formatSeconds(currentTime, hasHours);
timeFinishedString = `-${formatSeconds(
secondsRemaining,
durationExceedsHour(secondsRemaining)
)}`;
} else {
timeString = `${formatSeconds(currentTime, hasHours)} / ${formatSeconds(
duration,
hasHours
)}`;
timeFinishedString = `${t("videoPlayer.timeLeft", {
timeLeft: formatSeconds(
secondsRemaining,
durationExceedsHour(secondsRemaining)
),
})} ${formattedTimeFinished}`;
}
const child =
timeFormat === VideoPlayerTimeFormat.REGULAR ? (
<span>{timeString}</span>
) : (
<span>{timeFinishedString}</span>
);
return ( return (
<VideoPlayerButton onClick={() => toggleMode()}>{child}</VideoPlayerButton> <VideoPlayerButton onClick={() => toggleMode()}>
<span>
{t(`player.time.${localizationKey}`, {
timeFinished,
timeWatched,
timeLeft,
duration,
formatParams: {
timeFinished: { hour: "numeric", minute: "numeric" },
},
})}
</span>
</VideoPlayerButton>
); );
} }

View File

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Toggle } from "@/components/buttons/Toggle"; import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
@ -213,6 +214,7 @@ export function CaptionSetting(props: {
export const colors = ["#ffffff", "#80b1fa", "#e2e535"]; export const colors = ["#ffffff", "#80b1fa", "#e2e535"];
export function CaptionSettingsView({ id }: { id: string }) { export function CaptionSettingsView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const styling = useSubtitleStore((s) => s.styling); const styling = useSubtitleStore((s) => s.styling);
const overrideCasing = useSubtitleStore((s) => s.overrideCasing); const overrideCasing = useSubtitleStore((s) => s.overrideCasing);
@ -228,7 +230,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
</Menu.BackLink> </Menu.BackLink>
<Menu.Section className="space-y-6"> <Menu.Section className="space-y-6">
<CaptionSetting <CaptionSetting
label="Caption delay" label={t("player.menus.captions.settings.fixCapitals")}
max={10} max={10}
min={-10} min={-10}
onChange={(v) => setDelay(v)} onChange={(v) => setDelay(v)}
@ -238,7 +240,9 @@ export function CaptionSettingsView({ id }: { id: string }) {
controlButtons controlButtons
/> />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Menu.FieldTitle>Fix capitalization</Menu.FieldTitle> <Menu.FieldTitle>
{t("player.menus.captions.settings.delay")}
</Menu.FieldTitle>
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
<Toggle <Toggle
enabled={overrideCasing} enabled={overrideCasing}
@ -248,7 +252,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
</div> </div>
<Menu.Divider /> <Menu.Divider />
<CaptionSetting <CaptionSetting
label="Background opacity" label={t("settings.captions.backgroundLabel")}
max={100} max={100}
min={0} min={0}
onChange={(v) => updateStyling({ backgroundOpacity: v / 100 })} onChange={(v) => updateStyling({ backgroundOpacity: v / 100 })}
@ -256,7 +260,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
textTransformer={(s) => `${s}%`} textTransformer={(s) => `${s}%`}
/> />
<CaptionSetting <CaptionSetting
label="Text size" label={t("settings.captions.textSizeLabel")}
max={200} max={200}
min={1} min={1}
textTransformer={(s) => `${s}%`} textTransformer={(s) => `${s}%`}
@ -264,7 +268,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
value={styling.size * 100} value={styling.size * 100}
/> />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Menu.FieldTitle>Color</Menu.FieldTitle> <Menu.FieldTitle>{t("settings.captions.colorLabel")}</Menu.FieldTitle>
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
{colors.map((v) => ( {colors.map((v) => (
<ColorOption <ColorOption

View File

@ -1,5 +1,6 @@
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncFn } from "react-use"; import { useAsyncFn } from "react-use";
import { convert } from "subsrt-ts"; import { convert } from "subsrt-ts";
@ -45,6 +46,7 @@ export function CaptionOption(props: {
} }
function CustomCaptionOption() { function CustomCaptionOption() {
const { t } = useTranslation();
const lang = usePlayerStore((s) => s.caption.selected?.language); const lang = usePlayerStore((s) => s.caption.selected?.language);
const setCaption = usePlayerStore((s) => s.setCaption); const setCaption = usePlayerStore((s) => s.setCaption);
const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs); const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs);
@ -55,7 +57,7 @@ function CustomCaptionOption() {
selected={lang === "custom"} selected={lang === "custom"}
onClick={() => fileInput.current?.click()} onClick={() => fileInput.current?.click()}
> >
Upload captions {t("player.menus.captions.customChoice")}
<input <input
className="hidden" className="hidden"
ref={fileInput} ref={fileInput}
@ -82,10 +84,12 @@ function CustomCaptionOption() {
} }
function useSubtitleList(subs: CaptionListItem[], searchQuery: string) { function useSubtitleList(subs: CaptionListItem[], searchQuery: string) {
const { t: translate } = useTranslation();
const unknownChoice = translate("player.menus.captions.unknownLanguage");
return useMemo(() => { return useMemo(() => {
const input = subs.map((t) => ({ const input = subs.map((t) => ({
...t, ...t,
languageName: getLanguageFromIETF(t.language) ?? "Unknown", languageName: getLanguageFromIETF(t.language) ?? unknownChoice,
})); }));
const sorted = sortLangCodes(input.map((t) => t.language)); const sorted = sortLangCodes(input.map((t) => t.language));
let results = input.sort((a, b) => { let results = input.sort((a, b) => {
@ -102,10 +106,11 @@ function useSubtitleList(subs: CaptionListItem[], searchQuery: string) {
} }
return results; return results;
}, [subs, searchQuery]); }, [subs, searchQuery, unknownChoice]);
} }
export function CaptionsView({ id }: { id: string }) { export function CaptionsView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const lang = usePlayerStore((s) => s.caption.selected?.language); const lang = usePlayerStore((s) => s.caption.selected?.language);
const [currentlyDownloading, setCurrentlyDownloading] = useState< const [currentlyDownloading, setCurrentlyDownloading] = useState<
@ -155,11 +160,11 @@ export function CaptionsView({ id }: { id: string }) {
onClick={() => router.navigate("/captions/settings")} onClick={() => router.navigate("/captions/settings")}
className="py-1 -my-1 px-3 -mx-3 rounded tabbable" className="py-1 -my-1 px-3 -mx-3 rounded tabbable"
> >
Customize {t("player.menus.captions.customizeLabel")}
</button> </button>
} }
> >
Captions {t("player.menus.captions.title")}
</Menu.BackLink> </Menu.BackLink>
<div className="mt-3"> <div className="mt-3">
<Input value={searchQuery} onInput={setSearchQuery} /> <Input value={searchQuery} onInput={setSearchQuery} />
@ -167,7 +172,7 @@ export function CaptionsView({ id }: { id: string }) {
</div> </div>
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3"> <Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
<CaptionOption onClick={() => disable()} selected={!lang}> <CaptionOption onClick={() => disable()} selected={!lang}>
Off {t("player.menus.captions.offChoice")}
</CaptionOption> </CaptionOption>
<CustomCaptionOption /> <CustomCaptionOption />
{content} {content}

View File

@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
@ -19,8 +20,26 @@ function useDownloadLink() {
return url; return url;
} }
function StyleTrans(props: { k: string }) {
return (
<Trans
i18nKey={props.k}
components={{
bold: <Menu.Highlight />,
ios_share: (
<Icon icon={Icons.IOS_SHARE} className="inline-block text-xl -mb-1" />
),
ios_files: (
<Icon icon={Icons.IOS_FILES} className="inline-block text-xl -mb-1" />
),
}}
/>
);
}
export function DownloadView({ id }: { id: string }) { export function DownloadView({ id }: { id: string }) {
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const { t } = useTranslation();
const downloadUrl = useDownloadLink(); const downloadUrl = useDownloadLink();
const selectedCaption = usePlayerStore((s) => s.caption?.selected); const selectedCaption = usePlayerStore((s) => s.caption?.selected);
@ -37,31 +56,30 @@ export function DownloadView({ id }: { id: string }) {
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/")}> <Menu.BackLink onClick={() => router.navigate("/")}>
Download {t("player.menus.downloads.title")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
<div> <div>
<Menu.ChevronLink onClick={() => router.navigate("/download/pc")}> <Menu.ChevronLink onClick={() => router.navigate("/download/pc")}>
Downloading on PC {t("player.menus.downloads.onPc.title")}
</Menu.ChevronLink> </Menu.ChevronLink>
<Menu.ChevronLink onClick={() => router.navigate("/download/ios")}> <Menu.ChevronLink onClick={() => router.navigate("/download/ios")}>
Downloading on iOS {t("player.menus.downloads.onIos.title")}
</Menu.ChevronLink> </Menu.ChevronLink>
<Menu.ChevronLink <Menu.ChevronLink
onClick={() => router.navigate("/download/android")} onClick={() => router.navigate("/download/android")}
> >
Downloading on Android {t("player.menus.downloads.onAndroid.title")}
</Menu.ChevronLink> </Menu.ChevronLink>
<Menu.Divider /> <Menu.Divider />
<Menu.Paragraph marginClass="my-6"> <Menu.Paragraph marginClass="my-6">
Downloads are taken directly from the provider. movie-web does not <StyleTrans k="player.menus.downloads.disclaimer" />
have control over how the downloads are provided.
</Menu.Paragraph> </Menu.Paragraph>
<Button className="w-full" href={downloadUrl} theme="purple"> <Button className="w-full" href={downloadUrl} theme="purple">
Download video {t("player.menus.downloads.downloadVideo")}
</Button> </Button>
<Button <Button
className="w-full mt-2" className="w-full mt-2"
@ -70,7 +88,7 @@ export function DownloadView({ id }: { id: string }) {
theme="secondary" theme="secondary"
download="subtitles.srt" download="subtitles.srt"
> >
Download current caption {t("player.menus.downloads.downloadCaption")}
</Button> </Button>
</div> </div>
</Menu.Section> </Menu.Section>
@ -80,15 +98,16 @@ export function DownloadView({ id }: { id: string }) {
export function CantDownloadView({ id }: { id: string }) { export function CantDownloadView({ id }: { id: string }) {
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const { t } = useTranslation();
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/")}> <Menu.BackLink onClick={() => router.navigate("/")}>
Download {t("player.menus.downloads.title")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
<Menu.Paragraph> <Menu.Paragraph>
Insert explanation for why you can&apos;t download HLS here <StyleTrans k="player.menus.downloads.hlsExplanation" />
</Menu.Paragraph> </Menu.Paragraph>
</Menu.Section> </Menu.Section>
</> </>
@ -97,16 +116,16 @@ export function CantDownloadView({ id }: { id: string }) {
function AndroidExplanationView({ id }: { id: string }) { function AndroidExplanationView({ id }: { id: string }) {
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const { t } = useTranslation();
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/download")}> <Menu.BackLink onClick={() => router.navigate("/download")}>
Download / Android {t("player.menus.downloads.onAndroid.shortTitle")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
<Menu.Paragraph> <Menu.Paragraph>
To download on Android, <Menu.Highlight>tap and hold</Menu.Highlight>{" "} <StyleTrans k="player.menus.downloads.onAndroid.1" />
on the video, then select <Menu.Highlight>save</Menu.Highlight>.
</Menu.Paragraph> </Menu.Paragraph>
</Menu.Section> </Menu.Section>
</> </>
@ -115,16 +134,16 @@ function AndroidExplanationView({ id }: { id: string }) {
function PCExplanationView({ id }: { id: string }) { function PCExplanationView({ id }: { id: string }) {
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const { t } = useTranslation();
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/download")}> <Menu.BackLink onClick={() => router.navigate("/download")}>
Download / PC {t("player.menus.downloads.onPc.shortTitle")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
<Menu.Paragraph> <Menu.Paragraph>
On PC, right click the video and select{" "} <StyleTrans k="player.menus.downloads.onPc.1" />
<Menu.Highlight>Save video as</Menu.Highlight>
</Menu.Paragraph> </Menu.Paragraph>
</Menu.Section> </Menu.Section>
</> </>
@ -137,27 +156,11 @@ function IOSExplanationView({ id }: { id: string }) {
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/download")}> <Menu.BackLink onClick={() => router.navigate("/download")}>
Download / iOS <StyleTrans k="player.menus.downloads.onIos.shortTitle" />
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
<Menu.Paragraph> <Menu.Paragraph>
To download on iOS, click{" "} <StyleTrans k="player.menus.downloads.onIos.1" />
<Menu.Highlight>
<Icon
className="inline-block text-xl -mb-1"
icon={Icons.IOS_SHARE}
/>
</Menu.Highlight>
, then{" "}
<Menu.Highlight>
Save to Files
<Icon
className="inline-block text-xl -mb-1 mx-1"
icon={Icons.IOS_FILES}
/>
</Menu.Highlight>{" "}
. All that&apos;s left to do now is to pick a nice and cozy folder for
your video!
</Menu.Paragraph> </Menu.Paragraph>
</Menu.Section> </Menu.Section>
</> </>

View File

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Menu } from "@/components/player/internals/ContextMenu"; import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
@ -34,6 +35,7 @@ function ButtonList(props: {
} }
export function PlaybackSettingsView({ id }: { id: string }) { export function PlaybackSettingsView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
const display = usePlayerStore((s) => s.display); const display = usePlayerStore((s) => s.display);
@ -50,11 +52,13 @@ export function PlaybackSettingsView({ id }: { id: string }) {
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/")}> <Menu.BackLink onClick={() => router.navigate("/")}>
Playback settings {t("player.menus.playback.title")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
<div className="space-y-4 mt-3"> <div className="space-y-4 mt-3">
<Menu.FieldTitle>Playback speed</Menu.FieldTitle> <Menu.FieldTitle>
{t("player.menus.playback.speedLabel")}
</Menu.FieldTitle>
<ButtonList <ButtonList
options={options} options={options}
selected={playbackRate} selected={playbackRate}

View File

@ -1,4 +1,6 @@
import { t } from "i18next";
import { useCallback } from "react"; import { useCallback } from "react";
import { Trans } from "react-i18next";
import { Toggle } from "@/components/buttons/Toggle"; import { Toggle } from "@/components/buttons/Toggle";
import { Menu } from "@/components/player/internals/ContextMenu"; import { Menu } from "@/components/player/internals/ContextMenu";
@ -57,7 +59,7 @@ export function QualityView({ id }: { id: string }) {
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/")}> <Menu.BackLink onClick={() => router.navigate("/")}>
Quality {t("player.menus.quality.title")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
{visibleQualities.map((v) => ( {visibleQualities.map((v) => (
@ -76,14 +78,14 @@ export function QualityView({ id }: { id: string }) {
<Menu.Link <Menu.Link
rightSide={<Toggle onClick={changeAutomatic} enabled={autoQuality} />} rightSide={<Toggle onClick={changeAutomatic} enabled={autoQuality} />}
> >
Automatic quality {t("player.menus.quality.automaticLabel")}
</Menu.Link> </Menu.Link>
<Menu.SmallText> <Menu.SmallText>
You can try{" "} <Trans i18nKey="player.menus.quality.hint">
<Menu.Anchor onClick={() => router.navigate("/source")}> <Menu.Anchor onClick={() => router.navigate("/source")}>
switching source text
</Menu.Anchor>{" "} </Menu.Anchor>
to get different quality options. </Trans>
</Menu.SmallText> </Menu.SmallText>
</Menu.Section> </Menu.Section>
</> </>

View File

@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Toggle } from "@/components/buttons/Toggle"; import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
@ -12,6 +13,7 @@ import { useSubtitleStore } from "@/stores/subtitles";
import { providers } from "@/utils/providers"; import { providers } from "@/utils/providers";
export function SettingsMenu({ id }: { id: string }) { export function SettingsMenu({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const currentQuality = usePlayerStore((s) => s.currentQuality); const currentQuality = usePlayerStore((s) => s.currentQuality);
const selectedCaptionLanguage = usePlayerStore( const selectedCaptionLanguage = usePlayerStore(
@ -26,26 +28,29 @@ export function SettingsMenu({ id }: { id: string }) {
const { toggleLastUsed } = useCaptions(); const { toggleLastUsed } = useCaptions();
const selectedLanguagePretty = selectedCaptionLanguage const selectedLanguagePretty = selectedCaptionLanguage
? getLanguageFromIETF(selectedCaptionLanguage) ?? "unknown" ? getLanguageFromIETF(selectedCaptionLanguage) ??
t("player.menus.captions.unknownLanguage")
: undefined; : undefined;
const source = usePlayerStore((s) => s.source); const source = usePlayerStore((s) => s.source);
return ( return (
<Menu.Card> <Menu.Card>
<Menu.SectionTitle>Video settings</Menu.SectionTitle> <Menu.SectionTitle>
{t("player.menus.settings.videoSection")}
</Menu.SectionTitle>
<Menu.Section> <Menu.Section>
<Menu.ChevronLink <Menu.ChevronLink
onClick={() => router.navigate("/quality")} onClick={() => router.navigate("/quality")}
rightText={currentQuality ? qualityToString(currentQuality) : ""} rightText={currentQuality ? qualityToString(currentQuality) : ""}
> >
Quality {t("player.menus.settings.qualityItem")}
</Menu.ChevronLink> </Menu.ChevronLink>
<Menu.ChevronLink <Menu.ChevronLink
onClick={() => router.navigate("/source")} onClick={() => router.navigate("/source")}
rightText={sourceName} rightText={sourceName}
> >
Video source {t("player.menus.settings.sourceItem")}
</Menu.ChevronLink> </Menu.ChevronLink>
<Menu.Link <Menu.Link
clickable clickable
@ -57,11 +62,13 @@ export function SettingsMenu({ id }: { id: string }) {
rightSide={<Icon className="text-xl" icon={Icons.DOWNLOAD} />} rightSide={<Icon className="text-xl" icon={Icons.DOWNLOAD} />}
className={source?.type === "file" ? "opacity-100" : "opacity-50"} className={source?.type === "file" ? "opacity-100" : "opacity-50"}
> >
Download {t("player.menus.settings.downloadItem")}
</Menu.Link> </Menu.Link>
</Menu.Section> </Menu.Section>
<Menu.SectionTitle>Viewing Experience</Menu.SectionTitle> <Menu.SectionTitle>
{t("player.menus.settings.experienceSection")}
</Menu.SectionTitle>
<Menu.Section> <Menu.Section>
<Menu.Link <Menu.Link
rightSide={ rightSide={
@ -71,16 +78,16 @@ export function SettingsMenu({ id }: { id: string }) {
/> />
} }
> >
Enable Captions {t("player.menus.settings.enableCaptions")}
</Menu.Link> </Menu.Link>
<Menu.ChevronLink <Menu.ChevronLink
onClick={() => router.navigate("/captions")} onClick={() => router.navigate("/captions")}
rightText={selectedLanguagePretty} rightText={selectedLanguagePretty ?? undefined}
> >
Caption settings {t("player.menus.settings.captionItem")}
</Menu.ChevronLink> </Menu.ChevronLink>
<Menu.ChevronLink onClick={() => router.navigate("/playback")}> <Menu.ChevronLink onClick={() => router.navigate("/playback")}>
Playback settings {t("player.menus.settings.playbackItem")}
</Menu.ChevronLink> </Menu.ChevronLink>
</Menu.Section> </Menu.Section>
</Menu.Card> </Menu.Card>

View File

@ -1,4 +1,5 @@
import { ReactNode, useEffect, useMemo, useRef } from "react"; import { ReactNode, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Loading } from "@/components/layout/Loading"; import { Loading } from "@/components/layout/Loading";
import { import {
@ -27,13 +28,14 @@ export function EmbedOption(props: {
sourceId: string; sourceId: string;
routerId: string; routerId: string;
}) { }) {
const unknownEmbedName = "Unknown"; const { t } = useTranslation();
const unknownEmbedName = t("player.menus.sources.unknownOption");
const embedName = useMemo(() => { const embedName = useMemo(() => {
if (!props.embedId) return unknownEmbedName; if (!props.embedId) return unknownEmbedName;
const sourceMeta = providers.getMetadata(props.embedId); const sourceMeta = providers.getMetadata(props.embedId);
return sourceMeta?.name ?? unknownEmbedName; return sourceMeta?.name ?? unknownEmbedName;
}, [props.embedId]); }, [props.embedId, unknownEmbedName]);
const { run, errored, loading } = useEmbedScraping( const { run, errored, loading } = useEmbedScraping(
props.routerId, props.routerId,
@ -52,6 +54,7 @@ export function EmbedOption(props: {
} }
export function EmbedSelectionView({ sourceId, id }: EmbedSelectionViewProps) { export function EmbedSelectionView({ sourceId, id }: EmbedSelectionViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const { run, watching, notfound, loading, items, errored } = const { run, watching, notfound, loading, items, errored } =
useSourceScraping(sourceId, id); useSourceScraping(sourceId, id);
@ -79,21 +82,26 @@ export function EmbedSelectionView({ sourceId, id }: EmbedSelectionViewProps) {
); );
else if (notfound) else if (notfound)
content = ( content = (
<Menu.TextDisplay title="No stream"> <Menu.TextDisplay
This source has no streams for this movie or show. title={t("player.menus.sources.noStream.title") ?? undefined}
>
{t("player.menus.sources.noStream.text")}
</Menu.TextDisplay> </Menu.TextDisplay>
); );
else if (items?.length === 0) else if (items?.length === 0)
content = ( content = (
<Menu.TextDisplay title="No embeds found"> <Menu.TextDisplay
We were unable to find any embeds for this source, please try another. title={t("player.menus.sources.noEmbeds.title") ?? undefined}
>
{t("player.menus.sources.noEmbeds.text")}
</Menu.TextDisplay> </Menu.TextDisplay>
); );
else if (errored) else if (errored)
content = ( content = (
<Menu.TextDisplay title="Failed to scrape"> <Menu.TextDisplay
We were unable to find any videos for this source. Don&apos;t come title={t("player.menus.sources.failed.title") ?? undefined}
bitchin&apos; to us about it, just try another source. >
{t("player.menus.sources.failed.text")}
</Menu.TextDisplay> </Menu.TextDisplay>
); );
else if (watching) else if (watching)
@ -123,6 +131,7 @@ export function SourceSelectionView({
id, id,
onChoose, onChoose,
}: SourceSelectionViewProps) { }: SourceSelectionViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const metaType = usePlayerStore((s) => s.meta?.type); const metaType = usePlayerStore((s) => s.meta?.type);
const currentSourceId = usePlayerStore((s) => s.sourceId); const currentSourceId = usePlayerStore((s) => s.sourceId);
@ -136,7 +145,7 @@ export function SourceSelectionView({
return ( return (
<> <>
<Menu.BackLink onClick={() => router.navigate("/")}> <Menu.BackLink onClick={() => router.navigate("/")}>
Sources {t("player.menus.sources.title")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section>
{sources.map((v) => ( {sources.map((v) => (

View File

@ -15,8 +15,8 @@ export function BackLink(props: { url: string }) {
className="py-1 -my-1 px-2 -mx-2 tabbable rounded-lg flex items-center cursor-pointer text-type-secondary hover:text-white transition-colors duration-200 font-medium" className="py-1 -my-1 px-2 -mx-2 tabbable rounded-lg flex items-center cursor-pointer text-type-secondary hover:text-white transition-colors duration-200 font-medium"
> >
<Icon className="mr-2" icon={Icons.ARROW_LEFT} /> <Icon className="mr-2" icon={Icons.ARROW_LEFT} />
<span className="md:hidden">{t("videoPlayer.backToHomeShort")}</span> <span className="md:hidden">{t("player.back.short")}</span>
<span className="hidden md:block">{t("videoPlayer.backToHome")}</span> <span className="hidden md:block">{t("player.back.default")}</span>
</button> </button>
</div> </div>
); );

View File

@ -163,7 +163,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
const errorDetails = getMediaErrorDetails(err); const errorDetails = getMediaErrorDetails(err);
emit("error", { emit("error", {
errorName: errorDetails.name, errorName: errorDetails.name,
message: errorDetails.message, key: errorDetails.key,
type: "htmlvideo", type: "htmlvideo",
}); });
}); });

View File

@ -4,7 +4,8 @@ import { Listener } from "@/utils/events";
export type DisplayErrorType = "hls" | "htmlvideo"; export type DisplayErrorType = "hls" | "htmlvideo";
export type DisplayError = { export type DisplayError = {
stackTrace?: string; stackTrace?: string;
message: string; message?: string;
key?: string;
errorName: string; errorName: string;
type: DisplayErrorType; type: DisplayErrorType;
}; };

View File

@ -1 +0,0 @@
export function test() {}

View File

@ -56,7 +56,7 @@ export function Paragraph(props: {
return <p className={props.marginClass ?? "my-3"}>{props.children}</p>; return <p className={props.marginClass ?? "my-3"}>{props.children}</p>;
} }
export function Highlight(props: { children: React.ReactNode }) { export function Highlight(props: { children?: React.ReactNode }) {
return <span className="text-white">{props.children}</span>; return <span className="text-white">{props.children}</span>;
} }

View File

@ -16,7 +16,7 @@ export function HeadUpdater() {
); );
} }
const humanizedEpisodeId = t("videoPlayer.seasonAndEpisode", { const humanizedEpisodeId = t("media.episodeDisplay", {
season: meta.season?.number, season: meta.season?.number,
episode: meta.episode?.number, episode: meta.episode?.number,
}); });

View File

@ -29,6 +29,7 @@ export function KeyboardEvents() {
mediaPlaying, mediaPlaying,
isRolling, isRolling,
time, time,
router,
}); });
useEffect(() => { useEffect(() => {
dataRef.current = { dataRef.current = {
@ -41,6 +42,7 @@ export function KeyboardEvents() {
mediaPlaying, mediaPlaying,
isRolling, isRolling,
time, time,
router,
}; };
}, [ }, [
setShowVolume, setShowVolume,
@ -52,6 +54,7 @@ export function KeyboardEvents() {
mediaPlaying, mediaPlaying,
isRolling, isRolling,
time, time,
router,
]); ]);
useEffect(() => { useEffect(() => {
@ -92,7 +95,7 @@ export function KeyboardEvents() {
dataRef.current.display?.[ dataRef.current.display?.[
dataRef.current.mediaPlaying.isPaused ? "play" : "pause" dataRef.current.mediaPlaying.isPaused ? "play" : "pause"
](); ]();
if (k === "Escape") router.close(); if (k === "Escape") dataRef.current.router.close();
// captions // captions
if (k === "c") dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors if (k === "c") dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors
@ -117,7 +120,7 @@ export function KeyboardEvents() {
return () => { return () => {
window.removeEventListener("keydown", keyEventHandler); window.removeEventListener("keydown", keyEventHandler);
}; };
}, [router]); }, []);
return null; return null;
} }

View File

@ -38,6 +38,10 @@ declare global {
} }
} }
/**
* MetaReporter occasionally reports the progress to the window object at a specific spot
* This is used by the PreMid presence to get currently playing data
*/
export function MetaReporter() { export function MetaReporter() {
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
const progress = usePlayerStore((s) => s.progress); const progress = usePlayerStore((s) => s.progress);

View File

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { StatusCircle } from "@/components/player/internals/StatusCircle"; import { StatusCircle } from "@/components/player/internals/StatusCircle";
import { Transition } from "@/components/utils/Transition"; import { Transition } from "@/components/utils/Transition";
@ -17,9 +18,9 @@ export interface ScrapeCardProps extends ScrapeItemProps {
} }
const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = { const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = {
notfound: "Doesn't have the video", notfound: "player.scraping.items.notFound",
failure: "Error occured", failure: "player.scraping.items.failure",
pending: "Checking for videos...", pending: "player.scraping.items.pending",
}; };
const statusMap: Record<ScrapeCardProps["status"], StatusCircle["type"]> = { const statusMap: Record<ScrapeCardProps["status"], StatusCircle["type"]> = {
@ -31,6 +32,7 @@ const statusMap: Record<ScrapeCardProps["status"], StatusCircle["type"]> = {
}; };
export function ScrapeItem(props: ScrapeItemProps) { export function ScrapeItem(props: ScrapeItemProps) {
const { t } = useTranslation();
const text = statusTextMap[props.status]; const text = statusTextMap[props.status];
const status = statusMap[props.status]; const status = statusMap[props.status];
@ -46,7 +48,7 @@ export function ScrapeItem(props: ScrapeItemProps) {
{props.name} {props.name}
</p> </p>
<Transition animation="fade" show={!!text}> <Transition animation="fade" show={!!text}>
<p className="text-[15px] mt-1">{text}</p> <p className="text-[15px] mt-1">{text ? t(text) : ""}</p>
</Transition> </Transition>
{props.children} {props.children}
</div> </div>

View File

@ -1,4 +1,5 @@
export function handleBuffered(time: number, buffered: TimeRanges): number { export function handleBuffered(time: number, buffered: TimeRanges): number {
// TODO normalize the buffer sections into one section. they can be stitched together
for (let i = 0; i < buffered.length; i += 1) { for (let i = 0; i < buffered.length; i += 1) {
if (buffered.start(buffered.length - 1 - i) < time) { if (buffered.start(buffered.length - 1 - i) < time) {
return buffered.end(buffered.length - 1 - i); return buffered.end(buffered.length - 1 - i);

View File

@ -1,35 +1,30 @@
const mediaErrorMap: Record<number, { name: string; message: string }> = { const mediaErrorMap: Record<number, { name: string; key: string }> = {
1: { 1: {
name: "MEDIA_ERR_ABORTED", name: "MEDIA_ERR_ABORTED",
message: key: "player.playbackError.errors.errorAborted",
"The fetching of the associated resource was aborted by the user's request.",
}, },
2: { 2: {
name: "MEDIA_ERR_NETWORK", name: "MEDIA_ERR_NETWORK",
message: key: "player.playbackError.errors.errorNetwork",
"Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
}, },
3: { 3: {
name: "MEDIA_ERR_DECODE", name: "MEDIA_ERR_DECODE",
message: key: "player.playbackError.errors.errorDecode",
"Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.",
}, },
4: { 4: {
name: "MEDIA_ERR_SRC_NOT_SUPPORTED", name: "MEDIA_ERR_SRC_NOT_SUPPORTED",
message: key: "player.playbackError.errors.errorNotSupported",
"The associated resource or media provider object has been found to be unsuitable.",
}, },
}; };
export function getMediaErrorDetails(err: MediaError | null): { export function getMediaErrorDetails(
name: string; err: MediaError | null
message: string; ): (typeof mediaErrorMap)[number] {
} {
const item = mediaErrorMap[err?.code ?? -1]; const item = mediaErrorMap[err?.code ?? -1];
if (!item) { if (!item) {
return { return {
name: "MediaError", name: "MEDIA_ERR_GENERIC",
message: "Unknown media error occured", key: "player.playbackError.errors.errorGenericMedia",
}; };
} }
return item; return item;

View File

@ -13,8 +13,12 @@ export function ErrorCard(props: { error: DisplayError | string }) {
); );
const { t } = useTranslation(); const { t } = useTranslation();
const errorMessage = let errorMessage: string | null = null;
typeof props.error === "string" ? props.error : props.error.message; if (typeof props.error === "string") errorMessage = props.error;
else if (props.error.key)
errorMessage = `${props.error.type}: ${t(props.error.key)}`;
else if (props.error.message)
errorMessage = `${props.error.type}: ${t(props.error.message)}`;
function copyError() { function copyError() {
if (!props.error || !navigator.clipboard) return; if (!props.error || !navigator.clipboard) return;