diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json
index 8ea2111b..fb261334 100644
--- a/src/assets/locales/en.json
+++ b/src/assets/locales/en.json
@@ -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 extension is required for that source.
(The default order is best for most users)",
"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",
diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts
index 4954b984..f64e5ef5 100644
--- a/src/backend/metadata/tmdb.ts
+++ b/src/backend/metadata/tmdb.ts
@@ -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}`;
diff --git a/src/components/media/InfoPopout.tsx b/src/components/media/InfoPopout.tsx
new file mode 100644
index 00000000..1c9bd9e1
--- /dev/null
+++ b/src/components/media/InfoPopout.tsx
@@ -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 (
+
+
+
+
{/* Title */}
+
+ {/* Description */}
+
+
+
+
+
+ {/* Additional details */}
+
+ {/* Genres */}
+
+
+
+ );
+}
+
+export function InfoPopout({ media, visible }: InfoPopoutProps) {
+ const [description, setDescription] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+ const [backdrop, setBackdrop] = useState();
+ const [dataLoaded, setDataLoaded] = useState(false);
+ const [shouldShow, setShouldShow] = useState(false);
+ const [additionalDetails, setAdditionalDetails] = useState(
+ {},
+ );
+ const hoverTimerRef = useRef(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 (
+ media.onHoverInfoEnter?.()}
+ onMouseLeave={() => media.onHoverInfoLeave?.()}
+ >
+
+ {isLoading ? (
+
+ ) : (
+
+ {backdrop && (
+
+

+
+ )}
+
+
+
{/* Spacer for backdrop height */}
+
+
+ {media.title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Additional Details Section */}
+
+ {additionalDetails.runtime && (
+
+ Runtime:{" "}
+ {formatRuntime(additionalDetails.runtime)}
+
+ )}
+ {additionalDetails.language && (
+
+ Language:{" "}
+ {additionalDetails.language.toUpperCase()}
+
+ )}
+ {additionalDetails.voteAverage !== undefined &&
+ additionalDetails.voteCount !== undefined &&
+ additionalDetails.voteCount > 0 && (
+
+ Rating:{" "}
+ {additionalDetails.voteAverage.toFixed(1)}/10
+
+ ({formatVoteCount(additionalDetails.voteCount)})
+
+
+ )}
+
+
+ {/* Genres */}
+ {additionalDetails.genres &&
+ additionalDetails.genres.length > 0 && (
+
+ {additionalDetails.genres.slice(0, 3).map((genre) => (
+
+ {genre.name}
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx
index b21046ec..5c217864 100644
--- a/src/components/media/MediaCard.tsx
+++ b/src/components/media/MediaCard.tsx
@@ -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>;
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({
/>
{
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
(null);
+ const [showHoverInfo, setShowHoverInfo] = useState(false);
+ const hoverTimer = useRef();
+ 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 = (
);
- if (!canLink) return {content};
+ if (!canLink)
+ return (
+
+ {content}{" "}
+
+
+ );
return (
{!overlayVisible ? (
@@ -420,6 +484,8 @@ export function MediaCard(props: MediaCardProps) {
"tabbable",
props.closable ? "hover:cursor-default" : "",
)}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
>
{content}
@@ -430,10 +496,16 @@ export function MediaCard(props: MediaCardProps) {
"tabbable",
props.closable ? "hover:cursor-default" : "",
)}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
>
{content}
)}
+
+ {shouldShowHoverInfo && (
+
+ )}
);
}
diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts
index c3f1a2f0..b52a63b5 100644
--- a/src/hooks/useSettingsState.ts
+++ b/src/hooks/useSettingsState.ts
@@ -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,
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index eb50f9b1..e96ee5c1 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -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() {
/>
-
diff --git a/src/pages/parts/settings/ThemePart.tsx b/src/pages/parts/settings/AppearancePart.tsx
similarity index 67%
rename from src/pages/parts/settings/ThemePart.tsx
rename to src/pages/parts/settings/AppearancePart.tsx
index 1b7b489d..825b8b06 100644
--- a/src/pages/parts/settings/ThemePart.tsx
+++ b/src/pages/parts/settings/AppearancePart.tsx
@@ -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 (
-
+
{t("settings.appearance.title")}
-
- {availableThemes.map((v) => (
-
props.setTheme(v.id)}
- />
- ))}
+
+
+ {/* First Column - Preferences */}
+
+
+
+ {t("settings.appearance.options.discover")}
+
+
+ {t("settings.appearance.options.discoverDescription")}
+
+
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"
+ >
+
+
+ {t("settings.appearance.options.discoverLabel")}
+
+
+
+
+
+ {t("settings.appearance.options.hover")}
+
+
+ {t("settings.appearance.options.hoverDescription")}
+
+
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"
+ >
+
+
+ {t("settings.appearance.options.hoverLabel")}
+
+
+
+
+
+ {/* Second Column - Themes */}
+
+
+ {availableThemes.map((v) => (
+ props.setTheme(v.id)}
+ />
+ ))}
+
+
);
diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx
index 5827c48d..f8f1c0e9 100644
--- a/src/pages/parts/settings/PreferencesPart.tsx
+++ b/src/pages/parts/settings/PreferencesPart.tsx
@@ -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: {
-
- {/* Show Discover Preference */}
-
-
- {t("settings.preferences.discover")}
-
-
- {t("settings.preferences.discoverDescription")}
-
-
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"
- >
-
-
- {t("settings.preferences.discoverLabel")}
-
-
-
{/* Column */}
diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx
index bc66386d..dea61417 100644
--- a/src/stores/preferences/index.tsx
+++ b/src/stores/preferences/index.tsx
@@ -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;
diff --git a/src/utils/mediaTypes.ts b/src/utils/mediaTypes.ts
index c81b1ac0..5826c54f 100644
--- a/src/utils/mediaTypes.ts
+++ b/src/utils/mediaTypes.ts
@@ -5,4 +5,6 @@ export interface MediaItem {
release_date?: Date;
poster?: string;
type: "show" | "movie";
+ onHoverInfoEnter?: () => void;
+ onHoverInfoLeave?: () => void;
}