mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 04:31:51 +01:00
translations 🎉
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
ad518a6508
commit
4f682d55a9
@ -73,8 +73,6 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"i": "^0.3.7",
|
||||
"npm": "^9.2.0",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
|
@ -20,7 +20,7 @@ registerProvider({
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
// // search for relevant item
|
||||
// search for relevant item
|
||||
const searchResponse = await proxiedFetch<any>(
|
||||
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
|
||||
{
|
||||
|
@ -67,12 +67,7 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
id: MWMediaType.SERIES,
|
||||
name: t("searchBar.series"),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
// {
|
||||
// id: MWMediaType.ANIME,
|
||||
// name: "Anime",
|
||||
// icon: Icons.DRAGON,
|
||||
// },
|
||||
}
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface EditButtonProps {
|
||||
@ -9,6 +10,7 @@ export interface EditButtonProps {
|
||||
}
|
||||
|
||||
export function EditButton(props: EditButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
@ -22,7 +24,7 @@ export function EditButton(props: EditButtonProps) {
|
||||
>
|
||||
<span ref={parent}>
|
||||
{props.editing ? (
|
||||
<span className="mx-4 whitespace-nowrap">Stop editing</span>
|
||||
<span className="mx-4 whitespace-nowrap">{t("media.stopEditing")}</span>
|
||||
) : (
|
||||
<Icon icon={Icons.EDIT} />
|
||||
)}
|
||||
|
@ -4,6 +4,7 @@ import { Icons } from "@/components/Icon";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorShowcaseProps {
|
||||
error: {
|
||||
@ -35,29 +36,24 @@ interface ErrorMessageProps {
|
||||
}
|
||||
|
||||
export function ErrorMessage(props: ErrorMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
props.localSize ? "h-full" : "min-h-screen"
|
||||
} flex w-full flex-col items-center justify-center px-4 py-12`}
|
||||
className={`${props.localSize ? "h-full" : "min-h-screen"
|
||||
} flex w-full flex-col items-center justify-center px-4 py-12`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
<Title>Whoops, it broke</Title>
|
||||
<Title>{t("media.errors.genericTitle")}</Title>
|
||||
{props.children ? (
|
||||
<p className="my-6 max-w-lg">{props.children}</p>
|
||||
) : (
|
||||
<p className="my-6 max-w-lg">
|
||||
The app encountered an error and wasn't able to recover, please
|
||||
report it to the{" "}
|
||||
<Link url={conf().DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={conf().GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
<Trans i18nKey="media.errors.videoFailed">
|
||||
<Link url={conf().DISCORD_LINK} newTab />
|
||||
<Link url={conf().GITHUB_LINK} newTab />
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,109 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Dropdown, OptionItem } from "@/components/Dropdown";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { WatchedEpisode } from "@/components/media/WatchedEpisodeButton";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
||||
|
||||
export interface SeasonsProps {
|
||||
media: any;
|
||||
}
|
||||
|
||||
export function LoadingSeasons(props: { error?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mb-3 mt-5 h-10 w-56 rounded bg-denim-400 opacity-50" />
|
||||
</div>
|
||||
{!props.error ? (
|
||||
<>
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
||||
<p>{t("seasons.failed")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Seasons(props: SeasonsProps) {
|
||||
// const { t } = useTranslation();
|
||||
// const [searchSeasons, loading, error, success] = useLoading(
|
||||
// (portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
|
||||
// );
|
||||
// const history = useHistory();
|
||||
// const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
|
||||
// const seasonSelected = props.media.seasonId as string;
|
||||
// const episodeSelected = props.media.episodeId as string;
|
||||
// useEffect(() => {
|
||||
// (async () => {
|
||||
// const seasonData = await searchSeasons(props.media);
|
||||
// setSeasons(seasonData);
|
||||
// })();
|
||||
// }, [searchSeasons, props.media]);
|
||||
// function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) {
|
||||
// const newMedia: MWMedia = { ...props.media };
|
||||
// newMedia.episodeId = episodeId;
|
||||
// newMedia.seasonId = seasonId;
|
||||
// history.replace(
|
||||
// `/media/${newMedia.mediaType}/${serializePortableMedia(
|
||||
// convertMediaToPortable(newMedia)
|
||||
// )}`
|
||||
// );
|
||||
// }
|
||||
// const mapSeason = (season: MWMediaSeason) => ({
|
||||
// id: season.id,
|
||||
// name: season.title || `${t("seasons.season", { season: season.sort })}`,
|
||||
// });
|
||||
// const options = seasons.seasons.map(mapSeason);
|
||||
// const foundSeason = seasons.seasons.find(
|
||||
// (season) => season.id === seasonSelected
|
||||
// );
|
||||
// const selectedItem = foundSeason ? mapSeason(foundSeason) : null;
|
||||
// return (
|
||||
// <>
|
||||
// {loading ? <LoadingSeasons /> : null}
|
||||
// {error ? <LoadingSeasons error /> : null}
|
||||
// {success && seasons.seasons.length ? (
|
||||
// <>
|
||||
// <Dropdown
|
||||
// selectedItem={selectedItem as OptionItem}
|
||||
// options={options}
|
||||
// setSelectedItem={(seasonItem) =>
|
||||
// navigateToSeasonAndEpisode(
|
||||
// seasonItem.id,
|
||||
// seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0]
|
||||
// .id as string
|
||||
// )
|
||||
// }
|
||||
// />
|
||||
// {seasons.seasons
|
||||
// .find((s) => s.id === seasonSelected)
|
||||
// ?.episodes.map((v) => (
|
||||
// <WatchedEpisode
|
||||
// key={v.id}
|
||||
// media={{
|
||||
// ...props.media,
|
||||
// seriesData: seasons,
|
||||
// episodeId: v.id,
|
||||
// seasonId: seasonSelected,
|
||||
// }}
|
||||
// active={v.id === episodeSelected}
|
||||
// onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)}
|
||||
// />
|
||||
// ))}
|
||||
// </>
|
||||
// ) : null}
|
||||
// </>
|
||||
// );
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DotList } from "@/components/text/DotList";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
||||
@ -27,20 +28,19 @@ function MediaCardContent({
|
||||
closable,
|
||||
onClose,
|
||||
}: MediaCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||
|
||||
const canLink = linkable && !closable;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<article
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||
canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg"
|
||||
@ -51,7 +51,10 @@ function MediaCardContent({
|
||||
{series ? (
|
||||
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500">
|
||||
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
|
||||
S{series.season} E{series.episode}
|
||||
{t("seasons.seasonAndEpisode", {
|
||||
season: series.season,
|
||||
episode: series.episode
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@ -59,14 +62,12 @@ function MediaCardContent({
|
||||
{percentage !== undefined ? (
|
||||
<>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||
@ -82,9 +83,8 @@ function MediaCardContent({
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<IconPatch
|
||||
clickable
|
||||
@ -100,7 +100,7 @@ function MediaCardContent({
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[
|
||||
media.type.slice(0, 1).toUpperCase() + media.type.slice(1),
|
||||
t(`media.${media.type}`),
|
||||
media.year,
|
||||
]}
|
||||
/>
|
||||
|
@ -21,12 +21,9 @@ initializeChromecast();
|
||||
|
||||
// TODO video todos:
|
||||
// - chrome cast support
|
||||
// - bug: unmounting player throws errors in console
|
||||
// - bug: safari fullscreen will make video overlap player controls
|
||||
// - improvement: make scrapers use fuzzy matching on normalized titles
|
||||
// - bug: source selection doesnt work with HLS
|
||||
// - bug: .ass subtitle files are fucked
|
||||
// - improvement: episode watch at the ending should not startAt
|
||||
|
||||
// TODO stuff to test:
|
||||
// - browser: firefox, chrome, edge, safari desktop
|
||||
|
@ -44,3 +44,8 @@ body[data-no-select] {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
google-cast-launcher {
|
||||
@apply pointer-events-auto m-2 text-white flex items-center justify-center p-2;
|
||||
@apply transition-[background-color,transform] duration-100 rounded-full bg-denim-600 bg-opacity-0 hover:bg-opacity-50 active:bg-denim-500 active:bg-opacity-100 active:scale-110;
|
||||
}
|
||||
|
@ -3,26 +3,34 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading": "Fetching your favourite shows...",
|
||||
"loading_series": "Fetching your favourite series...",
|
||||
"loading_movie": "Fetching your favourite movies...",
|
||||
"loading": "Loading...",
|
||||
"allResults": "That's all we have!",
|
||||
"noResults": "We couldn't find anything!",
|
||||
"allFailed": "Failed to find media, try again!",
|
||||
"headingTitle": "Search results",
|
||||
"headingLink": "Back to home",
|
||||
"bookmarks": "Bookmarks",
|
||||
"continueWatching": "Continue Watching",
|
||||
"title": "What do you want to watch?",
|
||||
"placeholder": "What do you want to watch?"
|
||||
},
|
||||
"media": {
|
||||
"invalidUrl": "Your URL may be invalid",
|
||||
"arrowText": "Go back"
|
||||
"movie": "Movie",
|
||||
"series": "Series",
|
||||
"stopEditing": "Stop editing",
|
||||
"errors": {
|
||||
"genericTitle": "Whoops, it broke!",
|
||||
"failedMeta": "Failed to load meta",
|
||||
"mediaFailed": "We failed to request the media you asked for, check your internet connection and try again.",
|
||||
"videoFailed": "We encountered an error while playing the video you requested. If this keeps happening please report the issue to the <0>Discord server</0> or on <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"season": "Season {{season}}",
|
||||
"failed": "Failed to get season data"
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Not found",
|
||||
"backArrow": "Back to home",
|
||||
"media": {
|
||||
"title": "Couldn't find that media",
|
||||
@ -42,7 +50,31 @@
|
||||
"series": "Series",
|
||||
"Search": "Search"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"text": "The app encountered an error and wasn't able to recover, please report it to the"
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Finding the best video for you",
|
||||
"noVideos": "Whoops, couldn't find any videos for you",
|
||||
"loading": "Loading...",
|
||||
"backToHome": "Back to home",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"buttons": {
|
||||
"episodes": "Episodes",
|
||||
"source": "Source",
|
||||
"captions": "Captions"
|
||||
},
|
||||
"popouts": {
|
||||
"sources": "Sources",
|
||||
"seasons": "Seasons",
|
||||
"captions": "Captions",
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "No captions",
|
||||
"linkedCaptions": "Linked captions",
|
||||
"errors": {
|
||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||
"embedsError": "Something went wrong loading the embeds for this thing that you like"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,12 +4,14 @@ import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconB
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CaptionsSelectionAction(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
@ -20,7 +22,7 @@ export function CaptionsSelectionAction(props: Props) {
|
||||
<PopoutAnchor for="captions">
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
text={isMobile ? "Captions" : ""}
|
||||
text={isMobile ? t("videoPlayer.buttons.captions") as string : ""}
|
||||
wide={isMobile}
|
||||
onClick={() => controls.openPopout("captions")}
|
||||
icon={Icons.CAPTIONS}
|
||||
|
@ -3,7 +3,6 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useMisc } from "@/video/state/logic/misc";
|
||||
|
||||
// TODO pausing before first frame will infinitely show spinner until unpaused
|
||||
export function LoadingAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
@ -6,12 +6,14 @@ import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconB
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SeriesSelectionAction(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
const videoInterface = useInterface(descriptor);
|
||||
@ -26,7 +28,7 @@ export function SeriesSelectionAction(props: Props) {
|
||||
<VideoPlayerIconButton
|
||||
active={videoInterface.popout === "episodes"}
|
||||
icon={Icons.EPISODES}
|
||||
text="Episodes"
|
||||
text={t("videoPlayer.buttons.episodes") as string}
|
||||
wide
|
||||
onClick={() => controls.openPopout("episodes")}
|
||||
/>
|
||||
|
@ -4,12 +4,14 @@ import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconB
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourceSelectionAction(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const controls = useControls(descriptor);
|
||||
@ -20,8 +22,9 @@ export function SourceSelectionAction(props: Props) {
|
||||
<PopoutAnchor for="source">
|
||||
<VideoPlayerIconButton
|
||||
active={videoInterface.popout === "source"}
|
||||
icon={Icons.FILE}
|
||||
text="Source"
|
||||
icon={Icons.CLAPPER_BOARD}
|
||||
iconSize="text-xl"
|
||||
text={t("videoPlayer.buttons.source") as string}
|
||||
wide
|
||||
onClick={() => controls.openPopout("source")}
|
||||
/>
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useCurrentSeriesEpisodeInfo(descriptor: string) {
|
||||
const meta = useMeta(descriptor);
|
||||
const {t} = useTranslation()
|
||||
|
||||
const currentSeasonInfo = useMemo(() => {
|
||||
return meta?.seasons?.find(
|
||||
@ -22,8 +24,11 @@ export function useCurrentSeriesEpisodeInfo(descriptor: string) {
|
||||
);
|
||||
|
||||
if (!isSeries) return { isSeries: false };
|
||||
|
||||
const humanizedEpisodeId = `S${currentSeasonInfo?.number} E${currentEpisodeInfo?.number}`;
|
||||
|
||||
const humanizedEpisodeId = t("videoPlayer.seasonAndEpisode", {
|
||||
season: currentSeasonInfo?.number,
|
||||
episode: currentEpisodeInfo?.number
|
||||
});
|
||||
|
||||
return {
|
||||
isSeries: true,
|
||||
|
@ -3,6 +3,7 @@ import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Component, ReactNode } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
@ -67,15 +68,10 @@ export class VideoErrorBoundary extends Component<
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage error={this.state.error} localSize>
|
||||
The video player encounted a fatal error, please report it to the{" "}
|
||||
<Link url={conf().DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={conf().GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
<Trans i18nKey="videoPlayer.errors.fatalError">
|
||||
<Link url={conf().DISCORD_LINK} newTab />
|
||||
<Link url={conf().GITHUB_LINK} newTab />
|
||||
</Trans>
|
||||
</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
} from "@/state/bookmark";
|
||||
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
||||
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface VideoPlayerHeaderProps {
|
||||
media?: MWMediaMeta;
|
||||
@ -21,6 +22,8 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
|
||||
: false;
|
||||
const showDivider = props.media && props.onClick;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-1 items-center">
|
||||
@ -31,7 +34,7 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
|
||||
<span>Back to home</span>
|
||||
<span>{t("videoPlayer.backToHome")}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{showDivider ? (
|
||||
|
@ -7,6 +7,7 @@ import { useControls } from "@/video/state/logic/controls";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useSource } from "@/video/state/logic/source";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
@ -14,6 +15,8 @@ function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
}
|
||||
|
||||
export function CaptionSelectionPopout() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
const source = useSource(descriptor);
|
||||
@ -38,7 +41,7 @@ export function CaptionSelectionPopout() {
|
||||
return (
|
||||
<>
|
||||
<PopoutSection className="bg-ash-100 font-bold text-white">
|
||||
<div>Captions</div>
|
||||
<div>{t("videoPlayer.popouts.captions")}</div>
|
||||
</PopoutSection>
|
||||
<div className="relative overflow-y-auto">
|
||||
<PopoutSection>
|
||||
@ -49,13 +52,13 @@ export function CaptionSelectionPopout() {
|
||||
controls.closePopout();
|
||||
}}
|
||||
>
|
||||
No captions
|
||||
{t("videoPlayer.popouts.noCaptions")}
|
||||
</PopoutListEntry>
|
||||
</PopoutSection>
|
||||
|
||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase">
|
||||
<Icon className="text-base" icon={Icons.LINK} />
|
||||
<span>Linked captions</span>
|
||||
<span>{t("videoPlayer.popouts.linkedCaptions")}</span>
|
||||
</p>
|
||||
|
||||
<PopoutSection className="pt-0">
|
||||
|
@ -12,11 +12,14 @@ import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function EpisodeSelectionPopout() {
|
||||
const params = useParams<{
|
||||
media: string;
|
||||
}>();
|
||||
const { t } = useTranslation()
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
const controls = useControls(descriptor);
|
||||
@ -119,7 +122,7 @@ export function EpisodeSelectionPopout() {
|
||||
isPickingSeason ? "opacity-1" : "opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
Seasons
|
||||
{t("videoPlayer.popouts.seasons")}
|
||||
</span>
|
||||
</div>
|
||||
</PopoutSection>
|
||||
@ -134,15 +137,15 @@ export function EpisodeSelectionPopout() {
|
||||
>
|
||||
{currentSeasonInfo
|
||||
? meta?.seasons?.map?.((season) => (
|
||||
<PopoutListEntry
|
||||
key={season.id}
|
||||
active={meta?.episode?.seasonId === season.id}
|
||||
onClick={() => setSeason(season.id)}
|
||||
isOnDarkBackground
|
||||
>
|
||||
{season.title}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
<PopoutListEntry
|
||||
key={season.id}
|
||||
active={meta?.episode?.seasonId === season.id}
|
||||
onClick={() => setSeason(season.id)}
|
||||
isOnDarkBackground
|
||||
>
|
||||
{season.title}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
: "No season"}
|
||||
</PopoutSection>
|
||||
<PopoutSection className="relative h-full overflow-y-auto">
|
||||
@ -158,8 +161,9 @@ export function EpisodeSelectionPopout() {
|
||||
className="text-xl text-bink-600"
|
||||
/>
|
||||
<p className="mt-6 w-full text-center">
|
||||
Something went wrong loading the episodes for{" "}
|
||||
{currentSeasonInfo?.title?.toLowerCase()}
|
||||
{t("videoPLayer.popouts.errors.loadingWentWrong", {
|
||||
seasonTitle: currentSeasonInfo?.title?.toLowerCase()
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -167,26 +171,29 @@ export function EpisodeSelectionPopout() {
|
||||
<div>
|
||||
{currentSeasonEpisodes && currentSeasonInfo
|
||||
? currentSeasonEpisodes.map((e) => (
|
||||
<PopoutListEntry
|
||||
key={e.id}
|
||||
active={e.id === meta?.episode?.episodeId}
|
||||
onClick={() => {
|
||||
if (e.id === meta?.episode?.episodeId)
|
||||
controls.closePopout();
|
||||
else setCurrent(currentSeasonInfo.id, e.id);
|
||||
}}
|
||||
percentageCompleted={
|
||||
watched.items.find(
|
||||
(item) =>
|
||||
item.item?.series?.seasonId ===
|
||||
currentSeasonInfo.id &&
|
||||
item.item?.series?.episodeId === e.id
|
||||
)?.percentage
|
||||
}
|
||||
>
|
||||
E{e.number} - {e.title}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
<PopoutListEntry
|
||||
key={e.id}
|
||||
active={e.id === meta?.episode?.episodeId}
|
||||
onClick={() => {
|
||||
if (e.id === meta?.episode?.episodeId)
|
||||
controls.closePopout();
|
||||
else setCurrent(currentSeasonInfo.id, e.id);
|
||||
}}
|
||||
percentageCompleted={
|
||||
watched.items.find(
|
||||
(item) =>
|
||||
item.item?.series?.seasonId ===
|
||||
currentSeasonInfo.id &&
|
||||
item.item?.series?.episodeId === e.id
|
||||
)?.percentage
|
||||
}
|
||||
>
|
||||
{t("videoPlayer.popouts.episode", {
|
||||
index: e.number,
|
||||
title: e.title
|
||||
})}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
: "No episodes"}
|
||||
</div>
|
||||
)}
|
||||
|
@ -11,9 +11,11 @@ import { getProviders } from "@/backend/helpers/register";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// TODO HLS does not work
|
||||
export function SourceSelectionPopout() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const meta = useMeta(descriptor);
|
||||
@ -42,7 +44,7 @@ export function SourceSelectionPopout() {
|
||||
tmdbId: "",
|
||||
meta: meta.meta,
|
||||
},
|
||||
progress: () => {},
|
||||
progress: () => { },
|
||||
type: meta.meta.type,
|
||||
episode: meta.episode?.episodeId as any,
|
||||
season: meta.episode?.seasonId as any,
|
||||
@ -129,7 +131,7 @@ export function SourceSelectionPopout() {
|
||||
!showingProvider ? "opacity-1" : "opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
Sources
|
||||
{t("videoPlayer.popouts.sources")}
|
||||
</span>
|
||||
</div>
|
||||
</PopoutSection>
|
||||
@ -154,8 +156,7 @@ export function SourceSelectionPopout() {
|
||||
className="text-xl text-bink-600"
|
||||
/>
|
||||
<p className="mt-6 w-full text-center">
|
||||
Something went wrong loading the embeds for this thing that
|
||||
you like
|
||||
{t("videoPlayer.popouts.errors.embedsError")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -249,6 +249,7 @@ export function createVideoStateProvider(
|
||||
};
|
||||
const canplay = () => {
|
||||
state.mediaPlaying.isFirstLoading = false;
|
||||
state.mediaPlaying.isLoading = false;
|
||||
updateMediaPlaying(descriptor, state);
|
||||
};
|
||||
const fullscreenchange = () => {
|
||||
|
@ -5,48 +5,23 @@ import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { conf } from "@/setup/config";
|
||||
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export function MediaFetchErrorView() {
|
||||
const { t } = useTranslation()
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<Helmet>
|
||||
<title>Failed to load meta</title>
|
||||
<title>{t("media.errors.failedMeta")}</title>
|
||||
</Helmet>
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={goBack} />
|
||||
</div>
|
||||
<ErrorMessage>
|
||||
<p className="my-6 max-w-lg">
|
||||
We failed to request the media you asked for, check your internet
|
||||
connection and try again.
|
||||
</p>
|
||||
</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaPlaybackErrorView(props: { media?: MWMediaMeta }) {
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={goBack} media={props.media} />
|
||||
</div>
|
||||
<ErrorMessage>
|
||||
<p className="my-6 max-w-lg">
|
||||
We encountered an error while playing the video you requested. If this
|
||||
keeps happening please report the issue to the
|
||||
<Link url={conf().DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={conf().GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
{t("media.errors.mediaFailed")}
|
||||
</p>
|
||||
</ErrorMessage>
|
||||
</div>
|
||||
|
@ -22,19 +22,22 @@ import { useWatchedItem } from "@/state/watched";
|
||||
import { MediaFetchErrorView } from "./MediaErrorView";
|
||||
import { MediaScrapeLog } from "./MediaScrapeLog";
|
||||
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function MediaViewLoading(props: { onGoBack(): void }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen items-center justify-center">
|
||||
<Helmet>
|
||||
<title>Loading...</title>
|
||||
<title>{t("videoPlayer.loading")}</title>
|
||||
</Helmet>
|
||||
<div className="absolute inset-x-0 top-0 p-6">
|
||||
<VideoPlayerHeader onClick={props.onGoBack} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<Loading className="mb-4" />
|
||||
<p className="mb-8 text-denim-700">Finding the best video for you</p>
|
||||
<p className="mb-8 text-denim-700">{t("videoPlaye.findingBestVideo")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -48,6 +51,7 @@ interface MediaViewScrapingProps {
|
||||
}
|
||||
function MediaViewScraping(props: MediaViewScrapingProps) {
|
||||
const { eventLog, stream, pending } = useScrape(props.meta, props.selected);
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
@ -68,21 +72,20 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
||||
<>
|
||||
<Loading />
|
||||
<p className="mb-8 text-denim-700">
|
||||
Finding the best video for you
|
||||
{t("videoPlayer.findingBestVideo")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconPatch icon={Icons.EYE_SLASH} className="mb-8 text-bink-700" />
|
||||
<p className="mb-8 text-denim-700">
|
||||
Whoops, could't find any videos for you
|
||||
{t("videoPlayer.noVideos")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`flex flex-col items-center transition-opacity duration-200 ${
|
||||
pending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
className={`flex flex-col items-center transition-opacity duration-200 ${pending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<MediaScrapeLog events={eventLog} />
|
||||
</div>
|
||||
|
@ -13,12 +13,13 @@ export function NotFoundWrapper(props: {
|
||||
children?: ReactNode;
|
||||
video?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<Helmet>
|
||||
<title>Not found</title>
|
||||
<title>{t("notFound.genericTitle")}</title>
|
||||
</Helmet>
|
||||
{props.video ? (
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
|
@ -1,12 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { MWQuery } from "@/backend/metadata/types";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
|
||||
export function SearchLoadingView() {
|
||||
const { t } = useTranslation();
|
||||
const [query] = useSearchQuery()
|
||||
return (
|
||||
<Loading
|
||||
className="mt-40 mb-24 "
|
||||
text={t("search.loading") || "Fetching your favourite shows..."}
|
||||
/>
|
||||
<>
|
||||
<Loading
|
||||
className="mt-40 mb-24 "
|
||||
text={t(`search.loading_${query.type}`) || t("search.loading") || "Fetching your favourite shows..."}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ export function SearchView() {
|
||||
<>
|
||||
<div className="relative z-10 mb-24">
|
||||
<Helmet>
|
||||
<title>movie-web</title>
|
||||
<title>{t("global.name")}</title>
|
||||
</Helmet>
|
||||
<Navigation bg={showBg} />
|
||||
<ThinContainer>
|
||||
|
Loading…
Reference in New Issue
Block a user