add pause overlay

This commit is contained in:
Pas 2026-02-20 18:30:19 -07:00
parent a128a2cb19
commit 887dfa2ad5
14 changed files with 190 additions and 5 deletions

View file

@ -1257,7 +1257,10 @@
"homeSectionOrder": "Home section order", "homeSectionOrder": "Home section order",
"homeSectionOrderDescription": "Drag and drop to reorder the watching and bookmarks sections on your homepage. Group order can be editied from the home page.", "homeSectionOrderDescription": "Drag and drop to reorder the watching and bookmarks sections on your homepage. Group order can be editied from the home page.",
"forceCompactEpisodeViewLabel": "Compact episodes", "forceCompactEpisodeViewLabel": "Compact episodes",
"homeSectionOrderGroups": "Reorder bookmark groups" "homeSectionOrderGroups": "Reorder bookmark groups",
"pauseOverlay": "Pause overlay",
"pauseOverlayDescription": "Show a title/logo and description overlay when the player is paused and idle.",
"pauseOverlayLabel": "Pause overlay"
}, },
"sections": { "sections": {
"watching": "Currently Watching", "watching": "Currently Watching",
@ -1442,8 +1445,7 @@
"genreMovies": "{{genre}} Movies", "genreMovies": "{{genre}} Movies",
"genreShows": "{{genre}} Shows", "genreShows": "{{genre}} Shows",
"categoryMovies": "{{category}} Movies", "categoryMovies": "{{category}} Movies",
"categoryShows": "{{category}} Shows", "categoryShows": "{{category}} Shows"
"top10": "Top 10"
}, },
"change": "Change", "change": "Change",
"more": "View more" "more": "View more"

View file

@ -44,6 +44,7 @@ export interface SettingsInput {
manualSourceSelection?: boolean; manualSourceSelection?: boolean;
enableDoubleClickToSeek?: boolean; enableDoubleClickToSeek?: boolean;
enableAutoResumeOnPlaybackError?: boolean; enableAutoResumeOnPlaybackError?: boolean;
enablePauseOverlay?: boolean;
enableNumberKeySeeking?: boolean; enableNumberKeySeeking?: boolean;
keyboardShortcuts?: KeyboardShortcuts; keyboardShortcuts?: KeyboardShortcuts;
customTheme?: CustomThemeSettings; customTheme?: CustomThemeSettings;
@ -83,6 +84,7 @@ export interface SettingsResponse {
manualSourceSelection?: boolean; manualSourceSelection?: boolean;
enableDoubleClickToSeek?: boolean; enableDoubleClickToSeek?: boolean;
enableAutoResumeOnPlaybackError?: boolean; enableAutoResumeOnPlaybackError?: boolean;
enablePauseOverlay?: boolean;
enableNumberKeySeeking?: boolean; enableNumberKeySeeking?: boolean;
keyboardShortcuts?: KeyboardShortcuts; keyboardShortcuts?: KeyboardShortcuts;
customTheme?: CustomThemeSettings; customTheme?: CustomThemeSettings;

View file

@ -47,6 +47,7 @@ export function formatTMDBMetaResult(
object_type: mediaTypeToTMDB(type), object_type: mediaTypeToTMDB(type),
poster: getMediaPoster(movie.poster_path) ?? undefined, poster: getMediaPoster(movie.poster_path) ?? undefined,
original_release_date: new Date(movie.release_date), original_release_date: new Date(movie.release_date),
overview: movie.overview || undefined,
}; };
} }
if (type === MWMediaType.SERIES) { if (type === MWMediaType.SERIES) {
@ -62,6 +63,7 @@ export function formatTMDBMetaResult(
})), })),
poster: getMediaPoster(show.poster_path) ?? undefined, poster: getMediaPoster(show.poster_path) ?? undefined,
original_release_date: new Date(show.first_air_date), original_release_date: new Date(show.first_air_date),
overview: show.overview,
}; };
} }

View file

@ -79,6 +79,7 @@ export function formatTMDBMeta(
year: media.original_release_date?.getFullYear()?.toString(), year: media.original_release_date?.getFullYear()?.toString(),
poster: media.poster, poster: media.poster,
type, type,
overview: media.overview,
seasons: seasons as any, seasons: seasons as any,
seasonData: season seasonData: season
? { ? {
@ -408,8 +409,18 @@ export async function getMediaDetails<
const item = seasonsQueue.shift(); const item = seasonsQueue.shift();
if (!item) break; if (!item) break;
const { season, index } = item; const { season, index } = item;
const episodes = await getSeasonDetails(id, season.season_number); const seasonData = await get<TMDBSeason>(
allEpisodesBySeason[index] = episodes; `/tv/${id}/season/${season.season_number}`,
);
allEpisodesBySeason[index] = seasonData.episodes.map((episode) => ({
id: episode.id,
name: episode.name,
episode_number: episode.episode_number,
overview: episode.overview,
still_path: episode.still_path,
air_date: episode.air_date,
season_number: season.season_number,
}));
} }
}, },
); );

View file

@ -29,6 +29,7 @@ type MWMediaMetaBase = {
id: string; id: string;
year?: string; year?: string;
poster?: string; poster?: string;
overview?: string;
}; };
type MWMediaMetaSpecific = type MWMediaMetaSpecific =

View file

@ -25,6 +25,7 @@ export type TMDBMediaResult = {
original_release_date?: Date; original_release_date?: Date;
object_type: TMDBContentTypes; object_type: TMDBContentTypes;
seasons?: TMDBSeasonShort[]; seasons?: TMDBSeasonShort[];
overview?: string;
}; };
export type TMDBSeasonMetaResult = { export type TMDBSeasonMetaResult = {

View file

@ -36,17 +36,20 @@ export function usePlayerMeta() {
poster: m.meta.poster, poster: m.meta.poster,
tmdbId: m.tmdbId ?? "", tmdbId: m.tmdbId ?? "",
imdbId: m.imdbId, imdbId: m.imdbId,
overview: m.meta.overview,
episodes: m.meta.seasonData.episodes.map((v) => ({ episodes: m.meta.seasonData.episodes.map((v) => ({
number: v.number, number: v.number,
title: v.title, title: v.title,
tmdbId: v.id, tmdbId: v.id,
air_date: v.air_date, air_date: v.air_date,
overview: v.overview,
})), })),
episode: { episode: {
number: ep.number, number: ep.number,
title: ep.title, title: ep.title,
tmdbId: ep.id, tmdbId: ep.id,
air_date: ep.air_date, air_date: ep.air_date,
overview: ep.overview,
}, },
season: { season: {
number: m.meta.seasonData.number, number: m.meta.seasonData.number,
@ -62,6 +65,7 @@ export function usePlayerMeta() {
poster: m.meta.poster, poster: m.meta.poster,
tmdbId: m.tmdbId ?? "", tmdbId: m.tmdbId ?? "",
imdbId: m.imdbId, imdbId: m.imdbId,
overview: m.meta.overview,
}; };
} }
setDirectMeta(playerMeta); setDirectMeta(playerMeta);

View file

@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { useIdle } from "react-use";
import { getMediaLogo } from "@/backend/metadata/tmdb";
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
export function PauseOverlay() {
const isIdle = useIdle(10e3); // 10 seconds
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
const meta = usePlayerStore((s) => s.meta);
const enablePauseOverlay = usePreferencesStore((s) => s.enablePauseOverlay);
const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const shouldShow = isPaused && isIdle && enablePauseOverlay;
useEffect(() => {
let mounted = true;
const fetchLogo = async () => {
if (!meta?.tmdbId || !enableImageLogos) {
setLogoUrl(null);
return;
}
try {
const type =
meta.type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV;
const url = await getMediaLogo(meta.tmdbId, type);
if (mounted) setLogoUrl(url || null);
} catch {
if (mounted) setLogoUrl(null);
}
};
fetchLogo();
return () => {
mounted = false;
};
}, [meta?.tmdbId, meta?.type, enableImageLogos]);
if (!meta) return null;
const overview =
meta.type === "show" ? meta.episode?.overview : meta.overview;
// Don't render anything if we don't have content, but keep structure for fade if valid
const hasContent = overview || logoUrl || meta.title;
if (!hasContent) return null;
return (
<div
className={`absolute inset-0 z-[60] flex items-center bg-black/60 transition-opacity duration-500 ${
shouldShow
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
>
<div className="ml-16 max-w-2xl p-8 pointer-events-auto">
{logoUrl ? (
<img
src={logoUrl}
alt={meta.title}
className="mb-6 max-h-32 object-contain drop-shadow-lg"
/>
) : (
<h1 className="mb-4 text-4xl font-bold text-white drop-shadow-lg">
{meta.title}
</h1>
)}
{meta.type === "show" && meta.episode && (
<h2 className="mb-2 text-2xl font-semibold text-white/90 drop-shadow-md">
{meta.episode.title}
</h2>
)}
{overview && (
<p className="text-lg text-white/80 drop-shadow-md line-clamp-6">
{overview}
</p>
)}
</div>
</div>
);
}

View file

@ -81,6 +81,7 @@ export function useSettingsState(
manualSourceSelection: boolean, manualSourceSelection: boolean,
enableDoubleClickToSeek: boolean, enableDoubleClickToSeek: boolean,
enableAutoResumeOnPlaybackError: boolean, enableAutoResumeOnPlaybackError: boolean,
enablePauseOverlay: boolean,
customTheme: { customTheme: {
primary: string; primary: string;
secondary: string; secondary: string;
@ -277,6 +278,12 @@ export function useSettingsState(
resetEnableAutoResumeOnPlaybackError, resetEnableAutoResumeOnPlaybackError,
enableAutoResumeOnPlaybackErrorChanged, enableAutoResumeOnPlaybackErrorChanged,
] = useDerived(enableAutoResumeOnPlaybackError); ] = useDerived(enableAutoResumeOnPlaybackError);
const [
enablePauseOverlayState,
setEnablePauseOverlayState,
resetEnablePauseOverlay,
enablePauseOverlayChanged,
] = useDerived(enablePauseOverlay);
const [ const [
customThemeState, customThemeState,
setCustomThemeState, setCustomThemeState,
@ -323,6 +330,7 @@ export function useSettingsState(
resetManualSourceSelection(); resetManualSourceSelection();
resetEnableDoubleClickToSeek(); resetEnableDoubleClickToSeek();
resetEnableAutoResumeOnPlaybackError(); resetEnableAutoResumeOnPlaybackError();
resetEnablePauseOverlay();
resetCustomTheme(); resetCustomTheme();
} }
@ -364,6 +372,7 @@ export function useSettingsState(
manualSourceSelectionChanged || manualSourceSelectionChanged ||
enableDoubleClickToSeekChanged || enableDoubleClickToSeekChanged ||
enableAutoResumeOnPlaybackErrorChanged || enableAutoResumeOnPlaybackErrorChanged ||
enablePauseOverlayChanged ||
customThemeChanged; customThemeChanged;
return { return {
@ -554,6 +563,11 @@ export function useSettingsState(
set: setEnableAutoResumeOnPlaybackErrorState, set: setEnableAutoResumeOnPlaybackErrorState,
changed: enableAutoResumeOnPlaybackErrorChanged, changed: enableAutoResumeOnPlaybackErrorChanged,
}, },
enablePauseOverlay: {
state: enablePauseOverlayState,
set: setEnablePauseOverlayState,
changed: enablePauseOverlayChanged,
},
customTheme: { customTheme: {
state: customThemeState, state: customThemeState,
set: (v: { primary: string; secondary: string; tertiary: string }) => { set: (v: { primary: string; secondary: string; tertiary: string }) => {

View file

@ -531,6 +531,11 @@ export function SettingsPage() {
(s) => s.setEnableAutoResumeOnPlaybackError, (s) => s.setEnableAutoResumeOnPlaybackError,
); );
const enablePauseOverlay = usePreferencesStore((s) => s.enablePauseOverlay);
const setEnablePauseOverlay = usePreferencesStore(
(s) => s.setEnablePauseOverlay,
);
const account = useAuthStore((s) => s.account); const account = useAuthStore((s) => s.account);
const updateProfile = useAuthStore((s) => s.setAccountProfile); const updateProfile = useAuthStore((s) => s.setAccountProfile);
const updateDeviceName = useAuthStore((s) => s.updateDeviceName); const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
@ -647,6 +652,9 @@ export function SettingsPage() {
settings.enableAutoResumeOnPlaybackError, settings.enableAutoResumeOnPlaybackError,
); );
} }
if (settings.enablePauseOverlay !== undefined) {
setEnablePauseOverlay(settings.enablePauseOverlay);
}
if (settings.customTheme) { if (settings.customTheme) {
setCustomTheme(settings.customTheme); setCustomTheme(settings.customTheme);
setCustomThemeBaseline(settings.customTheme); setCustomThemeBaseline(settings.customTheme);
@ -687,6 +695,7 @@ export function SettingsPage() {
setManualSourceSelection, setManualSourceSelection,
setEnableDoubleClickToSeek, setEnableDoubleClickToSeek,
setEnableAutoResumeOnPlaybackError, setEnableAutoResumeOnPlaybackError,
setEnablePauseOverlay,
setCustomTheme, setCustomTheme,
]); ]);
@ -728,6 +737,7 @@ export function SettingsPage() {
manualSourceSelection, manualSourceSelection,
enableDoubleClickToSeek, enableDoubleClickToSeek,
enableAutoResumeOnPlaybackError, enableAutoResumeOnPlaybackError,
enablePauseOverlay,
customThemeBaseline ?? customTheme, customThemeBaseline ?? customTheme,
); );
@ -797,6 +807,7 @@ export function SettingsPage() {
state.manualSourceSelection.changed || state.manualSourceSelection.changed ||
state.enableDoubleClickToSeek.changed || state.enableDoubleClickToSeek.changed ||
state.enableAutoResumeOnPlaybackError.changed || state.enableAutoResumeOnPlaybackError.changed ||
state.enablePauseOverlay.changed ||
state.customTheme.changed state.customTheme.changed
) { ) {
await updateSettings(backendUrl, account, { await updateSettings(backendUrl, account, {
@ -829,6 +840,7 @@ export function SettingsPage() {
enableDoubleClickToSeek: state.enableDoubleClickToSeek.state, enableDoubleClickToSeek: state.enableDoubleClickToSeek.state,
enableAutoResumeOnPlaybackError: enableAutoResumeOnPlaybackError:
state.enableAutoResumeOnPlaybackError.state, state.enableAutoResumeOnPlaybackError.state,
enablePauseOverlay: state.enablePauseOverlay.state,
customTheme: state.customTheme.state, customTheme: state.customTheme.state,
}); });
} }
@ -889,6 +901,7 @@ export function SettingsPage() {
setEnableAutoResumeOnPlaybackError( setEnableAutoResumeOnPlaybackError(
state.enableAutoResumeOnPlaybackError.state, state.enableAutoResumeOnPlaybackError.state,
); );
setEnablePauseOverlay(state.enablePauseOverlay.state);
setCustomTheme(state.customTheme.state); setCustomTheme(state.customTheme.state);
setCustomThemeBaseline(state.customTheme.state); setCustomThemeBaseline(state.customTheme.state);
@ -951,6 +964,7 @@ export function SettingsPage() {
setManualSourceSelection, setManualSourceSelection,
setEnableDoubleClickToSeek, setEnableDoubleClickToSeek,
setEnableAutoResumeOnPlaybackError, setEnableAutoResumeOnPlaybackError,
setEnablePauseOverlay,
setCustomTheme, setCustomTheme,
]); ]);
return ( return (
@ -1067,6 +1081,8 @@ export function SettingsPage() {
homeSectionOrder={state.homeSectionOrder.state} homeSectionOrder={state.homeSectionOrder.state}
setHomeSectionOrder={state.homeSectionOrder.set} setHomeSectionOrder={state.homeSectionOrder.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state} enableLowPerformanceMode={state.enableLowPerformanceMode.state}
enablePauseOverlay={state.enablePauseOverlay.state}
setEnablePauseOverlay={state.enablePauseOverlay.set}
customTheme={state.customTheme.state} customTheme={state.customTheme.state}
setCustomTheme={state.customTheme.set} setCustomTheme={state.customTheme.set}
/> />

View file

@ -11,6 +11,7 @@ import {
SegmentData, SegmentData,
useSkipTime, useSkipTime,
} from "@/components/player/hooks/useSkipTime"; } from "@/components/player/hooks/useSkipTime";
import { PauseOverlay } from "@/components/player/overlays/PauseOverlay";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
@ -99,6 +100,7 @@ export function PlayerPart(props: PlayerPartProps) {
return ( return (
<Player.Container onLoad={props.onLoad} showingControls={showTargets}> <Player.Container onLoad={props.onLoad} showingControls={showTargets}>
{props.children} {props.children}
<PauseOverlay />
<Player.BlackOverlay <Player.BlackOverlay
show={showTargets && status === playerStatus.PLAYING} show={showTargets && status === playerStatus.PLAYING}
/> />

View file

@ -292,6 +292,9 @@ export function AppearancePart(props: {
enableImageLogos: boolean; enableImageLogos: boolean;
setEnableImageLogos: (v: boolean) => void; setEnableImageLogos: (v: boolean) => void;
enablePauseOverlay: boolean;
setEnablePauseOverlay: (v: boolean) => void;
enableCarouselView: boolean; enableCarouselView: boolean;
setEnableCarouselView: (v: boolean) => void; setEnableCarouselView: (v: boolean) => void;
@ -355,6 +358,7 @@ export function AppearancePart(props: {
setEnableFeatured, setEnableFeatured,
setEnableDetailsModal, setEnableDetailsModal,
setEnableImageLogos, setEnableImageLogos,
setEnablePauseOverlay,
setForceCompactEpisodeView, setForceCompactEpisodeView,
} = props; } = props;
@ -365,6 +369,7 @@ export function AppearancePart(props: {
setEnableFeatured(false); setEnableFeatured(false);
setEnableDetailsModal(false); setEnableDetailsModal(false);
setEnableImageLogos(false); setEnableImageLogos(false);
setEnablePauseOverlay(false);
setForceCompactEpisodeView(true); setForceCompactEpisodeView(true);
} }
}, [ }, [
@ -373,6 +378,7 @@ export function AppearancePart(props: {
setEnableFeatured, setEnableFeatured,
setEnableDetailsModal, setEnableDetailsModal,
setEnableImageLogos, setEnableImageLogos,
setEnablePauseOverlay,
setForceCompactEpisodeView, setForceCompactEpisodeView,
]); ]);
@ -553,6 +559,33 @@ export function AppearancePart(props: {
</div> </div>
</div> </div>
{/* Pause Overlay */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.pauseOverlay")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.pauseOverlayDescription")}
</p>
<div
onClick={() =>
!props.enableLowPerformanceMode &&
props.setEnablePauseOverlay(!props.enablePauseOverlay)
}
className={classNames(
"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",
props.enableLowPerformanceMode
? "cursor-not-allowed opacity-50 pointer-events-none"
: "cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.enablePauseOverlay} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.pauseOverlayLabel")}
</p>
</div>
</div>
{/* Carousel View */} {/* Carousel View */}
<div> <div>
<p className="text-white font-bold mb-3"> <p className="text-white font-bold mb-3">

View file

@ -29,6 +29,7 @@ export interface PlayerMetaEpisode {
tmdbId: string; tmdbId: string;
title: string; title: string;
air_date?: string; air_date?: string;
overview?: string;
} }
export interface PlayerMeta { export interface PlayerMeta {
@ -38,6 +39,7 @@ export interface PlayerMeta {
imdbId?: string; imdbId?: string;
releaseYear: number; releaseYear: number;
poster?: string; poster?: string;
overview?: string;
episodes?: PlayerMetaEpisode[]; episodes?: PlayerMetaEpisode[];
episode?: PlayerMetaEpisode; episode?: PlayerMetaEpisode;
season?: { season?: {

View file

@ -39,6 +39,7 @@ export interface PreferencesStore {
enableDoubleClickToSeek: boolean; enableDoubleClickToSeek: boolean;
enableAutoResumeOnPlaybackError: boolean; enableAutoResumeOnPlaybackError: boolean;
enableNumberKeySeeking: boolean; enableNumberKeySeeking: boolean;
enablePauseOverlay: boolean;
keyboardShortcuts: KeyboardShortcuts; keyboardShortcuts: KeyboardShortcuts;
setEnableThumbnails(v: boolean): void; setEnableThumbnails(v: boolean): void;
@ -72,6 +73,7 @@ export interface PreferencesStore {
setEnableDoubleClickToSeek(v: boolean): void; setEnableDoubleClickToSeek(v: boolean): void;
setEnableAutoResumeOnPlaybackError(v: boolean): void; setEnableAutoResumeOnPlaybackError(v: boolean): void;
setEnableNumberKeySeeking(v: boolean): void; setEnableNumberKeySeeking(v: boolean): void;
setEnablePauseOverlay(v: boolean): void;
setKeyboardShortcuts(v: KeyboardShortcuts): void; setKeyboardShortcuts(v: KeyboardShortcuts): void;
} }
@ -109,6 +111,7 @@ export const usePreferencesStore = create(
enableDoubleClickToSeek: false, enableDoubleClickToSeek: false,
enableAutoResumeOnPlaybackError: true, enableAutoResumeOnPlaybackError: true,
enableNumberKeySeeking: true, enableNumberKeySeeking: true,
enablePauseOverlay: false,
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
setEnableThumbnails(v) { setEnableThumbnails(v) {
set((s) => { set((s) => {
@ -270,6 +273,11 @@ export const usePreferencesStore = create(
s.enableNumberKeySeeking = v; s.enableNumberKeySeeking = v;
}); });
}, },
setEnablePauseOverlay(v) {
set((s) => {
s.enablePauseOverlay = v;
});
},
setKeyboardShortcuts(v) { setKeyboardShortcuts(v) {
set((s) => { set((s) => {
s.keyboardShortcuts = v; s.keyboardShortcuts = v;