This commit is contained in:
Pas 2025-10-10 23:17:00 -06:00
parent 0aac255d38
commit c7ff62da6f
25 changed files with 1137 additions and 196 deletions

View file

@ -12,6 +12,7 @@ import {
getMediaDetails,
getMediaPoster,
getMovieFromExternalId,
getNumberedIDs,
mediaTypeToTMDB,
} from "./tmdb";
import {
@ -194,11 +195,16 @@ export async function convertLegacyUrl(
if (isLegacyMediaType(url)) {
const details = await getMediaDetails(id, TMDBContentTypes.TV);
return `/media/${TMDBIdToUrlId(
const newPath = `/media/${TMDBIdToUrlId(
MWMediaType.SERIES,
details.id.toString(),
details.name,
)}${suffix}`;
// Preserve query parameters
const searchParams = new URLSearchParams(window.location.search);
return searchParams.toString()
? `${newPath}?${searchParams.toString()}`
: newPath;
}
const mediaType = TMDBMediaToMediaType(type as TMDBContentTypes);
@ -212,11 +218,82 @@ export async function convertLegacyUrl(
if (imdbId && mediaType === MWMediaType.MOVIE) {
const movieId = await getMovieFromExternalId(imdbId);
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) {
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

@ -427,6 +427,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.
*/

View file

@ -681,15 +681,29 @@ export function EpisodesView({
const playEpisode = useCallback(
(episodeId: string) => {
const oldMetaCopy = { ...meta };
if (loadingState.value) {
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);
}
// prevent router clear here, otherwise its done double
// player already switches route after meta change
router.close(true);
},
[setPlayerMeta, loadingState, router, onChange],
[setPlayerMeta, loadingState, router, onChange, meta],
);
const toggleWatchStatus = useCallback(

View file

@ -159,6 +159,21 @@ export function NextEpisodeButton(props: {
setShouldStartFromBeginning(true);
setDirectMeta(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 };
updateItem({
meta: metaCopy,
@ -181,6 +196,19 @@ export function NextEpisodeButton(props: {
setShouldStartFromBeginning(true);
setDirectMeta(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 };
updateItem({
meta: metaCopy,

View file

@ -18,11 +18,15 @@ import { AudioView } from "./settings/AudioView";
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
import { CaptionsView } from "./settings/CaptionsView";
import { DownloadRoutes } from "./settings/Downloads";
import { FedApiSetupView } from "./settings/FedApiSetupView";
import { LanguageView } from "./settings/LanguageView";
import { OpenSubtitlesCaptionView } from "./settings/OpensubtitlesCaptionsView";
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
import { QualityView } from "./settings/QualityView";
import { RealDebridSetupView } from "./settings/RealDebridSetupView";
import { SettingsMenu } from "./settings/SettingsMenu";
import SourceCaptionsView from "./settings/SourceCaptionsView";
import { ThemeView } from "./settings/ThemeView";
import { WatchPartyView } from "./settings/WatchPartyView";
function SettingsOverlay({ id }: { id: string }) {
@ -129,11 +133,41 @@ function SettingsOverlay({ id }: { id: string }) {
<EmbedSelectionView id={id} sourceId={chosenSourceId} />
</Menu.CardWithScrollable>
</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/realdebrid-setup"
width={343}
height={431}
>
<Menu.CardWithScrollable>
<RealDebridSetupView id={id} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/playback" width={343} height={215}>
<Menu.Card>
<PlaybackSettingsView id={id} />
</Menu.Card>
</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} />
<OverlayPage id={id} path="/watchparty" width={343} height={455}>
<Menu.CardWithScrollable>

View file

@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { VideoPlayerButton } from "@/components/player/internals/Button";
@ -15,10 +16,72 @@ export function Time(props: { short?: boolean }) {
time,
draggingTime,
} = usePlayerStore((s) => s.progress);
const meta = usePlayerStore((s) => s.meta);
const { isSeeking } = usePlayerStore((s) => s.interface);
const { t } = useTranslation();
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() {
setTimeFormat(
timeFormat === VideoPlayerTimeFormat.REGULAR

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

@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
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, testRealDebridKey } from "@/pages/parts/settings/SetupPart";
import { usePreferencesStore } from "@/stores/preferences";
async function getRealDebridKeyStatus(realDebridKey: string | null) {
if (realDebridKey) {
const status: Status = await testRealDebridKey(realDebridKey);
return status;
}
return "unset";
}
export function RealDebridSetupView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const realDebridKey = usePreferencesStore((s) => s.realDebridKey);
const setRealDebridKey = usePreferencesStore((s) => s.setRealDebridKey);
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 getRealDebridKeyStatus(realDebridKey);
setStatus(result);
};
checkKeyStatus();
}, [realDebridKey]);
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")}>
Real Debrid Setup
</Menu.BackLink>
<Menu.Section className="pb-4">
<div className="my-3">
<p className="max-w-[30rem] font-medium">
<Trans i18nKey="realdebrid.description">
Real Debrid is a premium download service that allows you to
download files instantly from many file hosts.
<br />
<MwLink>
<a
href="https://real-debrid.com/"
target="_blank"
rel="noreferrer"
>
real-debrid.com
</a>
</MwLink>
</Trans>
</p>
</div>
<div className="mt-6">
<p className="text-white font-bold mb-3">
{t("realdebrid.tokenLabel", "API Key")}
</p>
<div className="flex items-center w-full">
<StatusCircle type={statusMap[status]} className="mx-2 mr-4" />
<AuthInputBox
onChange={(newKey) => {
setRealDebridKey(newKey);
}}
value={realDebridKey ?? ""}
placeholder="ABC123..."
passwordToggleable
className="flex-grow"
/>
</div>
{status === "error" && (
<p ref={alertRef} className="text-type-danger mt-4">
{t("realdebrid.status.failure")}
</p>
)}
{status === "api_down" && (
<p ref={alertRef} className="text-type-danger mt-4">
{t("realdebrid.status.api_down")}
</p>
)}
{status === "invalid_token" && (
<p ref={alertRef} className="text-type-danger mt-4">
{t("realdebrid.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

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

View file

@ -10,6 +10,8 @@ import {
import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { AnotherLink } from "@/pages/onboarding/utils";
import { conf } from "@/setup/config";
import { usePlayerStore } from "@/stores/player/store";
export interface SourceSelectionViewProps {
@ -150,21 +152,32 @@ export function SourceSelectionView({
return (
<>
<Menu.BackLink
onClick={() => router.navigate("/")}
rightSide={
<button
type="button"
onClick={() => window.open("/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>
}
>
<Menu.BackLink onClick={() => router.navigate("/")}>
{t("player.menus.sources.title")}
</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("realdebrid") !==
"false" &&
conf().ALLOW_REAL_DEBRID_KEY && (
<AnotherLink
to="/realdebrid-setup"
id="settings/source"
className="text-type-link flex w-full pb-2"
>
<span>Setup RealDebrid</span>
</AnotherLink>
)}
{sources.map((v) => (
<SelectableLink
key={v.id}

View file

@ -0,0 +1,141 @@
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: "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",
},
];
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 }) {
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 (
<div className="flex items-center">
<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>
{isExternal ? (
<a
href={props.url}
onClick={handleClick}
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>
</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>
);
}

View file

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

View file

@ -4,7 +4,7 @@ import "./stores/__old/imports";
import "@/setup/ga";
import "@/assets/css/index.css";
import { StrictMode, Suspense, useCallback } from "react";
import { StrictMode, Suspense, useCallback, useEffect } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
@ -177,6 +177,19 @@ function ExtensionStatus() {
const container = document.getElementById("root");
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(
<StrictMode>
<ErrorBoundary>
@ -189,6 +202,7 @@ root.render(
<GroupSyncer />
<SettingsSyncer />
<TheRouter>
<ZoomSetter />
<MigrationRunner />
</TheRouter>
</ThemeProvider>

View file

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

View file

@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { Heading2, Heading3, Paragraph } from "@/components/utils/Text";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
export function Card(props: {
children?: React.ReactNode;
@ -125,3 +126,35 @@ export function Link(props: {
</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 { Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill";
import { Navigation } from "@/components/layout/Navigation";
import { Title } from "@/components/text/Title";
import { Paragraph } from "@/components/utils/Text";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
@ -17,22 +16,27 @@ export function NotFoundPart() {
<Helmet>
<title>{t("notFound.badge")}</title>
</Helmet>
<Navigation />
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center">
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.EYE_SLASH}>{t("notFound.badge")}</IconPill>
<Title>{t("notFound.title")}</Title>
<Paragraph>{t("notFound.message")}</Paragraph>
<div className="flex gap-3">
<Button
href="/"
theme="secondary"
padding="md:px-12 p-2.5"
className="mt-6"
<Paragraph>
This page isn&apos;t available on the embed! <br />
If you believe this is an error, please report it to the{" "}
<a
href="https://discord.gg/7z6znYgrTG"
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
onClick={() => window.location.reload()}
theme="purple"

View file

@ -147,14 +147,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill>
<Title>{t("player.metadata.legal.title")}</Title>
<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>
</ErrorLayout>
);
@ -169,14 +161,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill>
<Title>{t("player.metadata.api.text")}</Title>
<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>
</ErrorLayout>
);
@ -191,14 +175,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill>
<Title>{t("player.metadata.failed.title")}</Title>
<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>
</ErrorLayout>
);
@ -213,14 +189,6 @@ export function MetaPart(props: MetaPartProps) {
</IconPill>
<Title>{t("player.metadata.notFound.title")}</Title>
<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>
</ErrorLayout>
);

View file

@ -59,14 +59,32 @@ export function PlaybackErrorPart() {
</Button>
</div>
<div className="flex gap-3">
<Button
href="/"
theme="secondary"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.playbackError.homeButton")}
</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;
})()}
<Button
theme="secondary"
padding="md:px-12 p-2.5"

View file

@ -35,6 +35,10 @@ export function PlayerPart(props: PlayerPartProps) {
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 isPWA = window.matchMedia("(display-mode: standalone)").matches;
@ -119,20 +123,44 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.TopControls show={showTargets}>
<div className="grid grid-cols-[1fr,auto] xl:grid-cols-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.InfoButton />
<Player.BookmarkButton />
{new URLSearchParams(window.location.search).get("allinone") ===
"true" && <Player.InfoButton />}
</div>
<div className="text-center hidden xl:flex justify-center items-center">
<Player.EpisodeTitle />
</div>
<div className="hidden sm:flex items-center justify-end">
<BrandPill />
</div>
{new URLSearchParams(window.location.search).get("logo") !==
"false" && (
<a
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 ? (
<>
@ -170,11 +198,16 @@ export function PlayerPart(props: PlayerPartProps) {
) : null}
</Player.LeftSideControls>
<div className="flex items-center space-x-3">
<Player.Episodes inControl={inControl} />
<Player.SkipEpisodeButton
inControl={inControl}
onChange={props.onMetaChange}
/>
{new URLSearchParams(window.location.search).get("allinone") ===
"true" && (
<>
<Player.Episodes inControl={inControl} />
<Player.SkipEpisodeButton
inControl={inControl}
onChange={props.onMetaChange}
/>
</>
)}
{status === playerStatus.PLAYING ? (
<>
<Player.Pip />
@ -207,7 +240,8 @@ export function PlayerPart(props: PlayerPartProps) {
{!isPWA && !isIOS && status === playerStatus.PLAYING && (
<Player.Pip />
)}
<Player.Episodes inControl={inControl} />
{new URLSearchParams(window.location.search).get("allinone") ===
"true" && <Player.Episodes inControl={inControl} />}
{status === playerStatus.PLAYING ? (
<div className="hidden ssm:block">
<Player.Captions />
@ -237,11 +271,14 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.SpeedChangedPopout />
<UnreleasedEpisodeOverlay />
<Player.NextEpisodeButton
controlsShowing={showTargets}
onChange={props.onMetaChange}
inControl={inControl}
/>
{new URLSearchParams(window.location.search).get("allinone") ===
"true" && (
<Player.NextEpisodeButton
controlsShowing={showTargets}
onChange={props.onMetaChange}
inControl={inControl}
/>
)}
<SkipIntroButton
controlsShowing={showTargets}

View file

@ -121,14 +121,6 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) {
<Title>{t("player.scraping.notFound.title")}</Title>
<Paragraph>{t("player.scraping.notFound.text")}</Paragraph>
<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
onClick={() => modal.show()}
theme="purple"
@ -137,6 +129,32 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) {
>
{t("player.scraping.notFound.detailsButton")}
</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>
{/* <Button
onClick={() => navigate("/discover")}

View file

@ -160,14 +160,6 @@ export function ScrapingPartInterruptButton() {
return (
<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
onClick={() => window.location.reload()}
theme="purple"
@ -189,9 +181,11 @@ export function Tips() {
return (
<div className="flex flex-col gap-3">
<p className="text-type-secondary text-center text-sm text-bold">
Tip: {tip}
</p>
{new URLSearchParams(window.location.search).get("tips") !== "false" && (
<p className="text-type-secondary text-center text-sm text-bold">
Tip: {tip}
</p>
)}
</div>
);
}

View file

@ -1,20 +1,16 @@
import { ReactElement, Suspense, lazy, useEffect, useState } from "react";
import { lazyWithPreload } from "react-lazy-with-preload";
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import {
convertEmbedUrl,
convertLegacyUrl,
isEmbedUrl,
isLegacyUrl,
} from "@/backend/metadata/getmeta";
import { NotificationModal } from "@/components/overlays/notificationsModal";
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
import { useOnlineListener } from "@/hooks/usePing";
import { AboutPage } from "@/pages/About";
import { AdminPage } from "@/pages/admin/AdminPage";
import { AllBookmarks } from "@/pages/bookmarks/AllBookmarks";
import VideoTesterView from "@/pages/developer/VideoTesterView";
@ -23,10 +19,8 @@ import { Discover } from "@/pages/discover/Discover";
import { MoreContent } from "@/pages/discover/MoreContent";
import MaintenancePage from "@/pages/errors/MaintenancePage";
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { JipPage } from "@/pages/Jip";
import { LegalPage, shouldHaveLegalPage } from "@/pages/Legal";
import { LoginPage } from "@/pages/Login";
import { MigrationPage } from "@/pages/migration/Migration";
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
import { MigrationDownloadPage } from "@/pages/migration/MigrationDownload";
@ -34,11 +28,11 @@ import { MigrationUploadPage } from "@/pages/migration/MigrationUpload";
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
import { RegisterPage } from "@/pages/Register";
import { SupportPage } from "@/pages/Support";
import { Layout } from "@/setup/Layout";
import { useHistoryListener } from "@/stores/history";
import { LanguageProvider } from "@/stores/language";
import { LanguageProvider, useLanguageStore } from "@/stores/language";
import { ThemeProvider, useThemeStore } from "@/stores/theme";
const DeveloperPage = lazy(() => import("@/pages/DeveloperPage"));
const TestView = lazy(() => import("@/pages/developer/TestView"));
@ -51,51 +45,33 @@ SettingsPage.preload();
function LegacyUrlView({ children }: { children: ReactElement }) {
const location = useLocation();
const navigate = useNavigate();
const setTheme = useThemeStore((s) => s.setTheme);
const setLanguage = useLanguageStore((s) => s.setLanguage);
useEffect(() => {
const url = location.pathname;
if (!isLegacyUrl(url)) return;
if (!isLegacyUrl(location.pathname)) return;
convertLegacyUrl(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 (isLegacyUrl(location.pathname)) return null;
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/${query}`, { replace: true });
} else {
navigate("/", { replace: true });
}
}, [query, navigate]);
return null;
}
export const maintenanceTime = "March 31th 11:00 PM - 5:00 AM EST";
function App() {
@ -117,17 +93,64 @@ function App() {
}
}, [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 (
<Layout>
<ThemeProvider />
<LanguageProvider />
<NotificationModal id="notifications" />
{!showDowntime && (
<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 */}
<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
path="/media/:media"
element={
@ -148,11 +171,11 @@ function App() {
</LegacyUrlView>
}
/>
<Route path="/browse/:query?" element={<HomePage />} />
<Route path="/" element={<HomePage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/browse/:query?" element={<NotFoundPage />} />
<Route path="/" element={<NotFoundPage />} />
<Route path="/register" element={<NotFoundPage />} />
<Route path="/login" element={<NotFoundPage />} />
<Route path="/about" element={<NotFoundPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route
path="/onboarding/extension"

View file

@ -120,13 +120,33 @@ export function getPrettyLanguageNameFromLocale(locale: string): string | null {
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
* @param langCodes list language codes to sort
* @returns sorted version of inputted list
*/
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 langOrderA = languagesOrder.findIndex(