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 && ( +
+ {media.title} +
+ )} + +
+
{/* 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; }