mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
Localize the rest of everything
This commit is contained in:
parent
7537ebb56c
commit
a4808415db
25 changed files with 313 additions and 158 deletions
|
@ -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": {
|
||||||
|
|
|
@ -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,
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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'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's left to do now is to pick a nice and cozy folder for
|
|
||||||
your video!
|
|
||||||
</Menu.Paragraph>
|
</Menu.Paragraph>
|
||||||
</Menu.Section>
|
</Menu.Section>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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't come
|
title={t("player.menus.sources.failed.title") ?? undefined}
|
||||||
bitchin' 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) => (
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
export function test() {}
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue