mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
EMBED
Update ThemeView.tsx update debrid
This commit is contained in:
parent
c1bdcdf9df
commit
0ca23eb7c9
25 changed files with 1234 additions and 260 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
164
src/components/player/atoms/settings/DebridSetupView.tsx
Normal file
164
src/components/player/atoms/settings/DebridSetupView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
148
src/components/player/atoms/settings/FedApiSetupView.tsx
Normal file
148
src/components/player/atoms/settings/FedApiSetupView.tsx
Normal 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&badge=0&autopause=0&player_id=0&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 "ui" 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 "VIP" 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
src/components/player/atoms/settings/LanguageView.tsx
Normal file
79
src/components/player/atoms/settings/LanguageView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
161
src/components/player/atoms/settings/ThemeView.tsx
Normal file
161
src/components/player/atoms/settings/ThemeView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export function RealPlayerView() {
|
|||
);
|
||||
|
||||
return (
|
||||
<PlayerPart backUrl={backUrl} onMetaChange={metaChange}>
|
||||
<PlayerPart backUrl={backUrl}>
|
||||
{status === playerStatus.IDLE ? (
|
||||
<MetaPart onGetMeta={handleMetaReceived} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue