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,
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

@ -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.
*/

View file

@ -682,15 +682,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

@ -169,6 +169,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,
@ -193,6 +208,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

@ -13,14 +13,19 @@ import { VideoPlayerButton } from "@/components/player/internals/Button";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { AudioView } from "./settings/AudioView";
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
import { CaptionsView } from "./settings/CaptionsView";
import { DebridSetupView } from "./settings/DebridSetupView";
import { DownloadRoutes } from "./settings/Downloads";
import { FedApiSetupView } from "./settings/FedApiSetupView";
import { LanguageView } from "./settings/LanguageView";
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
import { QualityView } from "./settings/QualityView";
import { SettingsMenu } from "./settings/SettingsMenu";
import { ThemeView } from "./settings/ThemeView";
import { TranscriptView } from "./settings/TranscriptView";
import { WatchPartyView } from "./settings/WatchPartyView";
@ -28,6 +33,11 @@ function SettingsOverlay({ id }: { id: string }) {
const [chosenSourceId, setChosenSourceId] = useState<string | null>(null);
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
useEffect(() => {
if (!router.isRouterActive) {
@ -54,7 +64,7 @@ function SettingsOverlay({ id }: { id: string }) {
<AudioView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={452}>
<OverlayPage id={id} path="/captions" width={343} height={320}>
<Menu.CardWithScrollable>
<CaptionsView id={id} backLink />
</Menu.CardWithScrollable>
@ -91,7 +101,33 @@ 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/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>
<PlaybackSettingsView id={id} />
</Menu.Card>
@ -106,6 +142,16 @@ function SettingsOverlay({ id }: { id: string }) {
<TranscriptView id={id} />
</Menu.CardWithScrollable>
</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";
@ -16,10 +17,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,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 { 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";
import { usePreferencesStore } from "@/stores/preferences";
@ -144,12 +146,6 @@ export function SourceSelectionView({
const currentSourceId = usePlayerStore((s) => s.sourceId);
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
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 sources = useMemo(() => {
@ -160,34 +156,13 @@ export function SourceSelectionView({
.filter((v) => !disabledSources.includes(v.id));
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;
}
// Sort sources according to preferred order, but prioritize last successful source
// Sort sources according to preferred order
const orderedSources = [];
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
for (const sourceId of preferredSourceOrder) {
const sourceIndex = remainingSources.findIndex((s) => s.id === sourceId);
@ -201,34 +176,36 @@ export function SourceSelectionView({
orderedSources.push(...remainingSources);
return orderedSources;
}, [
metaType,
preferredSourceOrder,
enableSourceOrder,
disabledSources,
lastSuccessfulSource,
enableLastSuccessfulSource,
]);
}, [metaType, preferredSourceOrder, enableSourceOrder, disabledSources]);
return (
<>
<Menu.BackLink
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>
}
>
<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("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) => (
<SelectableLink
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 }) {
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

@ -6,6 +6,7 @@ import { Caption } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { useSubtitleStore } from "@/stores/subtitles";
import { getLanguageOrder } from "@/utils/language";
import {
filterDuplicateCaptionCues,
@ -134,7 +135,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";
@ -179,6 +179,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>
@ -191,6 +204,7 @@ root.render(
<GroupSyncer />
<SettingsSyncer />
<TheRouter>
<ZoomSetter />
<MigrationRunner />
</TheRouter>
</ThemeProvider>

View file

@ -204,7 +204,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

@ -154,14 +154,32 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) {
</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

@ -5,6 +5,7 @@ import { Player } from "@/components/player";
import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton";
import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay";
import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus";
import { Widescreen } from "@/components/player/atoms/Widescreen";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { useSkipTime } from "@/components/player/hooks/useSkipTime";
import { useIsMobile } from "@/hooks/useIsMobile";
@ -34,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;
@ -84,11 +89,15 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.SubtitleView controlsShown={showTargets} />
{status === playerStatus.PLAYING ? (
<Player.CenterControls>
<Player.LoadingSpinner />
<Player.AutoPlayStart />
<Player.CastingNotification />
</Player.CenterControls>
<>
<Player.CenterControls>
<Player.LoadingSpinner />
<Player.AutoPlayStart />
</Player.CenterControls>
<Player.CenterControls>
<Player.CastingNotification />
</Player.CenterControls>
</>
) : null}
<Player.CenterMobileControls
@ -114,21 +123,45 @@ 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 lg:flex items-center justify-end">
<BrandPill />
</div>
<div className="flex lg:hidden items-center justify-end">
{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 ? (
<>
<Player.Airplay />
@ -165,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 />
@ -182,10 +220,14 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.Captions />
) : null}
<Player.Settings />
{isShifting || isHoldingFullscreen ? (
<Player.Widescreen />
) : (
<Player.Fullscreen />
{/* Fullscreen on when not shifting */}
{!isShifting && <Player.Fullscreen />}
{/* Expand button visible when shifting */}
{isShifting && (
<div>
<Widescreen />
</div>
)}
</div>
</div>
@ -193,10 +235,13 @@ export function PlayerPart(props: PlayerPartProps) {
<div />
<div className="flex justify-center space-x-3">
{/* 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.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 />
@ -205,18 +250,16 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.Settings />
</div>
<div>
{status === playerStatus.PLAYING && (
{isPWA && status === playerStatus.PLAYING ? (
<Widescreen />
) : (
<div
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
className="select-none touch-none"
style={{ WebkitTapHighlightColor: "transparent" }}
>
{isHoldingFullscreen ? (
<Player.Widescreen />
) : (
<Player.Fullscreen />
)}
{isHoldingFullscreen ? <Widescreen /> : <Player.Fullscreen />}
</div>
)}
</div>
@ -228,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

@ -167,14 +167,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"
@ -196,9 +188,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,22 +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 { DetailsModal } from "@/components/overlays/detailsModal";
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
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";
@ -25,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";
@ -36,12 +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 { useClearModalsOnNavigation } from "@/stores/interface/overlayStack";
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"));
@ -54,58 +45,39 @@ 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/${encodeURIComponent(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() {
useHistoryListener();
useOnlineListener();
useGlobalKeyboardEvents();
useClearModalsOnNavigation();
const maintenance = false; // Shows maintance page
const [showDowntime, setShowDowntime] = useState(maintenance);
@ -121,21 +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" />
<KeyboardCommandsModal id="keyboard-commands" />
<DetailsModal id="details" />
<DetailsModal id="discover-details" />
<DetailsModal id="player-details" />
{!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={
@ -156,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(