mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-29 20:58:44 +00:00
Add info popover for media card hover
This commit is contained in:
parent
787979eb13
commit
34960cc0c2
10 changed files with 465 additions and 52 deletions
|
|
@ -612,7 +612,15 @@
|
|||
"teal": "Teal",
|
||||
"classic": "Classic"
|
||||
},
|
||||
"title": "Appearance"
|
||||
"title": "Appearance",
|
||||
"options": {
|
||||
"discover": "Discover section",
|
||||
"discoverDescription": "Show the Discover section on the Homepage below your bookmarked media. Enabled by default.",
|
||||
"discoverLabel": "Discover section",
|
||||
"hover": "Details popup",
|
||||
"hoverDescription": "Show a popup with details when hovering over a media item. Disabled by default.",
|
||||
"hoverLabel": "Details popup"
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"server": {
|
||||
|
|
@ -668,9 +676,6 @@
|
|||
"sourceOrder": "Reordering sources",
|
||||
"sourceOrderDescription": "Drag and drop to reorder sources. This will determine the order in which sources are checked for the media you are trying to watch. If a source is greyed out, it means the <bold>extension</bold> is required for that source. <br><br> <strong>(The default order is best for most users)</strong>",
|
||||
"title": "Preferences",
|
||||
"discover": "Discover section",
|
||||
"discoverDescription": "Show the Discover section on the Homepage below your bookmarked media. Enabling this can increase load times. This setting is enabled by default.",
|
||||
"discoverLabel": "Discover section",
|
||||
"sourceOrderEnableLabel": "Custom source order"
|
||||
},
|
||||
"reset": "Reset",
|
||||
|
|
|
|||
|
|
@ -275,6 +275,18 @@ export function getMediaDetails<
|
|||
throw new Error("Invalid media type");
|
||||
}
|
||||
|
||||
export function getMediaBackdrop(
|
||||
backdropPath: string | null,
|
||||
): string | undefined {
|
||||
const shouldProxyTmdb = usePreferencesStore.getState().proxyTmdb;
|
||||
const imgUrl = `https://image.tmdb.org/t/p/original${backdropPath}`;
|
||||
const proxyUrl = getProxyUrls()[0];
|
||||
if (proxyUrl && shouldProxyTmdb) {
|
||||
return `${proxyUrl}/?destination=${imgUrl}`;
|
||||
}
|
||||
if (backdropPath) return imgUrl;
|
||||
}
|
||||
|
||||
export function getMediaPoster(posterPath: string | null): string | undefined {
|
||||
const shouldProxyTmdb = usePreferencesStore.getState().proxyTmdb;
|
||||
const imgUrl = `https://image.tmdb.org/t/p/w342/${posterPath}`;
|
||||
|
|
|
|||
262
src/components/media/InfoPopout.tsx
Normal file
262
src/components/media/InfoPopout.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getMediaBackdrop, getMediaDetails } from "@/backend/metadata/tmdb";
|
||||
import {
|
||||
TMDBContentTypes,
|
||||
TMDBMovieData,
|
||||
TMDBShowData,
|
||||
} from "@/backend/metadata/types/tmdb";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
interface InfoPopoutProps {
|
||||
media: MediaItem;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// Add interface for storing additional details
|
||||
interface AdditionalDetails {
|
||||
runtime?: number | null;
|
||||
genres?: { id: number; name: string }[];
|
||||
language?: string;
|
||||
episodes?: number;
|
||||
seasons?: number;
|
||||
voteAverage?: number;
|
||||
voteCount?: number;
|
||||
}
|
||||
|
||||
function InfoSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="relative h-40">
|
||||
<div
|
||||
className="absolute inset-0 bg-mediaCard-hoverBackground"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-4 mt-[-30px]">
|
||||
<div className="h-7 w-3/4 bg-white/10 rounded mb-2" /> {/* Title */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{/* Description */}
|
||||
<div className="h-4 bg-white/10 rounded w-full" />
|
||||
<div className="h-4 bg-white/10 rounded w-full" />
|
||||
<div className="h-4 bg-white/10 rounded w-full" />
|
||||
<div className="h-4 bg-white/10 rounded w-3/4" />
|
||||
</div>
|
||||
{/* Additional details */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<div className="h-4 bg-white/10 rounded w-3/4" />
|
||||
<div className="h-4 bg-white/10 rounded w-3/4" />
|
||||
<div className="h-4 bg-white/10 rounded w-3/4" />
|
||||
</div>
|
||||
{/* Genres */}
|
||||
<div className="flex flex-wrap gap-1 mt-4">
|
||||
<div className="h-5 w-16 bg-white/10 rounded-full" />
|
||||
<div className="h-5 w-20 bg-white/10 rounded-full" />
|
||||
<div className="h-5 w-14 bg-white/10 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoPopout({ media, visible }: InfoPopoutProps) {
|
||||
const [description, setDescription] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [backdrop, setBackdrop] = useState<string | undefined>();
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
const [shouldShow, setShouldShow] = useState(false);
|
||||
const [additionalDetails, setAdditionalDetails] = useState<AdditionalDetails>(
|
||||
{},
|
||||
);
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const enablePopDetails = usePreferencesStore((s) => s.enablePopDetails);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!enablePopDetails) return;
|
||||
if (dataLoaded) return; // Skip if already loaded
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const type =
|
||||
media.type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV;
|
||||
const details = await getMediaDetails(media.id, type);
|
||||
const backdropUrl = getMediaBackdrop(details.backdrop_path);
|
||||
setDescription(details.overview || undefined);
|
||||
setBackdrop(backdropUrl);
|
||||
|
||||
if (type === TMDBContentTypes.MOVIE) {
|
||||
const movieDetails = details as TMDBMovieData;
|
||||
setAdditionalDetails({
|
||||
runtime: movieDetails.runtime,
|
||||
genres: movieDetails.genres,
|
||||
language: movieDetails.original_language,
|
||||
voteAverage: movieDetails.vote_average,
|
||||
voteCount: movieDetails.vote_count,
|
||||
});
|
||||
} else {
|
||||
const showDetails = details as TMDBShowData;
|
||||
setAdditionalDetails({
|
||||
episodes: showDetails.number_of_episodes,
|
||||
seasons: showDetails.number_of_seasons,
|
||||
genres: showDetails.genres,
|
||||
language: showDetails.original_language,
|
||||
voteAverage: showDetails.vote_average,
|
||||
voteCount: showDetails.vote_count,
|
||||
});
|
||||
}
|
||||
|
||||
setDataLoaded(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch media details:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [enablePopDetails, dataLoaded, media.type, media.id]);
|
||||
|
||||
// Start loading data when user hovers
|
||||
useEffect(() => {
|
||||
if (visible && !dataLoaded && !isLoading && enablePopDetails) {
|
||||
fetchData();
|
||||
}
|
||||
}, [visible, dataLoaded, isLoading, enablePopDetails, fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
// Start timer when user hovers
|
||||
if (visible && !shouldShow) {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setShouldShow(true);
|
||||
}, 200); // 0.2s
|
||||
}
|
||||
|
||||
if (!visible && shouldShow) {
|
||||
setShouldShow(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible, shouldShow]);
|
||||
|
||||
const showPopout = visible && shouldShow;
|
||||
|
||||
const formatRuntime = (minutes?: number | null) => {
|
||||
if (!minutes) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const formatVoteCount = (count?: number) => {
|
||||
if (!count) return "0";
|
||||
if (count >= 1000) {
|
||||
return `${Math.floor(count / 1000)}K+`;
|
||||
}
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute left-[calc(100%+12px)] top-1/2 -translate-y-1/2 ml-1 w-[280px] rounded-xl overflow-hidden transition-all duration-300",
|
||||
"backdrop-blur-md bg-mediaCard-hoverBackground border border-mediaCard-hoverAccent/40",
|
||||
"z-[999]",
|
||||
showPopout
|
||||
? "opacity-100 translate-x-0"
|
||||
: "opacity-0 -translate-x-4 pointer-events-none",
|
||||
)}
|
||||
onMouseEnter={() => media.onHoverInfoEnter?.()}
|
||||
onMouseLeave={() => media.onHoverInfoLeave?.()}
|
||||
>
|
||||
<div className="p-0">
|
||||
{isLoading ? (
|
||||
<InfoSkeleton />
|
||||
) : (
|
||||
<div className="relative">
|
||||
{backdrop && (
|
||||
<div className="absolute top-0 left-0 right-0 h-full z-0">
|
||||
<img
|
||||
src={backdrop}
|
||||
alt={media.title}
|
||||
className="w-full h-40 object-cover"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="h-40" /> {/* Spacer for backdrop height */}
|
||||
<div className="px-4 pb-4 mt-[-30px]">
|
||||
<h3 className="text-lg font-bold text-white mb-2">
|
||||
{media.title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-white/90 mb-4 line-clamp-4">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Additional Details Section */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{additionalDetails.runtime && (
|
||||
<div className="flex items-center gap-1 text-white/80">
|
||||
<span className="font-medium">Runtime:</span>{" "}
|
||||
{formatRuntime(additionalDetails.runtime)}
|
||||
</div>
|
||||
)}
|
||||
{additionalDetails.language && (
|
||||
<div className="flex items-center gap-1 text-white/80">
|
||||
<span className="font-medium">Language:</span>{" "}
|
||||
{additionalDetails.language.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{additionalDetails.voteAverage !== undefined &&
|
||||
additionalDetails.voteCount !== undefined &&
|
||||
additionalDetails.voteCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-white/80">
|
||||
<span className="font-medium">Rating:</span>{" "}
|
||||
{additionalDetails.voteAverage.toFixed(1)}/10
|
||||
<span className="text-white/60 text-[10px]">
|
||||
({formatVoteCount(additionalDetails.voteCount)})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{additionalDetails.genres &&
|
||||
additionalDetails.genres.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1">
|
||||
{additionalDetails.genres.slice(0, 3).map((genre) => (
|
||||
<span
|
||||
key={genre.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-white/10 text-white/70"
|
||||
>
|
||||
{genre.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
// I'm sorry this is so confusing 😭
|
||||
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
|
@ -8,12 +10,14 @@ import { mediaItemToId } from "@/backend/metadata/tmdb";
|
|||
import { DotList } from "@/components/text/DotList";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { MediaBookmarkButton } from "./MediaBookmark";
|
||||
import { Button } from "../buttons/Button";
|
||||
import { IconPatch } from "../buttons/IconPatch";
|
||||
import { Icon, Icons } from "../Icon";
|
||||
import { InfoPopout } from "./InfoPopout";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MediaItem;
|
||||
|
|
@ -59,12 +63,14 @@ function MediaCardContent({
|
|||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
link,
|
||||
isHoveringCard,
|
||||
}: MediaCardProps & {
|
||||
overlayVisible: boolean;
|
||||
setOverlayVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleMouseEnter: () => void;
|
||||
handleMouseLeave: () => void;
|
||||
link: string;
|
||||
isHoveringCard: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||
|
|
@ -88,7 +94,7 @@ function MediaCardContent({
|
|||
setOverlayVisible(false);
|
||||
}
|
||||
|
||||
if (media.year) {
|
||||
if (isReleased() && media.year) {
|
||||
dotListContent.push(media.year.toFixed());
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +157,7 @@ function MediaCardContent({
|
|||
/>
|
||||
<Flare.Child
|
||||
className={`pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300 ${
|
||||
canLink ? "group-hover:scale-95" : "opacity-60"
|
||||
canLink ? (isHoveringCard ? "scale-95" : "") : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
|
|
@ -344,6 +350,7 @@ function MediaCardContent({
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOverlayVisible(!overlayVisible);
|
||||
}}
|
||||
>
|
||||
|
|
@ -363,20 +370,59 @@ function MediaCardContent({
|
|||
export function MediaCard(props: MediaCardProps) {
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
||||
const [showHoverInfo, setShowHoverInfo] = useState(false);
|
||||
const hoverTimer = useRef<NodeJS.Timeout>();
|
||||
const [isHoveringCard, setIsHoveringCard] = useState(false);
|
||||
const [isHoveringInfo, setIsHoveringInfo] = useState(false);
|
||||
const [isBigScreen, setIsBigScreen] = useState(false);
|
||||
const enablePopDetails = usePreferencesStore((s) => s.enablePopDetails);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsBigScreen(window.innerWidth >= 768); // md breakpoint
|
||||
};
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHoveringCard(true);
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
setTimeoutId(null);
|
||||
}
|
||||
|
||||
if (hoverTimer.current) {
|
||||
clearTimeout(hoverTimer.current);
|
||||
}
|
||||
|
||||
if (isBigScreen && !overlayVisible) {
|
||||
hoverTimer.current = setTimeout(() => {
|
||||
setShowHoverInfo(true);
|
||||
}, 200); // 0.2 second delay
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHoveringCard(false);
|
||||
if (hoverTimer.current) {
|
||||
clearTimeout(hoverTimer.current);
|
||||
}
|
||||
|
||||
if (!isHoveringInfo) {
|
||||
setShowHoverInfo(false);
|
||||
}
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setOverlayVisible(false);
|
||||
}, 2000); // 2 seconds
|
||||
setTimeoutId(id);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
setTimeoutId(null);
|
||||
}
|
||||
};
|
||||
const shouldShowHoverInfo =
|
||||
showHoverInfo && !overlayVisible && isBigScreen && enablePopDetails;
|
||||
|
||||
const isReleased = useCallback(
|
||||
() => checkReleased(props.media),
|
||||
|
|
@ -398,6 +444,17 @@ export function MediaCard(props: MediaCardProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const hoverMedia = {
|
||||
...props.media,
|
||||
onHoverInfoEnter: () => setIsHoveringInfo(true),
|
||||
onHoverInfoLeave: () => {
|
||||
setIsHoveringInfo(false);
|
||||
if (!isHoveringCard && !overlayVisible) {
|
||||
setShowHoverInfo(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const content = (
|
||||
<MediaCardContent
|
||||
{...props}
|
||||
|
|
@ -406,10 +463,17 @@ export function MediaCard(props: MediaCardProps) {
|
|||
handleMouseEnter={handleMouseEnter}
|
||||
handleMouseLeave={handleMouseLeave}
|
||||
link={link}
|
||||
isHoveringCard={isHoveringCard}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!canLink) return <span>{content}</span>;
|
||||
if (!canLink)
|
||||
return (
|
||||
<span className="relative">
|
||||
{content}{" "}
|
||||
<InfoPopout media={hoverMedia} visible={shouldShowHoverInfo} />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="relative">
|
||||
{!overlayVisible ? (
|
||||
|
|
@ -420,6 +484,8 @@ export function MediaCard(props: MediaCardProps) {
|
|||
"tabbable",
|
||||
props.closable ? "hover:cursor-default" : "",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
|
|
@ -430,10 +496,16 @@ export function MediaCard(props: MediaCardProps) {
|
|||
"tabbable",
|
||||
props.closable ? "hover:cursor-default" : "",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowHoverInfo && (
|
||||
<InfoPopout media={hoverMedia} visible={shouldShowHoverInfo} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export function useSettingsState(
|
|||
enableThumbnails: boolean,
|
||||
enableAutoplay: boolean,
|
||||
enableDiscover: boolean,
|
||||
enablePopDetails: boolean,
|
||||
sourceOrder: string[],
|
||||
enableSourceOrder: boolean,
|
||||
proxyTmdb: boolean,
|
||||
|
|
@ -108,6 +109,12 @@ export function useSettingsState(
|
|||
resetEnableDiscover,
|
||||
enableDiscoverChanged,
|
||||
] = useDerived(enableDiscover);
|
||||
const [
|
||||
enablePopDetailsState,
|
||||
setEnablePopDetailsState,
|
||||
resetEnablePopDetails,
|
||||
enablePopDetailsChanged,
|
||||
] = useDerived(enablePopDetails);
|
||||
const [
|
||||
sourceOrderState,
|
||||
setSourceOrderState,
|
||||
|
|
@ -136,6 +143,7 @@ export function useSettingsState(
|
|||
resetEnableThumbnails();
|
||||
resetEnableAutoplay();
|
||||
resetEnableDiscover();
|
||||
resetEnablePopDetails();
|
||||
resetSourceOrder();
|
||||
resetEnableSourceOrder();
|
||||
resetProxyTmdb();
|
||||
|
|
@ -153,6 +161,7 @@ export function useSettingsState(
|
|||
enableThumbnailsChanged ||
|
||||
enableAutoplayChanged ||
|
||||
enableDiscoverChanged ||
|
||||
enablePopDetailsChanged ||
|
||||
sourceOrderChanged ||
|
||||
enableSourceOrderChanged ||
|
||||
proxyTmdbChanged;
|
||||
|
|
@ -215,6 +224,11 @@ export function useSettingsState(
|
|||
set: setEnableDiscoverState,
|
||||
changed: enableDiscoverChanged,
|
||||
},
|
||||
enablePopDetails: {
|
||||
state: enablePopDetailsState,
|
||||
set: setEnablePopDetailsState,
|
||||
changed: enablePopDetailsChanged,
|
||||
},
|
||||
sourceOrder: {
|
||||
state: sourceOrderState,
|
||||
set: setSourceOrderState,
|
||||
|
|
|
|||
|
|
@ -23,12 +23,12 @@ import { useIsMobile } from "@/hooks/useIsMobile";
|
|||
import { useSettingsState } from "@/hooks/useSettingsState";
|
||||
import { AccountActionsPart } from "@/pages/parts/settings/AccountActionsPart";
|
||||
import { AccountEditPart } from "@/pages/parts/settings/AccountEditPart";
|
||||
import { AppearancePart } from "@/pages/parts/settings/AppearancePart";
|
||||
import { CaptionsPart } from "@/pages/parts/settings/CaptionsPart";
|
||||
import { ConnectionsPart } from "@/pages/parts/settings/ConnectionsPart";
|
||||
import { DeviceListPart } from "@/pages/parts/settings/DeviceListPart";
|
||||
import { RegisterCalloutPart } from "@/pages/parts/settings/RegisterCalloutPart";
|
||||
import { SidebarPart } from "@/pages/parts/settings/SidebarPart";
|
||||
import { ThemePart } from "@/pages/parts/settings/ThemePart";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
|
|
@ -146,6 +146,9 @@ export function SettingsPage() {
|
|||
const enableDiscover = usePreferencesStore((s) => s.enableDiscover);
|
||||
const setEnableDiscover = usePreferencesStore((s) => s.setEnableDiscover);
|
||||
|
||||
const enablePopDetails = usePreferencesStore((s) => s.enablePopDetails);
|
||||
const setEnablePopDetails = usePreferencesStore((s) => s.setEnablePopDetails);
|
||||
|
||||
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
||||
const setEnableSourceOrder = usePreferencesStore(
|
||||
(s) => s.setEnableSourceOrder,
|
||||
|
|
@ -179,6 +182,7 @@ export function SettingsPage() {
|
|||
enableThumbnails,
|
||||
enableAutoplay,
|
||||
enableDiscover,
|
||||
enablePopDetails,
|
||||
sourceOrder,
|
||||
enableSourceOrder,
|
||||
proxyTmdb,
|
||||
|
|
@ -253,6 +257,7 @@ export function SettingsPage() {
|
|||
setEnableThumbnails(state.enableThumbnails.state);
|
||||
setEnableAutoplay(state.enableAutoplay.state);
|
||||
setEnableDiscover(state.enableDiscover.state);
|
||||
setEnablePopDetails(state.enablePopDetails.state);
|
||||
setSourceOrder(state.sourceOrder.state);
|
||||
setAppLanguage(state.appLanguage.state);
|
||||
setTheme(state.theme.state);
|
||||
|
|
@ -285,6 +290,7 @@ export function SettingsPage() {
|
|||
state,
|
||||
setEnableAutoplay,
|
||||
setEnableDiscover,
|
||||
setEnablePopDetails,
|
||||
setSourceOrder,
|
||||
setAppLanguage,
|
||||
setTheme,
|
||||
|
|
@ -338,8 +344,6 @@ export function SettingsPage() {
|
|||
setEnableThumbnails={state.enableThumbnails.set}
|
||||
enableAutoplay={state.enableAutoplay.state}
|
||||
setEnableAutoplay={state.enableAutoplay.set}
|
||||
enableDiscover={state.enableDiscover.state}
|
||||
setEnableDiscover={state.enableDiscover.set}
|
||||
sourceOrder={availableSources}
|
||||
setSourceOrder={state.sourceOrder.set}
|
||||
enableSourceOrder={state.enableSourceOrder.state}
|
||||
|
|
@ -347,10 +351,14 @@ export function SettingsPage() {
|
|||
/>
|
||||
</div>
|
||||
<div id="settings-appearance" className="mt-28">
|
||||
<ThemePart
|
||||
<AppearancePart
|
||||
active={previewTheme ?? "default"}
|
||||
inUse={activeTheme ?? "default"}
|
||||
setTheme={setThemeWithPreview}
|
||||
enableDiscover={state.enableDiscover.state}
|
||||
setEnableDiscover={state.enableDiscover.set}
|
||||
enablePopDetails={state.enablePopDetails.state}
|
||||
setEnablePopDetails={state.enablePopDetails.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-captions" className="mt-28">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Toggle } from "@/components/buttons/Toggle";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
|
||||
|
|
@ -130,27 +131,77 @@ function ThemePreview(props: {
|
|||
);
|
||||
}
|
||||
|
||||
export function ThemePart(props: {
|
||||
export function AppearancePart(props: {
|
||||
active: string;
|
||||
inUse: string;
|
||||
setTheme: (theme: string) => void;
|
||||
|
||||
enableDiscover: boolean;
|
||||
setEnableDiscover: (v: boolean) => void;
|
||||
|
||||
enablePopDetails: boolean;
|
||||
setEnablePopDetails: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-12">
|
||||
<Heading1 border>{t("settings.appearance.title")}</Heading1>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-6 max-w-[700px]">
|
||||
{availableThemes.map((v) => (
|
||||
<ThemePreview
|
||||
selector={v.selector}
|
||||
active={props.active === v.id}
|
||||
inUse={props.inUse === v.id}
|
||||
name={t(v.key)}
|
||||
key={v.id}
|
||||
onClick={() => props.setTheme(v.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* First Column - Preferences */}
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.appearance.options.discover")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.appearance.options.discoverDescription")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => props.setEnableDiscover(!props.enableDiscover)}
|
||||
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
|
||||
>
|
||||
<Toggle enabled={props.enableDiscover} />
|
||||
<p className="flex-1 text-white font-bold">
|
||||
{t("settings.appearance.options.discoverLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.appearance.options.hover")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.appearance.options.hoverDescription")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => props.setEnablePopDetails(!props.enablePopDetails)}
|
||||
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
|
||||
>
|
||||
<Toggle enabled={props.enablePopDetails} />
|
||||
<p className="flex-1 text-white font-bold">
|
||||
{t("settings.appearance.options.hoverLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second Column - Themes */}
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 gap-4 max-w-[600px]">
|
||||
{availableThemes.map((v) => (
|
||||
<ThemePreview
|
||||
selector={v.selector}
|
||||
active={props.active === v.id}
|
||||
inUse={props.inUse === v.id}
|
||||
name={t(v.key)}
|
||||
key={v.id}
|
||||
onClick={() => props.setTheme(v.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -23,8 +23,6 @@ export function PreferencesPart(props: {
|
|||
setEnableAutoplay: (v: boolean) => void;
|
||||
sourceOrder: string[];
|
||||
setSourceOrder: (v: string[]) => void;
|
||||
enableDiscover: boolean;
|
||||
setEnableDiscover: (v: boolean) => void;
|
||||
enableSourceOrder: boolean;
|
||||
setenableSourceOrder: (v: boolean) => void;
|
||||
}) {
|
||||
|
|
@ -125,25 +123,6 @@ export function PreferencesPart(props: {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Discover Preference */}
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.preferences.discover")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.preferences.discoverDescription")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => props.setEnableDiscover(!props.enableDiscover)}
|
||||
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
|
||||
>
|
||||
<Toggle enabled={props.enableDiscover} />
|
||||
<p className="flex-1 text-white font-bold">
|
||||
{t("settings.preferences.discoverLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column */}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface PreferencesStore {
|
|||
enableThumbnails: boolean;
|
||||
enableAutoplay: boolean;
|
||||
enableDiscover: boolean;
|
||||
enablePopDetails: boolean;
|
||||
sourceOrder: string[];
|
||||
enableSourceOrder: boolean;
|
||||
proxyTmdb: boolean;
|
||||
|
|
@ -13,6 +14,7 @@ export interface PreferencesStore {
|
|||
setEnableThumbnails(v: boolean): void;
|
||||
setEnableAutoplay(v: boolean): void;
|
||||
setEnableDiscover(v: boolean): void;
|
||||
setEnablePopDetails(v: boolean): void;
|
||||
setSourceOrder(v: string[]): void;
|
||||
setEnableSourceOrder(v: boolean): void;
|
||||
setProxyTmdb(v: boolean): void;
|
||||
|
|
@ -24,6 +26,7 @@ export const usePreferencesStore = create(
|
|||
enableThumbnails: false,
|
||||
enableAutoplay: true,
|
||||
enableDiscover: true,
|
||||
enablePopDetails: false,
|
||||
sourceOrder: [],
|
||||
enableSourceOrder: false,
|
||||
proxyTmdb: false,
|
||||
|
|
@ -42,6 +45,11 @@ export const usePreferencesStore = create(
|
|||
s.enableDiscover = v;
|
||||
});
|
||||
},
|
||||
setEnablePopDetails(v) {
|
||||
set((s) => {
|
||||
s.enablePopDetails = v;
|
||||
});
|
||||
},
|
||||
setSourceOrder(v) {
|
||||
set((s) => {
|
||||
s.sourceOrder = v;
|
||||
|
|
|
|||
|
|
@ -5,4 +5,6 @@ export interface MediaItem {
|
|||
release_date?: Date;
|
||||
poster?: string;
|
||||
type: "show" | "movie";
|
||||
onHoverInfoEnter?: () => void;
|
||||
onHoverInfoLeave?: () => void;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue