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",
"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"
},
"items": {
"pending": "Checking for videos...",
"notFound": "Doesn't have the video",
"failure": "Error occured"
}
},
"playbackError": {
"badge": "Not found",
"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 💖",
"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": {
"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 💖",
"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": {

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import classNames from "classnames";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
@ -41,6 +42,7 @@ export function NextEpisodeButton(props: {
controlsShowing: boolean;
onChange?: (meta: PlayerMeta) => void;
}) {
const { t } = useTranslation();
const duration = usePlayerStore((s) => s.progress.duration);
const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn);
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"
onClick={hideNextEpisodeButton}
>
Cancel
{t("player.nextEpisode.cancel")}
</Button>
<Button
onClick={() => loadNextEpisode()}
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} />
Next episode
{t("player.nextEpisode.next")}
</Button>
</div>
</Transition>

View File

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

View File

@ -9,10 +9,14 @@ export function Time(props: { short?: boolean }) {
const timeFormat = usePlayerStore((s) => s.interface.timeFormat);
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 { t } = useTranslation();
const hasHours = durationExceedsHour(duration);
const hasHours = durationExceedsHour(timeDuration);
function toggleMode() {
setTimeFormat(
@ -24,47 +28,36 @@ export function Time(props: { short?: boolean }) {
const currentTime = Math.min(
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 duration = formatSeconds(timeDuration, hasHours);
const formattedTimeFinished = t("videoPlayer.finishAt", {
timeFinished,
formatParams: {
timeFinished: { hour: "numeric", minute: "numeric" },
},
});
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>
);
let localizationKey = "regular";
if (props.short) localizationKey = "short";
else if (timeFormat === VideoPlayerTimeFormat.REMAINING)
localizationKey = "remaining";
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 { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon";
@ -213,6 +214,7 @@ export function CaptionSetting(props: {
export const colors = ["#ffffff", "#80b1fa", "#e2e535"];
export function CaptionSettingsView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const styling = useSubtitleStore((s) => s.styling);
const overrideCasing = useSubtitleStore((s) => s.overrideCasing);
@ -228,7 +230,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
</Menu.BackLink>
<Menu.Section className="space-y-6">
<CaptionSetting
label="Caption delay"
label={t("player.menus.captions.settings.fixCapitals")}
max={10}
min={-10}
onChange={(v) => setDelay(v)}
@ -238,7 +240,9 @@ export function CaptionSettingsView({ id }: { id: string }) {
controlButtons
/>
<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">
<Toggle
enabled={overrideCasing}
@ -248,7 +252,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
</div>
<Menu.Divider />
<CaptionSetting
label="Background opacity"
label={t("settings.captions.backgroundLabel")}
max={100}
min={0}
onChange={(v) => updateStyling({ backgroundOpacity: v / 100 })}
@ -256,7 +260,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
textTransformer={(s) => `${s}%`}
/>
<CaptionSetting
label="Text size"
label={t("settings.captions.textSizeLabel")}
max={200}
min={1}
textTransformer={(s) => `${s}%`}
@ -264,7 +268,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
value={styling.size * 100}
/>
<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">
{colors.map((v) => (
<ColorOption

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,8 @@ import { Listener } from "@/utils/events";
export type DisplayErrorType = "hls" | "htmlvideo";
export type DisplayError = {
stackTrace?: string;
message: string;
message?: string;
key?: string;
errorName: string;
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>;
}
export function Highlight(props: { children: React.ReactNode }) {
export function Highlight(props: { children?: React.ReactNode }) {
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,
episode: meta.episode?.number,
});

View File

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

View File

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

View File

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

View File

@ -13,8 +13,12 @@ export function ErrorCard(props: { error: DisplayError | string }) {
);
const { t } = useTranslation();
const errorMessage =
typeof props.error === "string" ? props.error : props.error.message;
let errorMessage: string | null = null;
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() {
if (!props.error || !navigator.clipboard) return;