Update ThemeView.tsx

update debrid
This commit is contained in:
Pas 2025-10-10 23:17:00 -06:00
parent c1bdcdf9df
commit 0ca23eb7c9
25 changed files with 1234 additions and 260 deletions

View file

@ -12,6 +12,7 @@ import {
getMediaDetails, getMediaDetails,
getMediaPoster, getMediaPoster,
getMovieFromExternalId, getMovieFromExternalId,
getNumberedIDs,
mediaTypeToTMDB, mediaTypeToTMDB,
} from "./tmdb"; } from "./tmdb";
import { import {
@ -194,11 +195,16 @@ export async function convertLegacyUrl(
if (isLegacyMediaType(url)) { if (isLegacyMediaType(url)) {
const details = await getMediaDetails(id, TMDBContentTypes.TV); const details = await getMediaDetails(id, TMDBContentTypes.TV);
return `/media/${TMDBIdToUrlId( const newPath = `/media/${TMDBIdToUrlId(
MWMediaType.SERIES, MWMediaType.SERIES,
details.id.toString(), details.id.toString(),
details.name, details.name,
)}${suffix}`; )}${suffix}`;
// Preserve query parameters
const searchParams = new URLSearchParams(window.location.search);
return searchParams.toString()
? `${newPath}?${searchParams.toString()}`
: newPath;
} }
const mediaType = TMDBMediaToMediaType(type as TMDBContentTypes); const mediaType = TMDBMediaToMediaType(type as TMDBContentTypes);
@ -212,11 +218,82 @@ export async function convertLegacyUrl(
if (imdbId && mediaType === MWMediaType.MOVIE) { if (imdbId && mediaType === MWMediaType.MOVIE) {
const movieId = await getMovieFromExternalId(imdbId); const movieId = await getMovieFromExternalId(imdbId);
if (movieId) { if (movieId) {
return `/media/${TMDBIdToUrlId(mediaType, movieId, meta.meta.title)}`; const newPath = `/media/${TMDBIdToUrlId(mediaType, movieId, meta.meta.title)}`;
// Preserve query parameters
const searchParams = new URLSearchParams(window.location.search);
return searchParams.toString()
? `${newPath}?${searchParams.toString()}`
: newPath;
} }
if (tmdbId) { if (tmdbId) {
return `/media/${TMDBIdToUrlId(mediaType, tmdbId, meta.meta.title)}`; const newPath = `/media/${TMDBIdToUrlId(mediaType, tmdbId, meta.meta.title)}`;
// Preserve query parameters
const searchParams = new URLSearchParams(window.location.search);
return searchParams.toString()
? `${newPath}?${searchParams.toString()}`
: newPath;
} }
} }
} }
export function isEmbedUrl(url: string): boolean {
if (url.startsWith("/embed/")) return true;
return false;
}
export function isTVEmbedMediaType(url: string): boolean {
if (url.startsWith("/embed/tmdb-tv")) return true;
return false;
}
export async function convertEmbedUrl(
url: string,
): Promise<string | undefined> {
const urlParts = url.split("/").slice(2);
const [, type, id] = urlParts[0].split("-", 3);
const suffix = urlParts
.slice(1)
.map((v) => `/${v}`)
.join("");
if (!id) return undefined;
if (type === "tv") {
const suffixParts = suffix.split("/").slice(1);
const suffixIDs = await getNumberedIDs(
id,
suffixParts[0],
suffixParts[1],
).then((v) => (v ? `${v.season}/${v.episode}` : ""));
const details = await getMediaDetails(id, TMDBContentTypes.TV);
const newPath = `/media/${TMDBIdToUrlId(
MWMediaType.SERIES,
details.id.toString(),
details.name,
)}/${suffixIDs}`;
// Preserve query parameters
const searchParams = new URLSearchParams(window.location.search);
return searchParams.toString()
? `${newPath}?${searchParams.toString()}`
: newPath;
}
if (type === "movie") {
const details = await getMediaDetails(id, TMDBContentTypes.MOVIE);
const newPath = `/media/${TMDBIdToUrlId(
MWMediaType.MOVIE,
details.id.toString(),
details.title,
)}`;
// Preserve query parameters
const searchParams = new URLSearchParams(window.location.search);
return searchParams.toString()
? `${newPath}?${searchParams.toString()}`
: newPath;
}
return undefined;
}

View file

@ -472,6 +472,38 @@ export function formatTMDBSearchResult(
}; };
} }
export async function getNumberedIDs(
id: string,
seasonNumber: string,
episodeNumber: string,
): Promise<{ season: string; episode: string } | undefined> {
const season = parseInt(seasonNumber, 10);
const episode = parseInt(episodeNumber, 10);
if (Number.isNaN(season) || Number.isNaN(episode)) {
return undefined;
}
const seasonData = await get<TMDBSeason>(`/tv/${id}/season/${season}`);
if (!seasonData || !seasonData.episodes) {
return undefined;
}
const targetEpisode = seasonData.episodes.find(
(e) => e.episode_number === episode,
);
if (!targetEpisode) {
// eslint-disable-next-line no-console
console.log("Episode not found");
return undefined;
}
return {
season: seasonData.id.toString(),
episode: targetEpisode.id.toString(),
};
}
/** /**
* Fetches the clear logo for a movie or show from TMDB images endpoint. * Fetches the clear logo for a movie or show from TMDB images endpoint.
*/ */

View file

@ -682,15 +682,29 @@ export function EpisodesView({
const playEpisode = useCallback( const playEpisode = useCallback(
(episodeId: string) => { (episodeId: string) => {
const oldMetaCopy = { ...meta };
if (loadingState.value) { if (loadingState.value) {
const newData = setPlayerMeta(loadingState.value.fullData, episodeId); const newData = setPlayerMeta(loadingState.value.fullData, episodeId);
window.parent.postMessage(
{
type: "episodeChanged",
episodeNumber: newData?.episode?.number,
seasonNumber: newData?.season?.number,
tmdbId: oldMetaCopy?.tmdbId,
imdbId: oldMetaCopy?.imdbId,
oldEpisodeNumber: oldMetaCopy?.episode?.number,
oldSeasonNumber: oldMetaCopy?.season?.number,
},
"*",
);
if (newData) onChange?.(newData); if (newData) onChange?.(newData);
} }
// prevent router clear here, otherwise its done double // prevent router clear here, otherwise its done double
// player already switches route after meta change // player already switches route after meta change
router.close(true); router.close(true);
}, },
[setPlayerMeta, loadingState, router, onChange], [setPlayerMeta, loadingState, router, onChange, meta],
); );
const toggleWatchStatus = useCallback( const toggleWatchStatus = useCallback(

View file

@ -169,6 +169,21 @@ export function NextEpisodeButton(props: {
setShouldStartFromBeginning(true); setShouldStartFromBeginning(true);
setDirectMeta(metaCopy); setDirectMeta(metaCopy);
props.onChange?.(metaCopy); props.onChange?.(metaCopy);
// Send message to parent window about episode change
window.parent.postMessage(
{
type: "episodeChanged",
episodeNumber: nextEp.number,
seasonNumber: metaCopy.season?.number,
oldEpisodeNumber: meta?.episode?.number,
oldSeasonNumber: meta?.season?.number,
title: nextEp.title,
tmdbId: nextEp.tmdbId,
},
"*",
);
const defaultProgress = { duration: 0, watched: 0 }; const defaultProgress = { duration: 0, watched: 0 };
updateItem({ updateItem({
meta: metaCopy, meta: metaCopy,
@ -193,6 +208,19 @@ export function NextEpisodeButton(props: {
setShouldStartFromBeginning(true); setShouldStartFromBeginning(true);
setDirectMeta(metaCopy); setDirectMeta(metaCopy);
props.onChange?.(metaCopy); props.onChange?.(metaCopy);
// Send message to parent window about episode restart
window.parent.postMessage(
{
type: "episodeRestarted",
episodeNumber: meta.episode.number,
seasonNumber: meta.season?.number,
title: meta.episode.title,
tmdbId: meta.episode.tmdbId,
},
"*",
);
const defaultProgress = { duration: 0, watched: 0 }; const defaultProgress = { duration: 0, watched: 0 };
updateItem({ updateItem({
meta: metaCopy, meta: metaCopy,

View file

@ -13,14 +13,19 @@ import { VideoPlayerButton } from "@/components/player/internals/Button";
import { Menu } from "@/components/player/internals/ContextMenu"; import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { AudioView } from "./settings/AudioView"; import { AudioView } from "./settings/AudioView";
import { CaptionSettingsView } from "./settings/CaptionSettingsView"; import { CaptionSettingsView } from "./settings/CaptionSettingsView";
import { CaptionsView } from "./settings/CaptionsView"; import { CaptionsView } from "./settings/CaptionsView";
import { DebridSetupView } from "./settings/DebridSetupView";
import { DownloadRoutes } from "./settings/Downloads"; import { DownloadRoutes } from "./settings/Downloads";
import { FedApiSetupView } from "./settings/FedApiSetupView";
import { LanguageView } from "./settings/LanguageView";
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
import { QualityView } from "./settings/QualityView"; import { QualityView } from "./settings/QualityView";
import { SettingsMenu } from "./settings/SettingsMenu"; import { SettingsMenu } from "./settings/SettingsMenu";
import { ThemeView } from "./settings/ThemeView";
import { TranscriptView } from "./settings/TranscriptView"; import { TranscriptView } from "./settings/TranscriptView";
import { WatchPartyView } from "./settings/WatchPartyView"; import { WatchPartyView } from "./settings/WatchPartyView";
@ -28,6 +33,11 @@ function SettingsOverlay({ id }: { id: string }) {
const [chosenSourceId, setChosenSourceId] = useState<string | null>(null); const [chosenSourceId, setChosenSourceId] = useState<string | null>(null);
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const debridToken = usePreferencesStore((s) => s.debridToken);
const setdebridToken = usePreferencesStore((s) => s.setdebridToken);
const debridService = usePreferencesStore((s) => s.debridService);
const setdebridService = usePreferencesStore((s) => s.setdebridService);
// reset source id when going to home or closing overlay // reset source id when going to home or closing overlay
useEffect(() => { useEffect(() => {
if (!router.isRouterActive) { if (!router.isRouterActive) {
@ -54,7 +64,7 @@ function SettingsOverlay({ id }: { id: string }) {
<AudioView id={id} /> <AudioView id={id} />
</Menu.Card> </Menu.Card>
</OverlayPage> </OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={452}> <OverlayPage id={id} path="/captions" width={343} height={320}>
<Menu.CardWithScrollable> <Menu.CardWithScrollable>
<CaptionsView id={id} backLink /> <CaptionsView id={id} backLink />
</Menu.CardWithScrollable> </Menu.CardWithScrollable>
@ -91,7 +101,33 @@ function SettingsOverlay({ id }: { id: string }) {
<EmbedSelectionView id={id} sourceId={chosenSourceId} /> <EmbedSelectionView id={id} sourceId={chosenSourceId} />
</Menu.CardWithScrollable> </Menu.CardWithScrollable>
</OverlayPage> </OverlayPage>
<OverlayPage id={id} path="/playback" width={343} height={330}> <OverlayPage
id={id}
path="/source/fed-api-setup"
width={343}
height={431}
>
<Menu.CardWithScrollable>
<FedApiSetupView id={id} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage
id={id}
path="/source/debrid-setup"
width={343}
height={431}
>
<Menu.CardWithScrollable>
<DebridSetupView
id={id}
debridToken={debridToken}
setdebridToken={setdebridToken}
debridService={debridService}
setdebridService={setdebridService}
/>
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/playback" width={343} height={215}>
<Menu.Card> <Menu.Card>
<PlaybackSettingsView id={id} /> <PlaybackSettingsView id={id} />
</Menu.Card> </Menu.Card>
@ -106,6 +142,16 @@ function SettingsOverlay({ id }: { id: string }) {
<TranscriptView id={id} /> <TranscriptView id={id} />
</Menu.CardWithScrollable> </Menu.CardWithScrollable>
</OverlayPage> </OverlayPage>
<OverlayPage id={id} path="/theme" width={343} height={431}>
<Menu.Card>
<ThemeView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/language" width={343} height={431}>
<Menu.Card>
<LanguageView id={id} />
</Menu.Card>
</OverlayPage>
<DownloadRoutes id={id} /> <DownloadRoutes id={id} />
<OverlayPage id={id} path="/watchparty" width={343} height={455}> <OverlayPage id={id} path="/watchparty" width={343} height={455}>
<Menu.CardWithScrollable> <Menu.CardWithScrollable>

View file

@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { VideoPlayerButton } from "@/components/player/internals/Button"; import { VideoPlayerButton } from "@/components/player/internals/Button";
@ -16,10 +17,72 @@ export function Time(props: { short?: boolean }) {
time, time,
draggingTime, draggingTime,
} = usePlayerStore((s) => s.progress); } = usePlayerStore((s) => s.progress);
const meta = usePlayerStore((s) => s.meta);
const { isSeeking } = usePlayerStore((s) => s.interface); const { isSeeking } = usePlayerStore((s) => s.interface);
const { t } = useTranslation(); const { t } = useTranslation();
const hasHours = durationExceedsHour(timeDuration); const hasHours = durationExceedsHour(timeDuration);
// Use refs to store the last update time and timeout ID
const lastUpdateRef = useRef<number>(0);
const timeoutRef = useRef<number | null>(null);
// Send current time via postMessage with throttling
useEffect(() => {
const currentTime = Math.min(
Math.max(isSeeking ? draggingTime : time, 0),
timeDuration,
);
const now = Date.now();
const timeSinceLastUpdate = now - lastUpdateRef.current;
const THROTTLE_MS = 1000; // 1 second
// Clear any existing timeout
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// If enough time has passed, send update immediately
if (timeSinceLastUpdate >= THROTTLE_MS) {
window.parent.postMessage(
{
type: "playerTimeUpdate",
time: currentTime,
duration: timeDuration,
tmdbId: meta?.tmdbId,
imdbId: meta?.imdbId,
},
"*",
);
lastUpdateRef.current = now;
} else {
// Otherwise, schedule an update
timeoutRef.current = window.setTimeout(() => {
window.parent.postMessage(
{
type: "playerTimeUpdate",
time: currentTime,
duration: timeDuration,
tmdbId: meta?.tmdbId,
imdbId: meta?.imdbId,
},
"*",
);
lastUpdateRef.current = Date.now();
timeoutRef.current = null;
}, THROTTLE_MS - timeSinceLastUpdate);
}
// Cleanup function to clear any pending timeout
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [time, draggingTime, isSeeking, timeDuration, meta]);
function toggleMode() { function toggleMode() {
setTimeFormat( setTimeFormat(
timeFormat === VideoPlayerTimeFormat.REGULAR timeFormat === VideoPlayerTimeFormat.REGULAR

View file

@ -0,0 +1,164 @@
import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { Dropdown } from "@/components/form/Dropdown";
import { Menu } from "@/components/player/internals/ContextMenu";
import {
StatusCircle,
StatusCircleProps,
} from "@/components/player/internals/StatusCircle";
import { MwLink } from "@/components/text/Link";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import {
Status,
testTorboxToken,
testdebridToken,
} from "@/pages/parts/settings/SetupPart";
async function getdebridTokenStatus(
debridToken: string | null,
debridService: string,
) {
if (debridToken) {
const status: Status =
debridService === "torbox"
? await testTorboxToken(debridToken)
: await testdebridToken(debridToken);
return status;
}
return "unset";
}
interface DebridSetupViewProps {
id: string;
debridToken: string | null;
setdebridToken: (value: string | null) => void;
debridService: string;
setdebridService: (value: string) => void;
}
export function DebridSetupView({
id,
debridToken,
setdebridToken,
debridService,
setdebridService,
}: DebridSetupViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const [status, setStatus] = useState<Status>("unset");
const statusMap: Record<Status, StatusCircleProps["type"]> = {
error: "error",
success: "success",
unset: "noresult",
api_down: "error",
invalid_token: "error",
};
useEffect(() => {
const checkTokenStatus = async () => {
const result = await getdebridTokenStatus(debridToken, debridService);
setStatus(result);
};
checkTokenStatus();
}, [debridToken, debridService]);
const handleReload = () => {
window.location.reload();
};
const alertRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if ((status === "success" || status === "error") && alertRef.current) {
alertRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [status]);
return (
<>
<Menu.BackLink onClick={() => router.navigate("/source")}>
Debrid Setup
</Menu.BackLink>
<Menu.Section className="pb-4">
<div className="my-3">
<p className="max-w-[30rem] font-medium">
<Trans i18nKey="debrid.description">
<MwLink to="https://real-debrid.com/" />
{/* fifth's referral code */}
<MwLink to="https://torbox.app/subscription?referral=3f665ece-0405-4012-9db7-c6f90e8567e1" />
</Trans>
</p>
<p className="text-type-danger mt-2 max-w-[30rem]">
{t("debrid.notice")}
</p>
</div>
<div className="mt-6">
<p className="text-white font-bold mb-3">{t("debrid.tokenLabel")}</p>
<div className="flex md:flex-row flex-col items-center w-full gap-4">
<div className="flex items-center w-full">
<StatusCircle type={statusMap[status]} className="mx-2 mr-4" />
<AuthInputBox
onChange={(newToken) => {
setdebridToken(newToken);
}}
value={debridToken ?? ""}
placeholder="ABC123..."
passwordToggleable
className="flex-grow"
/>
</div>
<div className="flex items-center">
<Dropdown
options={[
{
id: "realdebrid",
name: t("debrid.serviceOptions.realdebrid"),
},
{
id: "torbox",
name: t("debrid.serviceOptions.torbox"),
},
]}
selectedItem={{
id: debridService,
name: t(`debrid.serviceOptions.${debridService}`),
}}
setSelectedItem={(item) => setdebridService(item.id)}
direction="up"
/>
</div>
</div>
{status === "error" && (
<p ref={alertRef} className="text-type-danger mt-4">
{t("debrid.status.failure")}
</p>
)}
{status === "api_down" && (
<p ref={alertRef} className="text-type-danger mt-4">
{t("debrid.status.api_down")}
</p>
)}
{status === "invalid_token" && (
<p ref={alertRef} className="text-type-danger mt-4">
{t("debrid.status.invalid_token")}
</p>
)}
{status === "success" && (
<div ref={alertRef} className="mt-4">
<Button theme="purple" onClick={handleReload}>
Continue
</Button>
</div>
)}
</div>
</Menu.Section>
</>
);
}

View file

@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { Menu } from "@/components/player/internals/ContextMenu";
import {
StatusCircle,
StatusCircleProps,
} from "@/components/player/internals/StatusCircle";
import { MwLink } from "@/components/text/Link";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { Status, testFebboxKey } from "@/pages/parts/settings/SetupPart";
import { usePreferencesStore } from "@/stores/preferences";
async function getFebboxKeyStatus(febboxKey: string | null) {
if (febboxKey) {
const status: Status = await testFebboxKey(febboxKey);
return status;
}
return "unset";
}
export function FedApiSetupView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const febboxKey = usePreferencesStore((s) => s.febboxKey);
const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey);
const [showVideo, setShowVideo] = useState(false);
const [status, setStatus] = useState<Status>("unset");
const statusMap: Record<Status, StatusCircleProps["type"]> = {
error: "error",
success: "success",
unset: "noresult",
api_down: "error",
invalid_token: "error",
};
useEffect(() => {
const checkKeyStatus = async () => {
const result = await getFebboxKeyStatus(febboxKey);
setStatus(result);
};
checkKeyStatus();
}, [febboxKey]);
const handleReload = () => {
window.location.reload();
};
const alertRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if ((status === "success" || status === "error") && alertRef.current) {
alertRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [status]);
return (
<>
<Menu.BackLink onClick={() => router.navigate("/source")}>
Febbox API Setup
</Menu.BackLink>
<Menu.Section className="pb-4">
<div className="my-3">
<p className="max-w-[30rem] font-medium">
<Trans i18nKey="settings.connections.febbox.description">
To get your UI Key:
<br />
<div
onClick={() => setShowVideo(!showVideo)}
className="flex items-center justify-between p-1 px-2 my-2 w-fit border border-type-secondary rounded-lg cursor-pointer text-type-secondary hover:text-white transition-colors duration-200"
>
<span className="text-sm">
{showVideo ? "Hide Video Tutorial" : "Show Video Tutorial"}
</span>
{showVideo ? (
<Icon icon={Icons.CHEVRON_UP} className="pl-1" />
) : (
<Icon icon={Icons.CHEVRON_DOWN} className="pl-1" />
)}
</div>
{showVideo && (
<>
<div className="relative pt-[56.25%] mt-2">
<iframe
src="https://player.vimeo.com/video/1059834885?h=c3ab398d42&amp;badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479"
allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media"
className="absolute top-0 left-0 w-full h-full border border-type-secondary rounded-lg bg-black"
title="P-Stream FED API Setup Tutorial"
/>
</div>
<br />
</>
)}
1. Go to <MwLink url="https://febbox.com">febbox.com</MwLink> and
log in with Google (use a fresh account!)
<br />
2. Open DevTools or inspect the page
<br />
3. Go to Application tab Cookies
<br />
4. Copy the &quot;ui&quot; cookie.
<br />
5. Close the tab, but do NOT logout!
</Trans>
</p>
<p className="text-type-danger mt-2">(Do not share this Key!)</p>
</div>
<div className="mt-6">
<p className="text-white font-bold mb-3">
{t("settings.connections.febbox.KeyLabel", "Key")}
</p>
<div className="flex items-center w-full">
<StatusCircle type={statusMap[status]} className="mx-2 mr-4" />
<AuthInputBox
onChange={(newKey) => {
setFebboxKey(newKey);
}}
value={febboxKey ?? ""}
placeholder="eyABCdE..."
passwordToggleable
className="flex-grow"
/>
</div>
{status === "error" && (
<p ref={alertRef} className="text-type-danger mt-4">
Failed to fetch a &quot;VIP&quot; stream. Key is invalid or API is
down!
</p>
)}
{status === "success" && (
<div ref={alertRef} className="mt-4">
<Button theme="purple" onClick={handleReload}>
Continue
</Button>
</div>
)}
</div>
</Menu.Section>
</>
);
}

View file

@ -0,0 +1,79 @@
import Fuse from "fuse.js";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlagIcon } from "@/components/FlagIcon";
import { Menu } from "@/components/player/internals/ContextMenu";
import { Input } from "@/components/player/internals/ContextMenu/Input";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { appLanguageOptions } from "@/setup/i18n";
import { useLanguageStore } from "@/stores/language";
import { getLocaleInfo, sortLangCodes } from "@/utils/language";
import { SelectableLink } from "../../internals/ContextMenu/Links";
export function LanguageView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const { language, setLanguage } = useLanguageStore();
const [searchQuery, setSearchQuery] = useState("");
const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code));
const options = useMemo(() => {
const input = appLanguageOptions
.sort((a, b) => sorted.indexOf(a.code) - sorted.indexOf(b.code))
.map((opt) => ({
id: opt.code,
name: `${opt.name}${opt.nativeName ? `${opt.nativeName}` : ""}`,
leftIcon: <FlagIcon langCode={opt.code} />,
}));
if (searchQuery.trim().length > 0) {
const fuse = new Fuse(input, {
includeScore: true,
keys: ["name"],
});
return fuse.search(searchQuery).map((res) => res.item);
}
return input;
}, [sorted, searchQuery]);
const selected = options.find(
(item) => item.id === getLocaleInfo(language)?.code,
);
const handleLanguageChange = useCallback(
(newLanguage: string) => {
setLanguage(newLanguage);
},
[setLanguage],
);
return (
<>
<Menu.BackLink onClick={() => router.navigate("/")}>
{t("settings.preferences.language")}
</Menu.BackLink>
<div className="mt-3">
<Input value={searchQuery} onInput={setSearchQuery} />
</div>
<Menu.Section className="flex flex-col pb-4">
{options.map((option) => (
<SelectableLink
key={option.id}
selected={selected?.id === option.id}
onClick={() => handleLanguageChange(option.id)}
>
<span className="flex items-center">
<span data-code={option.id} className="mr-3 inline-flex">
{option.leftIcon}
</span>
<span>{option.name}</span>
</span>
</SelectableLink>
))}
</Menu.Section>
</>
);
}

View file

@ -7,9 +7,11 @@ import { Icon, Icons } from "@/components/Icon";
import { useCaptions } from "@/components/player/hooks/useCaptions"; import { useCaptions } from "@/components/player/hooks/useCaptions";
import { Menu } from "@/components/player/internals/ContextMenu"; import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { useLanguageStore } from "@/stores/language";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { qualityToString } from "@/stores/player/utils/qualities"; import { qualityToString } from "@/stores/player/utils/qualities";
import { useSubtitleStore } from "@/stores/subtitles"; import { useSubtitleStore } from "@/stores/subtitles";
import { useThemeStore } from "@/stores/theme";
import { getPrettyLanguageNameFromLocale } from "@/utils/language"; import { getPrettyLanguageNameFromLocale } from "@/utils/language";
export function SettingsMenu({ id }: { id: string }) { export function SettingsMenu({ id }: { id: string }) {
@ -38,6 +40,14 @@ export function SettingsMenu({ id }: { id: string }) {
return meta?.name; return meta?.name;
}, [currentEmbedId]); }, [currentEmbedId]);
const { toggleLastUsed } = useCaptions(); const { toggleLastUsed } = useCaptions();
const activeTheme = useThemeStore((s) => s.theme);
const themeName = useMemo(() => {
return t(`settings.appearance.themes.${activeTheme || "default"}`);
}, [t, activeTheme]);
const language = useLanguageStore((s) => s.language);
const languageName = useMemo(() => {
return getPrettyLanguageNameFromLocale(language) ?? language;
}, [language]);
const selectedLanguagePretty = selectedCaptionLanguage const selectedLanguagePretty = selectedCaptionLanguage
? (getPrettyLanguageNameFromLocale(selectedCaptionLanguage) ?? ? (getPrettyLanguageNameFromLocale(selectedCaptionLanguage) ??
@ -115,26 +125,32 @@ export function SettingsMenu({ id }: { id: string }) {
)} )}
</Menu.Section> </Menu.Section>
<Menu.Section> <Menu.Section>
<Menu.Link {new URLSearchParams(window.location.search).get("downloads") !==
clickable "false" && (
onClick={() => <Menu.Link
router.navigate(downloadable ? "/download" : "/download/unable") clickable
} onClick={() =>
rightSide={<Icon className="text-xl" icon={Icons.DOWNLOAD} />} router.navigate(downloadable ? "/download" : "/download/unable")
className={downloadable ? "opacity-100" : "opacity-50"} }
> rightSide={<Icon className="text-xl" icon={Icons.DOWNLOAD} />}
{t("player.menus.settings.downloadItem")} className={downloadable ? "opacity-100" : "opacity-50"}
</Menu.Link> >
<Menu.Link {t("player.menus.settings.downloadItem")}
clickable </Menu.Link>
onClick={() => )}
router.navigate(downloadable ? "/watchparty" : "/download/unable") {new URLSearchParams(window.location.search).get("has-watchparty") !==
} "false" && (
rightSide={<Icon className="text-xl" icon={Icons.WATCH_PARTY} />} <Menu.Link
className={downloadable ? "opacity-100" : "opacity-50"} clickable
> onClick={() =>
{t("player.menus.watchparty.watchpartyItem")} router.navigate(downloadable ? "/watchparty" : "/download/unable")
</Menu.Link> }
rightSide={<Icon className="text-xl" icon={Icons.WATCH_PARTY} />}
className={downloadable ? "opacity-100" : "opacity-50"}
>
{t("player.menus.watchparty.watchpartyItem")}
</Menu.Link>
)}
</Menu.Section> </Menu.Section>
<Menu.SectionTitle /> <Menu.SectionTitle />
<Menu.Section> <Menu.Section>
@ -151,6 +167,24 @@ export function SettingsMenu({ id }: { id: string }) {
<Menu.ChevronLink onClick={() => router.navigate("/playback")}> <Menu.ChevronLink onClick={() => router.navigate("/playback")}>
{t("player.menus.settings.playbackItem")} {t("player.menus.settings.playbackItem")}
</Menu.ChevronLink> </Menu.ChevronLink>
{new URLSearchParams(window.location.search).get(
"interface-settings",
) !== "false" && (
<>
<Menu.ChevronLink
onClick={() => router.navigate("/theme")}
rightText={themeName}
>
{t("settings.appearance.title")}
</Menu.ChevronLink>
<Menu.ChevronLink
onClick={() => router.navigate("/language")}
rightText={languageName}
>
{t("settings.preferences.language")}
</Menu.ChevronLink>
</>
)}
</Menu.Section> </Menu.Section>
</Menu.Card> </Menu.Card>
); );

View file

@ -10,6 +10,8 @@ import {
import { Menu } from "@/components/player/internals/ContextMenu"; import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { AnotherLink } from "@/pages/onboarding/utils";
import { conf } from "@/setup/config";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences"; import { usePreferencesStore } from "@/stores/preferences";
@ -144,12 +146,6 @@ export function SourceSelectionView({
const currentSourceId = usePlayerStore((s) => s.sourceId); const currentSourceId = usePlayerStore((s) => s.sourceId);
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder); const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder); const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
const lastSuccessfulSource = usePreferencesStore(
(s) => s.lastSuccessfulSource,
);
const enableLastSuccessfulSource = usePreferencesStore(
(s) => s.enableLastSuccessfulSource,
);
const disabledSources = usePreferencesStore((s) => s.disabledSources); const disabledSources = usePreferencesStore((s) => s.disabledSources);
const sources = useMemo(() => { const sources = useMemo(() => {
@ -160,34 +156,13 @@ export function SourceSelectionView({
.filter((v) => !disabledSources.includes(v.id)); .filter((v) => !disabledSources.includes(v.id));
if (!enableSourceOrder || preferredSourceOrder.length === 0) { if (!enableSourceOrder || preferredSourceOrder.length === 0) {
// Even without custom source order, prioritize last successful source if enabled
if (enableLastSuccessfulSource && lastSuccessfulSource) {
const lastSourceIndex = allSources.findIndex(
(s) => s.id === lastSuccessfulSource,
);
if (lastSourceIndex !== -1) {
const lastSource = allSources.splice(lastSourceIndex, 1)[0];
return [lastSource, ...allSources];
}
}
return allSources; return allSources;
} }
// Sort sources according to preferred order, but prioritize last successful source // Sort sources according to preferred order
const orderedSources = []; const orderedSources = [];
const remainingSources = [...allSources]; const remainingSources = [...allSources];
// First, add the last successful source if it exists, is available, and the feature is enabled
if (enableLastSuccessfulSource && lastSuccessfulSource) {
const lastSourceIndex = remainingSources.findIndex(
(s) => s.id === lastSuccessfulSource,
);
if (lastSourceIndex !== -1) {
orderedSources.push(remainingSources[lastSourceIndex]);
remainingSources.splice(lastSourceIndex, 1);
}
}
// Add sources in preferred order // Add sources in preferred order
for (const sourceId of preferredSourceOrder) { for (const sourceId of preferredSourceOrder) {
const sourceIndex = remainingSources.findIndex((s) => s.id === sourceId); const sourceIndex = remainingSources.findIndex((s) => s.id === sourceId);
@ -201,34 +176,36 @@ export function SourceSelectionView({
orderedSources.push(...remainingSources); orderedSources.push(...remainingSources);
return orderedSources; return orderedSources;
}, [ }, [metaType, preferredSourceOrder, enableSourceOrder, disabledSources]);
metaType,
preferredSourceOrder,
enableSourceOrder,
disabledSources,
lastSuccessfulSource,
enableLastSuccessfulSource,
]);
return ( return (
<> <>
<Menu.BackLink <Menu.BackLink onClick={() => router.navigate("/")}>
onClick={() => router.navigate("/")}
rightSide={
<button
type="button"
onClick={() => {
window.location.href = "/settings#source-order";
}}
className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10"
>
{t("player.menus.sources.editOrder")}
</button>
}
>
{t("player.menus.sources.title")} {t("player.menus.sources.title")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section className="pb-4"> <Menu.Section>
{new URLSearchParams(window.location.search).get("fedapi") !==
"false" &&
conf().ALLOW_FEBBOX_KEY && (
<AnotherLink
to="/fed-api-setup"
id="settings/source"
className="text-type-link flex w-full pb-2"
>
<span>Setup Febbox API Key</span>
</AnotherLink>
)}
{new URLSearchParams(window.location.search).get("debrid") !==
"false" &&
conf().ALLOW_DEBRID_KEY && (
<AnotherLink
to="/debrid-setup"
id="settings/source"
className="text-type-link flex w-full pb-2"
>
<span>Setup Torbox or RealDebrid</span>
</AnotherLink>
)}
{sources.map((v) => ( {sources.map((v) => (
<SelectableLink <SelectableLink
key={v.id} key={v.id}

View file

@ -0,0 +1,161 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePreviewThemeStore, useThemeStore } from "@/stores/theme";
import { SelectableLink } from "../../internals/ContextMenu/Links";
const availableThemes = [
{
id: "default",
selector: "theme-default",
key: "settings.appearance.themes.default",
},
{
id: "classic",
selector: "theme-classic",
key: "settings.appearance.themes.classic",
},
{
id: "blue",
selector: "theme-blue",
key: "settings.appearance.themes.blue",
},
{
id: "teal",
selector: "theme-teal",
key: "settings.appearance.themes.teal",
},
{
id: "red",
selector: "theme-red",
key: "settings.appearance.themes.red",
},
{
id: "gray",
selector: "theme-gray",
key: "settings.appearance.themes.gray",
},
{
id: "green",
selector: "theme-green",
key: "settings.appearance.themes.green",
},
{
id: "forest",
selector: "theme-forest",
key: "settings.appearance.themes.forest",
},
{
id: "autumn",
selector: "theme-autumn",
key: "settings.appearance.themes.autumn",
},
{
id: "frost",
selector: "theme-frost",
key: "settings.appearance.themes.frost",
},
{
id: "mocha",
selector: "theme-mocha",
key: "settings.appearance.themes.mocha",
},
{
id: "pink",
selector: "theme-pink",
key: "settings.appearance.themes.pink",
},
{
id: "noir",
selector: "theme-noir",
key: "settings.appearance.themes.noir",
},
{
id: "ember",
selector: "theme-ember",
key: "settings.appearance.themes.ember",
},
{
id: "acid",
selector: "theme-acid",
key: "settings.appearance.themes.acid",
},
{
id: "spark",
selector: "theme-spark",
key: "settings.appearance.themes.spark",
},
{
id: "grape",
selector: "theme-grape",
key: "settings.appearance.themes.grape",
},
{
id: "spiderman",
selector: "theme-spiderman",
key: "settings.appearance.themes.spiderman",
},
{
id: "wolverine",
selector: "theme-wolverine",
key: "settings.appearance.themes.wolverine",
},
{
id: "hulk",
selector: "theme-hulk",
key: "settings.appearance.themes.hulk",
},
{
id: "popsicle",
selector: "theme-popsicle",
key: "settings.appearance.themes.popsicle",
},
{
id: "christmas",
selector: "theme-christmas",
key: "settings.appearance.themes.christmas",
},
{
id: "skyRealm",
selector: "theme-skyrealm",
key: "settings.appearance.themes.skyrealm",
},
];
export function ThemeView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const activeTheme = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme);
const handleThemeChange = useCallback(
(themeId: string) => {
const newTheme = themeId === "default" ? null : themeId;
setTheme(newTheme);
setPreviewTheme(themeId);
router.close();
},
[setTheme, setPreviewTheme, router],
);
return (
<>
<Menu.BackLink onClick={() => router.navigate("/")}>Theme</Menu.BackLink>
<Menu.Section className="flex flex-col pb-4">
{availableThemes.map((theme) => (
<SelectableLink
key={theme.id}
selected={(activeTheme ?? "default") === theme.id}
onClick={() => handleThemeChange(theme.id)}
>
{t(theme.key)}
</SelectableLink>
))}
</Menu.Section>
</>
);
}

View file

@ -6,16 +6,36 @@ import { Icon, Icons } from "@/components/Icon";
export function BackLink(props: { url: string }) { export function BackLink(props: { url: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Check if URL is external (starts with http:// or https://)
const isExternal = /^https?:\/\//.test(props.url);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
window.parent.location.href = props.url;
};
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<Link {isExternal ? (
to={props.url} <a
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" href={props.url}
> onClick={handleClick}
<Icon className="mr-2" icon={Icons.ARROW_LEFT} /> 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"
<span className="md:hidden">{t("player.back.short")}</span> >
<span className="hidden md:block">{t("player.back.default")}</span> <Icon className="mr-2" icon={Icons.ARROW_LEFT} />
</Link> <span className="md:hidden">{t("player.back.short")}</span>
<span className="hidden md:block">{t("player.back.default")}</span>
</a>
) : (
<Link
to={props.url}
className="py-1 -my-1 px-2 -mx-2 tabbable rounded-lg flex items-center cursor-pointer text-type-secondary hover:text-white transition-colors duration-200 font-medium"
>
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
<span className="md:hidden">{t("player.back.short")}</span>
<span className="hidden md:block">{t("player.back.default")}</span>
</Link>
)}
</div> </div>
); );
} }

View file

@ -6,6 +6,7 @@ import { Caption } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences"; import { usePreferencesStore } from "@/stores/preferences";
import { useSubtitleStore } from "@/stores/subtitles"; import { useSubtitleStore } from "@/stores/subtitles";
import { getLanguageOrder } from "@/utils/language";
import { import {
filterDuplicateCaptionCues, filterDuplicateCaptionCues,
@ -134,7 +135,7 @@ export function useCaptions() {
}, [setCaption, setLanguage, setIsOpenSubtitles]); }, [setCaption, setLanguage, setIsOpenSubtitles]);
const selectLastUsedLanguage = useCallback(async () => { const selectLastUsedLanguage = useCallback(async () => {
const language = lastSelectedLanguage ?? "en"; const language = lastSelectedLanguage ?? getLanguageOrder()[0];
await selectLanguage(language); await selectLanguage(language);
return true; return true;
}, [lastSelectedLanguage, selectLanguage]); }, [lastSelectedLanguage, selectLanguage]);

View file

@ -4,7 +4,7 @@ import "./stores/__old/imports";
import "@/setup/ga"; import "@/setup/ga";
import "@/assets/css/index.css"; import "@/assets/css/index.css";
import { StrictMode, Suspense, useCallback } from "react"; import { StrictMode, Suspense, useCallback, useEffect } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async"; import { HelmetProvider } from "react-helmet-async";
@ -179,6 +179,19 @@ function ExtensionStatus() {
const container = document.getElementById("root"); const container = document.getElementById("root");
const root = createRoot(container!); const root = createRoot(container!);
function ZoomSetter() {
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
const scale = parseFloat(searchParams.get("scale") ?? "1");
if (!Number.isNaN(scale)) {
(document.body.style as any).zoom = `${scale}`;
}
}, []);
return null;
}
root.render( root.render(
<StrictMode> <StrictMode>
<ErrorBoundary> <ErrorBoundary>
@ -191,6 +204,7 @@ root.render(
<GroupSyncer /> <GroupSyncer />
<SettingsSyncer /> <SettingsSyncer />
<TheRouter> <TheRouter>
<ZoomSetter />
<MigrationRunner /> <MigrationRunner />
</TheRouter> </TheRouter>
</ThemeProvider> </ThemeProvider>

View file

@ -204,7 +204,7 @@ export function RealPlayerView() {
); );
return ( return (
<PlayerPart backUrl={backUrl} onMetaChange={metaChange}> <PlayerPart backUrl={backUrl}>
{status === playerStatus.IDLE ? ( {status === playerStatus.IDLE ? (
<MetaPart onGetMeta={handleMetaReceived} /> <MetaPart onGetMeta={handleMetaReceived} />
) : null} ) : null}

View file

@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { Heading2, Heading3, Paragraph } from "@/components/utils/Text"; import { Heading2, Heading3, Paragraph } from "@/components/utils/Text";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
export function Card(props: { export function Card(props: {
children?: React.ReactNode; children?: React.ReactNode;
@ -125,3 +126,35 @@ export function Link(props: {
</a> </a>
); );
} }
export function AnotherLink(props: {
children?: React.ReactNode;
to?: string;
href?: string;
className?: string;
target?: "_blank";
id: string;
}) {
const router = useOverlayRouter(props.id);
return (
<a
onClick={() => {
if (props.to) router.navigate(props.to);
}}
href={props.href}
target={props.target}
className={classNames(
"text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity",
props.className,
)}
rel="noreferrer"
>
{props.children}
<Icon
icon={Icons.ARROW_RIGHT}
className="group-hover:translate-x-0.5 transition-transform text-xl group-active:translate-x-0"
/>
</a>
);
}

View file

@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill"; import { IconPill } from "@/components/layout/IconPill";
import { Navigation } from "@/components/layout/Navigation";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { Paragraph } from "@/components/utils/Text"; import { Paragraph } from "@/components/utils/Text";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout"; import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
@ -17,22 +16,27 @@ export function NotFoundPart() {
<Helmet> <Helmet>
<title>{t("notFound.badge")}</title> <title>{t("notFound.badge")}</title>
</Helmet> </Helmet>
<Navigation />
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center"> <div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center">
<ErrorLayout> <ErrorLayout>
<ErrorContainer> <ErrorContainer>
<IconPill icon={Icons.EYE_SLASH}>{t("notFound.badge")}</IconPill> <IconPill icon={Icons.EYE_SLASH}>{t("notFound.badge")}</IconPill>
<Title>{t("notFound.title")}</Title> <Title>{t("notFound.title")}</Title>
<Paragraph>{t("notFound.message")}</Paragraph> <Paragraph>{t("notFound.message")}</Paragraph>
<div className="flex gap-3"> <Paragraph>
<Button This page isn&apos;t available on the embed! <br />
href="/" If you believe this is an error, please report it to the{" "}
theme="secondary" <a
padding="md:px-12 p-2.5" href="https://discord.gg/7z6znYgrTG"
className="mt-6" target="_blank"
rel="noreferrer"
className="text-type-link whitespace-nowrap"
> >
{t("notFound.goHome")} {" "}
</Button> P-Stream Discord
</a>{" "}
server.
</Paragraph>
<div className="flex gap-3">
<Button <Button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
theme="purple" theme="purple"

View file

@ -147,14 +147,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill> </IconPill>
<Title>{t("player.metadata.legal.title")}</Title> <Title>{t("player.metadata.legal.title")}</Title>
<Paragraph>{t("player.metadata.legal.text")}</Paragraph> <Paragraph>{t("player.metadata.legal.text")}</Paragraph>
<Button
href="/"
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.failed.homeButton")}
</Button>
</ErrorContainer> </ErrorContainer>
</ErrorLayout> </ErrorLayout>
); );
@ -169,14 +161,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill> </IconPill>
<Title>{t("player.metadata.api.text")}</Title> <Title>{t("player.metadata.api.text")}</Title>
<Paragraph>{t("player.metadata.api.title")}</Paragraph> <Paragraph>{t("player.metadata.api.title")}</Paragraph>
<Button
href="/"
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.failed.homeButton")}
</Button>
</ErrorContainer> </ErrorContainer>
</ErrorLayout> </ErrorLayout>
); );
@ -191,14 +175,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill> </IconPill>
<Title>{t("player.metadata.failed.title")}</Title> <Title>{t("player.metadata.failed.title")}</Title>
<Paragraph>{t("player.metadata.failed.text")}</Paragraph> <Paragraph>{t("player.metadata.failed.text")}</Paragraph>
<Button
href="/"
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.failed.homeButton")}
</Button>
</ErrorContainer> </ErrorContainer>
</ErrorLayout> </ErrorLayout>
); );
@ -213,14 +189,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill> </IconPill>
<Title>{t("player.metadata.notFound.title")}</Title> <Title>{t("player.metadata.notFound.title")}</Title>
<Paragraph>{t("player.metadata.notFound.text")}</Paragraph> <Paragraph>{t("player.metadata.notFound.text")}</Paragraph>
<Button
href="/"
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.notFound.homeButton")}
</Button>
</ErrorContainer> </ErrorContainer>
</ErrorLayout> </ErrorLayout>
); );

View file

@ -154,14 +154,32 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) {
</Button> </Button>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button {(() => {
href="/" const backlink = new URLSearchParams(window.location.search).get(
theme="secondary" "backlink",
padding="md:px-12 p-2.5" );
className="mt-6"
> // Only show backlink if it comes from URL parameter, and strip any quotes
{t("player.playbackError.homeButton")} if (backlink) {
</Button> // Remove any surrounding quotes from the URL
const cleanUrl = backlink.replace(/^["'](.*)["']$/, "$1");
return (
<Button
onClick={(e) => {
e.preventDefault();
window.parent.location.href = cleanUrl;
}}
theme="secondary"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.scraping.notFound.homeButton")}
</Button>
);
}
return null;
})()}
<Button <Button
theme="secondary" theme="secondary"
padding="md:px-12 p-2.5" padding="md:px-12 p-2.5"

View file

@ -5,6 +5,7 @@ import { Player } from "@/components/player";
import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton"; import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton";
import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay"; import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay";
import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus"; import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus";
import { Widescreen } from "@/components/player/atoms/Widescreen";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { useSkipTime } from "@/components/player/hooks/useSkipTime"; import { useSkipTime } from "@/components/player/hooks/useSkipTime";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
@ -34,6 +35,10 @@ export function PlayerPart(props: PlayerPartProps) {
const inControl = !enabled || isHost; const inControl = !enabled || isHost;
// backUrl prop is required for backwards compatibility with other components
// but we're only using backlink from URL parameters for rendering the back link
const _ = props.backUrl;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isPWA = window.matchMedia("(display-mode: standalone)").matches; const isPWA = window.matchMedia("(display-mode: standalone)").matches;
@ -84,11 +89,15 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.SubtitleView controlsShown={showTargets} /> <Player.SubtitleView controlsShown={showTargets} />
{status === playerStatus.PLAYING ? ( {status === playerStatus.PLAYING ? (
<Player.CenterControls> <>
<Player.LoadingSpinner /> <Player.CenterControls>
<Player.AutoPlayStart /> <Player.LoadingSpinner />
<Player.CastingNotification /> <Player.AutoPlayStart />
</Player.CenterControls> </Player.CenterControls>
<Player.CenterControls>
<Player.CastingNotification />
</Player.CenterControls>
</>
) : null} ) : null}
<Player.CenterMobileControls <Player.CenterMobileControls
@ -114,21 +123,45 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.TopControls show={showTargets}> <Player.TopControls show={showTargets}>
<div className="grid grid-cols-[1fr,auto] xl:grid-cols-3 items-center"> <div className="grid grid-cols-[1fr,auto] xl:grid-cols-3 items-center">
<div className="flex space-x-3 items-center"> <div className="flex space-x-3 items-center">
<Player.BackLink url={props.backUrl} /> {(() => {
<span className="text mx-3 text-type-secondary">/</span> const backlink = new URLSearchParams(window.location.search).get(
"backlink",
);
// Only show backlink if it comes from URL parameter, and strip any quotes
if (backlink) {
// Remove any surrounding quotes from the URL
const cleanUrl = backlink.replace(/^["'](.*)["']$/, "$1");
return (
<>
<Player.BackLink url={cleanUrl} />
<span className="text mx-3 text-type-secondary">/</span>
</>
);
}
return null;
})()}
<Player.Title /> <Player.Title />
<Player.InfoButton /> {new URLSearchParams(window.location.search).get("allinone") ===
"true" && <Player.InfoButton />}
<Player.BookmarkButton />
</div> </div>
<div className="text-center hidden xl:flex justify-center items-center"> <div className="text-center hidden xl:flex justify-center items-center">
<Player.EpisodeTitle /> <Player.EpisodeTitle />
</div> </div>
<div className="hidden lg:flex items-center justify-end"> {new URLSearchParams(window.location.search).get("logo") !==
<BrandPill /> "false" && (
</div> <a
<div className="flex lg:hidden items-center justify-end"> href="https://pstream.mov"
target="_blank"
rel="noreferrer"
className="hidden sm:flex items-center justify-end"
>
<BrandPill />
</a>
)}
<div className="flex sm:hidden items-center justify-end">
{status === playerStatus.PLAYING ? ( {status === playerStatus.PLAYING ? (
<> <>
<Player.Airplay /> <Player.Airplay />
@ -165,11 +198,16 @@ export function PlayerPart(props: PlayerPartProps) {
) : null} ) : null}
</Player.LeftSideControls> </Player.LeftSideControls>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Player.Episodes inControl={inControl} /> {new URLSearchParams(window.location.search).get("allinone") ===
<Player.SkipEpisodeButton "true" && (
inControl={inControl} <>
onChange={props.onMetaChange} <Player.Episodes inControl={inControl} />
/> <Player.SkipEpisodeButton
inControl={inControl}
onChange={props.onMetaChange}
/>
</>
)}
{status === playerStatus.PLAYING ? ( {status === playerStatus.PLAYING ? (
<> <>
<Player.Pip /> <Player.Pip />
@ -182,10 +220,14 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.Captions /> <Player.Captions />
) : null} ) : null}
<Player.Settings /> <Player.Settings />
{isShifting || isHoldingFullscreen ? ( {/* Fullscreen on when not shifting */}
<Player.Widescreen /> {!isShifting && <Player.Fullscreen />}
) : (
<Player.Fullscreen /> {/* Expand button visible when shifting */}
{isShifting && (
<div>
<Widescreen />
</div>
)} )}
</div> </div>
</div> </div>
@ -193,10 +235,13 @@ export function PlayerPart(props: PlayerPartProps) {
<div /> <div />
<div className="flex justify-center space-x-3"> <div className="flex justify-center space-x-3">
{/* Disable PiP for iOS PWA */} {/* Disable PiP for iOS PWA */}
{!(isPWA && isIOS) && status === playerStatus.PLAYING && ( {new URLSearchParams(window.location.search).get("allinone") ===
"true" && <Player.Episodes inControl={inControl} />}
{!isPWA && !isIOS && status === playerStatus.PLAYING && (
<Player.Pip /> <Player.Pip />
)} )}
<Player.Episodes inControl={inControl} /> {new URLSearchParams(window.location.search).get("allinone") ===
"true" && <Player.Episodes inControl={inControl} />}
{status === playerStatus.PLAYING ? ( {status === playerStatus.PLAYING ? (
<div className="hidden ssm:block"> <div className="hidden ssm:block">
<Player.Captions /> <Player.Captions />
@ -205,18 +250,16 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.Settings /> <Player.Settings />
</div> </div>
<div> <div>
{status === playerStatus.PLAYING && ( {isPWA && status === playerStatus.PLAYING ? (
<Widescreen />
) : (
<div <div
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
className="select-none touch-none" className="select-none touch-none"
style={{ WebkitTapHighlightColor: "transparent" }} style={{ WebkitTapHighlightColor: "transparent" }}
> >
{isHoldingFullscreen ? ( {isHoldingFullscreen ? <Widescreen /> : <Player.Fullscreen />}
<Player.Widescreen />
) : (
<Player.Fullscreen />
)}
</div> </div>
)} )}
</div> </div>
@ -228,11 +271,14 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.SpeedChangedPopout /> <Player.SpeedChangedPopout />
<UnreleasedEpisodeOverlay /> <UnreleasedEpisodeOverlay />
<Player.NextEpisodeButton {new URLSearchParams(window.location.search).get("allinone") ===
controlsShowing={showTargets} "true" && (
onChange={props.onMetaChange} <Player.NextEpisodeButton
inControl={inControl} controlsShowing={showTargets}
/> onChange={props.onMetaChange}
inControl={inControl}
/>
)}
<SkipIntroButton <SkipIntroButton
controlsShowing={showTargets} controlsShowing={showTargets}

View file

@ -121,14 +121,6 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) {
<Title>{t("player.scraping.notFound.title")}</Title> <Title>{t("player.scraping.notFound.title")}</Title>
<Paragraph>{t("player.scraping.notFound.text")}</Paragraph> <Paragraph>{t("player.scraping.notFound.text")}</Paragraph>
<div className="flex gap-3"> <div className="flex gap-3">
<Button
href="/"
theme="secondary"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.scraping.notFound.homeButton")}
</Button>
<Button <Button
onClick={() => modal.show()} onClick={() => modal.show()}
theme="purple" theme="purple"
@ -137,6 +129,32 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) {
> >
{t("player.scraping.notFound.detailsButton")} {t("player.scraping.notFound.detailsButton")}
</Button> </Button>
{(() => {
const backlink = new URLSearchParams(window.location.search).get(
"backlink",
);
// Only show backlink if it comes from URL parameter, and strip any quotes
if (backlink) {
// Remove any surrounding quotes from the URL
const cleanUrl = backlink.replace(/^["'](.*)["']$/, "$1");
return (
<Button
onClick={(e) => {
e.preventDefault();
window.parent.location.href = cleanUrl;
}}
theme="secondary"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.scraping.notFound.homeButton")}
</Button>
);
}
return null;
})()}
</div> </div>
{/* <Button {/* <Button
onClick={() => navigate("/discover")} onClick={() => navigate("/discover")}

View file

@ -167,14 +167,6 @@ export function ScrapingPartInterruptButton() {
return ( return (
<div className="flex gap-3 pb-3"> <div className="flex gap-3 pb-3">
<Button
href="/"
theme="secondary"
padding="md:px-17 p-3"
className="mt-6"
>
{t("notFound.goHome")}
</Button>
<Button <Button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
theme="purple" theme="purple"
@ -196,9 +188,11 @@ export function Tips() {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-type-secondary text-center text-sm text-bold"> {new URLSearchParams(window.location.search).get("tips") !== "false" && (
Tip: {tip} <p className="text-type-secondary text-center text-sm text-bold">
</p> Tip: {tip}
</p>
)}
</div> </div>
); );
} }

View file

@ -1,22 +1,16 @@
import { ReactElement, Suspense, lazy, useEffect, useState } from "react"; import { ReactElement, Suspense, lazy, useEffect, useState } from "react";
import { lazyWithPreload } from "react-lazy-with-preload"; import { lazyWithPreload } from "react-lazy-with-preload";
import { import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import {
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; convertEmbedUrl,
import { DetailsModal } from "@/components/overlays/detailsModal"; convertLegacyUrl,
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; isEmbedUrl,
isLegacyUrl,
} from "@/backend/metadata/getmeta";
import { NotificationModal } from "@/components/overlays/notificationsModal"; import { NotificationModal } from "@/components/overlays/notificationsModal";
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents"; import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
import { useOnlineListener } from "@/hooks/usePing"; import { useOnlineListener } from "@/hooks/usePing";
import { AboutPage } from "@/pages/About";
import { AdminPage } from "@/pages/admin/AdminPage"; import { AdminPage } from "@/pages/admin/AdminPage";
import { AllBookmarks } from "@/pages/bookmarks/AllBookmarks"; import { AllBookmarks } from "@/pages/bookmarks/AllBookmarks";
import VideoTesterView from "@/pages/developer/VideoTesterView"; import VideoTesterView from "@/pages/developer/VideoTesterView";
@ -25,10 +19,8 @@ import { Discover } from "@/pages/discover/Discover";
import { MoreContent } from "@/pages/discover/MoreContent"; import { MoreContent } from "@/pages/discover/MoreContent";
import MaintenancePage from "@/pages/errors/MaintenancePage"; import MaintenancePage from "@/pages/errors/MaintenancePage";
import { NotFoundPage } from "@/pages/errors/NotFoundPage"; import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { JipPage } from "@/pages/Jip"; import { JipPage } from "@/pages/Jip";
import { LegalPage, shouldHaveLegalPage } from "@/pages/Legal"; import { LegalPage, shouldHaveLegalPage } from "@/pages/Legal";
import { LoginPage } from "@/pages/Login";
import { MigrationPage } from "@/pages/migration/Migration"; import { MigrationPage } from "@/pages/migration/Migration";
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect"; import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
import { MigrationDownloadPage } from "@/pages/migration/MigrationDownload"; import { MigrationDownloadPage } from "@/pages/migration/MigrationDownload";
@ -36,12 +28,11 @@ import { MigrationUploadPage } from "@/pages/migration/MigrationUpload";
import { OnboardingPage } from "@/pages/onboarding/Onboarding"; import { OnboardingPage } from "@/pages/onboarding/Onboarding";
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension"; import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy"; import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
import { RegisterPage } from "@/pages/Register";
import { SupportPage } from "@/pages/Support"; import { SupportPage } from "@/pages/Support";
import { Layout } from "@/setup/Layout"; import { Layout } from "@/setup/Layout";
import { useHistoryListener } from "@/stores/history"; import { useHistoryListener } from "@/stores/history";
import { useClearModalsOnNavigation } from "@/stores/interface/overlayStack"; import { LanguageProvider, useLanguageStore } from "@/stores/language";
import { LanguageProvider } from "@/stores/language"; import { ThemeProvider, useThemeStore } from "@/stores/theme";
const DeveloperPage = lazy(() => import("@/pages/DeveloperPage")); const DeveloperPage = lazy(() => import("@/pages/DeveloperPage"));
const TestView = lazy(() => import("@/pages/developer/TestView")); const TestView = lazy(() => import("@/pages/developer/TestView"));
@ -54,58 +45,39 @@ SettingsPage.preload();
function LegacyUrlView({ children }: { children: ReactElement }) { function LegacyUrlView({ children }: { children: ReactElement }) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const setTheme = useThemeStore((s) => s.setTheme);
const setLanguage = useLanguageStore((s) => s.setLanguage);
useEffect(() => { useEffect(() => {
const url = location.pathname; if (!isLegacyUrl(location.pathname)) return;
if (!isLegacyUrl(url)) return;
convertLegacyUrl(location.pathname).then((convertedUrl) => { convertLegacyUrl(location.pathname).then((convertedUrl) => {
navigate(convertedUrl ?? "/", { replace: true }); navigate(convertedUrl ?? "/", { replace: true });
}); });
}, [location.pathname, navigate]); }, [location.pathname, navigate]);
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const theme = searchParams.get("theme");
const language = searchParams.get("language");
if (theme) {
setTheme(theme);
}
if (language) {
setLanguage(language);
}
}, [location.search, setTheme, setLanguage]);
if (isLegacyUrl(location.pathname)) return null; if (isLegacyUrl(location.pathname)) return null;
return children; return children;
} }
function QuickSearch() {
const { query } = useParams<{ query: string }>();
const navigate = useNavigate();
useEffect(() => {
if (query) {
generateQuickSearchMediaUrl(query).then((url) => {
navigate(url ?? "/", { replace: true });
});
} else {
navigate("/", { replace: true });
}
}, [query, navigate]);
return null;
}
function QueryView() {
const { query } = useParams<{ query: string }>();
const navigate = useNavigate();
useEffect(() => {
if (query) {
navigate(`/browse/${encodeURIComponent(query)}`, { replace: true });
} else {
navigate("/", { replace: true });
}
}, [query, navigate]);
return null;
}
export const maintenanceTime = "March 31th 11:00 PM - 5:00 AM EST"; export const maintenanceTime = "March 31th 11:00 PM - 5:00 AM EST";
function App() { function App() {
useHistoryListener(); useHistoryListener();
useOnlineListener(); useOnlineListener();
useGlobalKeyboardEvents(); useGlobalKeyboardEvents();
useClearModalsOnNavigation();
const maintenance = false; // Shows maintance page const maintenance = false; // Shows maintance page
const [showDowntime, setShowDowntime] = useState(maintenance); const [showDowntime, setShowDowntime] = useState(maintenance);
@ -121,21 +93,64 @@ function App() {
} }
}, [setShowDowntime, maintenance]); }, [setShowDowntime, maintenance]);
function EmbedRedirectView({ children }: { children: ReactElement }) {
const location = useLocation();
const navigate = useNavigate();
const setTheme = useThemeStore((s) => s.setTheme);
const setLanguage = useLanguageStore((s) => s.setLanguage);
useEffect(() => {
if (!isEmbedUrl(location.pathname)) return;
convertEmbedUrl(location.pathname).then((convertedUrl) => {
navigate(convertedUrl ?? "/", { replace: true });
});
}, [location.pathname, navigate]);
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const theme = searchParams.get("theme");
const language = searchParams.get("language");
if (theme) {
setTheme(theme);
}
if (language) {
setLanguage(language);
}
}, [location.search, setTheme, setLanguage]);
if (isEmbedUrl(location.pathname)) return null;
return children;
}
return ( return (
<Layout> <Layout>
<ThemeProvider />
<LanguageProvider /> <LanguageProvider />
<NotificationModal id="notifications" /> <NotificationModal id="notifications" />
<KeyboardCommandsModal id="keyboard-commands" />
<DetailsModal id="details" />
<DetailsModal id="discover-details" />
<DetailsModal id="player-details" />
{!showDowntime && ( {!showDowntime && (
<Routes> <Routes>
{/* functional routes */}
<Route path="/s/:query" element={<QuickSearch />} />
<Route path="/search/:type" element={<Navigate to="/browse" />} />
<Route path="/search/:type/:query?" element={<QueryView />} />
{/* pages */} {/* pages */}
<Route
path="/embed/:media"
element={
<EmbedRedirectView>
<Suspense fallback={null}>
<PlayerView />
</Suspense>
</EmbedRedirectView>
}
/>
<Route
path="/embed/:media/:seasonNumber/:episodeNumber"
element={
<EmbedRedirectView>
<Suspense fallback={null}>
<PlayerView />
</Suspense>
</EmbedRedirectView>
}
/>
<Route <Route
path="/media/:media" path="/media/:media"
element={ element={
@ -156,11 +171,11 @@ function App() {
</LegacyUrlView> </LegacyUrlView>
} }
/> />
<Route path="/browse/:query?" element={<HomePage />} /> <Route path="/browse/:query?" element={<NotFoundPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/" element={<NotFoundPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<NotFoundPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<NotFoundPage />} />
<Route path="/about" element={<AboutPage />} /> <Route path="/about" element={<NotFoundPage />} />
<Route path="/onboarding" element={<OnboardingPage />} /> <Route path="/onboarding" element={<OnboardingPage />} />
<Route <Route
path="/onboarding/extension" path="/onboarding/extension"

View file

@ -120,13 +120,33 @@ export function getPrettyLanguageNameFromLocale(locale: string): string | null {
return `${lang}${regionText}`; return `${lang}${regionText}`;
} }
/**
* Get language order from URL parameter if present, otherwise return default order
* @returns Array of language codes in order
*/
export function getLanguageOrder(): string[] {
if (typeof window === "undefined") return languageOrder;
try {
// Use URL constructor to properly parse the URL
const url = new URL(window.location.href);
const langOrderParam = url.searchParams.get("language-order");
if (!langOrderParam) return languageOrder;
return langOrderParam.split(",").map((lang) => lang.trim());
} catch (e) {
// If URL parsing fails for any reason, return default order
return languageOrder;
}
}
/** /**
* Sort locale codes by occurrence, rest on alphabetical order * Sort locale codes by occurrence, rest on alphabetical order
* @param langCodes list language codes to sort * @param langCodes list language codes to sort
* @returns sorted version of inputted list * @returns sorted version of inputted list
*/ */
export function sortLangCodes(langCodes: string[]) { export function sortLangCodes(langCodes: string[]) {
const languagesOrder = [...languageOrder].reverse(); // Reverse is necessary, not sure why const languagesOrder = [...getLanguageOrder()].reverse(); // Reverse is necessary, not sure why
const results = langCodes.sort((a, b) => { const results = langCodes.sort((a, b) => {
const langOrderA = languagesOrder.findIndex( const langOrderA = languagesOrder.findIndex(