diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index a208bd1f..fbd03570 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -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 { + 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; +} diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index 00ea2be4..0743bc85 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -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(`/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. */ diff --git a/src/components/player/atoms/Episodes.tsx b/src/components/player/atoms/Episodes.tsx index 2d081980..afbaa215 100644 --- a/src/components/player/atoms/Episodes.tsx +++ b/src/components/player/atoms/Episodes.tsx @@ -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( diff --git a/src/components/player/atoms/NextEpisodeButton.tsx b/src/components/player/atoms/NextEpisodeButton.tsx index a59d2efb..60ca97cb 100644 --- a/src/components/player/atoms/NextEpisodeButton.tsx +++ b/src/components/player/atoms/NextEpisodeButton.tsx @@ -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, diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index a7bad320..ab05f9d5 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -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(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 }) { - + @@ -91,7 +101,33 @@ function SettingsOverlay({ id }: { id: string }) { - + + + + + + + + + + + @@ -106,6 +142,16 @@ function SettingsOverlay({ id }: { id: string }) { + + + + + + + + + + diff --git a/src/components/player/atoms/Time.tsx b/src/components/player/atoms/Time.tsx index 5561c4a4..8682f0a5 100644 --- a/src/components/player/atoms/Time.tsx +++ b/src/components/player/atoms/Time.tsx @@ -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(0); + const timeoutRef = useRef(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 diff --git a/src/components/player/atoms/settings/DebridSetupView.tsx b/src/components/player/atoms/settings/DebridSetupView.tsx new file mode 100644 index 00000000..43e05a05 --- /dev/null +++ b/src/components/player/atoms/settings/DebridSetupView.tsx @@ -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("unset"); + const statusMap: Record = { + 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(null); + + useEffect(() => { + if ((status === "success" || status === "error") && alertRef.current) { + alertRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }, [status]); + + return ( + <> + router.navigate("/source")}> + Debrid Setup + + +
+

+ + + {/* fifth's referral code */} + + +

+

+ {t("debrid.notice")} +

+
+ +
+

{t("debrid.tokenLabel")}

+
+
+ + { + setdebridToken(newToken); + }} + value={debridToken ?? ""} + placeholder="ABC123..." + passwordToggleable + className="flex-grow" + /> +
+
+ setdebridService(item.id)} + direction="up" + /> +
+
+ {status === "error" && ( +

+ {t("debrid.status.failure")} +

+ )} + {status === "api_down" && ( +

+ {t("debrid.status.api_down")} +

+ )} + {status === "invalid_token" && ( +

+ {t("debrid.status.invalid_token")} +

+ )} + {status === "success" && ( +
+ +
+ )} +
+
+ + ); +} diff --git a/src/components/player/atoms/settings/FedApiSetupView.tsx b/src/components/player/atoms/settings/FedApiSetupView.tsx new file mode 100644 index 00000000..f9efbed8 --- /dev/null +++ b/src/components/player/atoms/settings/FedApiSetupView.tsx @@ -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("unset"); + const statusMap: Record = { + 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(null); + + useEffect(() => { + if ((status === "success" || status === "error") && alertRef.current) { + alertRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }, [status]); + + return ( + <> + router.navigate("/source")}> + Febbox API Setup + + +
+

+ + To get your UI Key: +
+

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" + > + + {showVideo ? "Hide Video Tutorial" : "Show Video Tutorial"} + + {showVideo ? ( + + ) : ( + + )} +
+ {showVideo && ( + <> +
+