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",
"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",
"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": {
"watching": "Currently Watching",
@ -1442,8 +1445,7 @@
"genreMovies": "{{genre}} Movies",
"genreShows": "{{genre}} Shows",
"categoryMovies": "{{category}} Movies",
"categoryShows": "{{category}} Shows",
"top10": "Top 10"
"categoryShows": "{{category}} Shows"
},
"change": "Change",
"more": "View more"

View file

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

View file

@ -47,6 +47,7 @@ export function formatTMDBMetaResult(
object_type: mediaTypeToTMDB(type),
poster: getMediaPoster(movie.poster_path) ?? undefined,
original_release_date: new Date(movie.release_date),
overview: movie.overview || undefined,
};
}
if (type === MWMediaType.SERIES) {
@ -62,6 +63,7 @@ export function formatTMDBMetaResult(
})),
poster: getMediaPoster(show.poster_path) ?? undefined,
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(),
poster: media.poster,
type,
overview: media.overview,
seasons: seasons as any,
seasonData: season
? {
@ -408,8 +409,18 @@ export async function getMediaDetails<
const item = seasonsQueue.shift();
if (!item) break;
const { season, index } = item;
const episodes = await getSeasonDetails(id, season.season_number);
allEpisodesBySeason[index] = episodes;
const seasonData = await get<TMDBSeason>(
`/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;
year?: string;
poster?: string;
overview?: string;
};
type MWMediaMetaSpecific =

View file

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

View file

@ -36,17 +36,20 @@ export function usePlayerMeta() {
poster: m.meta.poster,
tmdbId: m.tmdbId ?? "",
imdbId: m.imdbId,
overview: m.meta.overview,
episodes: m.meta.seasonData.episodes.map((v) => ({
number: v.number,
title: v.title,
tmdbId: v.id,
air_date: v.air_date,
overview: v.overview,
})),
episode: {
number: ep.number,
title: ep.title,
tmdbId: ep.id,
air_date: ep.air_date,
overview: ep.overview,
},
season: {
number: m.meta.seasonData.number,
@ -62,6 +65,7 @@ export function usePlayerMeta() {
poster: m.meta.poster,
tmdbId: m.tmdbId ?? "",
imdbId: m.imdbId,
overview: m.meta.overview,
};
}
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,
enableDoubleClickToSeek: boolean,
enableAutoResumeOnPlaybackError: boolean,
enablePauseOverlay: boolean,
customTheme: {
primary: string;
secondary: string;
@ -277,6 +278,12 @@ export function useSettingsState(
resetEnableAutoResumeOnPlaybackError,
enableAutoResumeOnPlaybackErrorChanged,
] = useDerived(enableAutoResumeOnPlaybackError);
const [
enablePauseOverlayState,
setEnablePauseOverlayState,
resetEnablePauseOverlay,
enablePauseOverlayChanged,
] = useDerived(enablePauseOverlay);
const [
customThemeState,
setCustomThemeState,
@ -323,6 +330,7 @@ export function useSettingsState(
resetManualSourceSelection();
resetEnableDoubleClickToSeek();
resetEnableAutoResumeOnPlaybackError();
resetEnablePauseOverlay();
resetCustomTheme();
}
@ -364,6 +372,7 @@ export function useSettingsState(
manualSourceSelectionChanged ||
enableDoubleClickToSeekChanged ||
enableAutoResumeOnPlaybackErrorChanged ||
enablePauseOverlayChanged ||
customThemeChanged;
return {
@ -554,6 +563,11 @@ export function useSettingsState(
set: setEnableAutoResumeOnPlaybackErrorState,
changed: enableAutoResumeOnPlaybackErrorChanged,
},
enablePauseOverlay: {
state: enablePauseOverlayState,
set: setEnablePauseOverlayState,
changed: enablePauseOverlayChanged,
},
customTheme: {
state: customThemeState,
set: (v: { primary: string; secondary: string; tertiary: string }) => {

View file

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

View file

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

View file

@ -292,6 +292,9 @@ export function AppearancePart(props: {
enableImageLogos: boolean;
setEnableImageLogos: (v: boolean) => void;
enablePauseOverlay: boolean;
setEnablePauseOverlay: (v: boolean) => void;
enableCarouselView: boolean;
setEnableCarouselView: (v: boolean) => void;
@ -355,6 +358,7 @@ export function AppearancePart(props: {
setEnableFeatured,
setEnableDetailsModal,
setEnableImageLogos,
setEnablePauseOverlay,
setForceCompactEpisodeView,
} = props;
@ -365,6 +369,7 @@ export function AppearancePart(props: {
setEnableFeatured(false);
setEnableDetailsModal(false);
setEnableImageLogos(false);
setEnablePauseOverlay(false);
setForceCompactEpisodeView(true);
}
}, [
@ -373,6 +378,7 @@ export function AppearancePart(props: {
setEnableFeatured,
setEnableDetailsModal,
setEnableImageLogos,
setEnablePauseOverlay,
setForceCompactEpisodeView,
]);
@ -553,6 +559,33 @@ export function AppearancePart(props: {
</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 */}
<div>
<p className="text-white font-bold mb-3">

View file

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

View file

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