add compact episodes view

This commit is contained in:
Pas 2025-06-11 19:43:52 -06:00
parent 7c89b2ffc1
commit d6f132b4c9
7 changed files with 117 additions and 9 deletions

View file

@ -792,7 +792,10 @@
"logosLabel": "Image logos", "logosLabel": "Image logos",
"carouselView": "Carousel view", "carouselView": "Carousel view",
"carouselViewDescription": "Display your currently watching and bookmark sections as carousels instead of a grid. Disabled by default.", "carouselViewDescription": "Display your currently watching and bookmark sections as carousels instead of a grid. Disabled by default.",
"carouselViewLabel": "Carousel view" "carouselViewLabel": "Carousel view",
"forceCompactEpisodeView": "Force compact episode view",
"forceCompactEpisodeViewDescription": "Force the episode carousel in the player to use the \"classic\" compact vertical view. Disabled by default.",
"forceCompactEpisodeViewLabel": "Compact episodes"
} }
}, },
"connections": { "connections": {

View file

@ -18,6 +18,7 @@ export interface SettingsInput {
enableDetailsModal?: boolean; enableDetailsModal?: boolean;
enableImageLogos?: boolean; enableImageLogos?: boolean;
enableCarouselView?: boolean; enableCarouselView?: boolean;
forceCompactEpisodeView?: boolean;
sourceOrder?: string[]; sourceOrder?: string[];
enableSourceOrder?: boolean; enableSourceOrder?: boolean;
proxyTmdb?: boolean; proxyTmdb?: boolean;

View file

@ -17,6 +17,7 @@ import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { PlayerMeta } from "@/stores/player/slices/source"; import { PlayerMeta } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress"; import { useProgressStore } from "@/stores/progress";
import { hasAired } from "../utils/aired"; import { hasAired } from "../utils/aired";
@ -122,6 +123,9 @@ export function EpisodesView({
const descriptionRefs = useRef<{ const descriptionRefs = useRef<{
[key: string]: HTMLParagraphElement | null; [key: string]: HTMLParagraphElement | null;
}>({}); }>({});
const forceCompactEpisodeView = usePreferencesStore(
(s) => s.forceCompactEpisodeView,
);
const isTextTruncated = (element: HTMLElement | null) => { const isTextTruncated = (element: HTMLElement | null) => {
if (!element) return false; if (!element) return false;
@ -248,7 +252,12 @@ export function EpisodesView({
content = ( content = (
<div className="relative"> <div className="relative">
{/* Horizontal scroll buttons */} {/* Horizontal scroll buttons */}
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block"> <div
className={classNames(
"absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
)}
>
<button <button
type="button" type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
@ -261,8 +270,14 @@ export function EpisodesView({
<div <div
ref={carouselRef} ref={carouselRef}
className={classNames( className={classNames(
"flex flex-col lg:flex-row lg:overflow-x-auto space-y-3 sm:space-y-4 lg:space-y-0 lg:space-x-4 pb-4 pt-2 lg:px-12 scrollbar-hide", "flex pb-4 pt-2 scrollbar-hide",
{ "carousel-container": window.innerWidth >= 1024 }, {
"carousel-container":
window.innerWidth >= 1024 && !forceCompactEpisodeView,
},
forceCompactEpisodeView
? "flex-col space-y-3"
: "flex-col lg:flex-row lg:overflow-x-auto space-y-3 sm:space-y-4 lg:space-y-0 lg:space-x-4 lg:px-12 ",
)} )}
style={{ style={{
scrollbarWidth: "none", scrollbarWidth: "none",
@ -289,7 +304,12 @@ export function EpisodesView({
return ( return (
<div key={ep.id} ref={isActive ? activeEpisodeRef : null}> <div key={ep.id} ref={isActive ? activeEpisodeRef : null}>
{/* Extra small screens - Simple vertical list with no thumbnails */} {/* Extra small screens - Simple vertical list with no thumbnails */}
<div className="block sm:hidden w-full px-3"> <div
className={classNames(
"block w-full px-3",
forceCompactEpisodeView ? "" : "sm:hidden",
)}
>
<Menu.Link <Menu.Link
onClick={() => playEpisode(ep.id)} onClick={() => playEpisode(ep.id)}
active={isActive} active={isActive}
@ -327,6 +347,7 @@ export function EpisodesView({
onClick={() => playEpisode(ep.id)} onClick={() => playEpisode(ep.id)}
className={classNames( className={classNames(
"hidden sm:flex lg:hidden w-full rounded-lg overflow-hidden transition-all duration-200 relative cursor-pointer", "hidden sm:flex lg:hidden w-full rounded-lg overflow-hidden transition-all duration-200 relative cursor-pointer",
forceCompactEpisodeView ? "!hidden" : "",
isActive isActive
? "bg-video-context-hoverColor/50" ? "bg-video-context-hoverColor/50"
: "hover:bg-video-context-hoverColor/50", : "hover:bg-video-context-hoverColor/50",
@ -430,6 +451,7 @@ export function EpisodesView({
onClick={() => playEpisode(ep.id)} onClick={() => playEpisode(ep.id)}
className={classNames( className={classNames(
"hidden lg:block flex-shrink-0 transition-all duration-200 relative cursor-pointer rounded-lg overflow-hidden", "hidden lg:block flex-shrink-0 transition-all duration-200 relative cursor-pointer rounded-lg overflow-hidden",
forceCompactEpisodeView ? "!hidden" : "",
isActive isActive
? "bg-video-context-hoverColor/50" ? "bg-video-context-hoverColor/50"
: "hover:bg-video-context-hoverColor/50", : "hover:bg-video-context-hoverColor/50",
@ -547,7 +569,12 @@ export function EpisodesView({
</div> </div>
{/* Right scroll button */} {/* Right scroll button */}
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block"> <div
className={classNames(
"absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
)}
>
<button <button
type="button" type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
@ -597,13 +624,25 @@ function EpisodesOverlay({
[router], [router],
); );
const forceCompactEpisodeView = usePreferencesStore(
(s) => s.forceCompactEpisodeView,
);
return ( return (
<Overlay id={id}> <Overlay id={id}>
<OverlayRouter id={id}> <OverlayRouter id={id}>
<OverlayPage id={id} path="/" width={343} height={431}> <OverlayPage id={id} path="/" width={343} height={431}>
<SeasonsView setSeason={setSeason} selectedSeason={selectedSeason} /> <SeasonsView setSeason={setSeason} selectedSeason={selectedSeason} />
</OverlayPage> </OverlayPage>
<OverlayPage id={id} path="/episodes" width={0} height={375} fullWidth> <OverlayPage
id={id}
path="/episodes"
width={343}
height={
forceCompactEpisodeView || window.innerWidth < 1024 ? 431 : 375
}
fullWidth={!forceCompactEpisodeView}
>
{selectedSeason.length > 0 ? ( {selectedSeason.length > 0 ? (
<EpisodesView <EpisodesView
selectedSeason={selectedSeason} selectedSeason={selectedSeason}

View file

@ -63,6 +63,7 @@ export function useSettingsState(
enableSkipCredits: boolean, enableSkipCredits: boolean,
enableImageLogos: boolean, enableImageLogos: boolean,
enableCarouselView: boolean, enableCarouselView: boolean,
forceCompactEpisodeView: boolean,
) { ) {
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
useDerived(proxyUrls); useDerived(proxyUrls);
@ -160,6 +161,12 @@ export function useSettingsState(
resetEnableCarouselView, resetEnableCarouselView,
enableCarouselViewChanged, enableCarouselViewChanged,
] = useDerived(enableCarouselView); ] = useDerived(enableCarouselView);
const [
forceCompactEpisodeViewState,
setForceCompactEpisodeViewState,
resetForceCompactEpisodeView,
forceCompactEpisodeViewChanged,
] = useDerived(forceCompactEpisodeView);
function reset() { function reset() {
resetTheme(); resetTheme();
@ -183,6 +190,7 @@ export function useSettingsState(
resetEnableSourceOrder(); resetEnableSourceOrder();
resetProxyTmdb(); resetProxyTmdb();
resetEnableCarouselView(); resetEnableCarouselView();
resetForceCompactEpisodeView();
} }
const changed = const changed =
@ -205,7 +213,8 @@ export function useSettingsState(
sourceOrderChanged || sourceOrderChanged ||
enableSourceOrderChanged || enableSourceOrderChanged ||
proxyTmdbChanged || proxyTmdbChanged ||
enableCarouselViewChanged; enableCarouselViewChanged ||
forceCompactEpisodeViewChanged;
return { return {
reset, reset,
@ -310,5 +319,10 @@ export function useSettingsState(
set: setEnableCarouselViewState, set: setEnableCarouselViewState,
changed: enableCarouselViewChanged, changed: enableCarouselViewChanged,
}, },
forceCompactEpisodeView: {
state: forceCompactEpisodeViewState,
set: setForceCompactEpisodeViewState,
changed: forceCompactEpisodeViewChanged,
},
}; };
} }

View file

@ -178,6 +178,13 @@ export function SettingsPage() {
(s) => s.setEnableCarouselView, (s) => s.setEnableCarouselView,
); );
const forceCompactEpisodeView = usePreferencesStore(
(s) => s.forceCompactEpisodeView,
);
const setForceCompactEpisodeView = usePreferencesStore(
(s) => s.setForceCompactEpisodeView,
);
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);
@ -227,6 +234,7 @@ export function SettingsPage() {
enableSkipCredits, enableSkipCredits,
enableImageLogos, enableImageLogos,
enableCarouselView, enableCarouselView,
forceCompactEpisodeView,
); );
const availableSources = useMemo(() => { const availableSources = useMemo(() => {
@ -282,7 +290,8 @@ export function SettingsPage() {
state.sourceOrder.changed || state.sourceOrder.changed ||
state.enableSourceOrder.changed || state.enableSourceOrder.changed ||
state.proxyTmdb.changed || state.proxyTmdb.changed ||
state.enableCarouselView.changed state.enableCarouselView.changed ||
state.forceCompactEpisodeView.changed
) { ) {
await updateSettings(backendUrl, account, { await updateSettings(backendUrl, account, {
applicationLanguage: state.appLanguage.state, applicationLanguage: state.appLanguage.state,
@ -301,6 +310,7 @@ export function SettingsPage() {
enableSourceOrder: state.enableSourceOrder.state, enableSourceOrder: state.enableSourceOrder.state,
proxyTmdb: state.proxyTmdb.state, proxyTmdb: state.proxyTmdb.state,
enableCarouselView: state.enableCarouselView.state, enableCarouselView: state.enableCarouselView.state,
forceCompactEpisodeView: state.forceCompactEpisodeView.state,
}); });
} }
if (state.deviceName.changed) { if (state.deviceName.changed) {
@ -337,6 +347,7 @@ export function SettingsPage() {
setRealDebridKey(state.realDebridKey.state); setRealDebridKey(state.realDebridKey.state);
setProxyTmdb(state.proxyTmdb.state); setProxyTmdb(state.proxyTmdb.state);
setEnableCarouselView(state.enableCarouselView.state); setEnableCarouselView(state.enableCarouselView.state);
setForceCompactEpisodeView(state.forceCompactEpisodeView.state);
if (state.profile.state) { if (state.profile.state) {
updateProfile(state.profile.state); updateProfile(state.profile.state);
@ -378,6 +389,7 @@ export function SettingsPage() {
setEnableSourceOrder, setEnableSourceOrder,
setProxyTmdb, setProxyTmdb,
setEnableCarouselView, setEnableCarouselView,
setForceCompactEpisodeView,
]); ]);
return ( return (
<SubPageLayout> <SubPageLayout>
@ -443,6 +455,8 @@ export function SettingsPage() {
setEnableImageLogos={state.enableImageLogos.set} setEnableImageLogos={state.enableImageLogos.set}
enableCarouselView={state.enableCarouselView.state} enableCarouselView={state.enableCarouselView.state}
setEnableCarouselView={state.enableCarouselView.set} setEnableCarouselView={state.enableCarouselView.set}
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
/> />
</div> </div>
<div id="settings-captions" className="mt-28"> <div id="settings-captions" className="mt-28">

View file

@ -216,6 +216,9 @@ export function AppearancePart(props: {
enableCarouselView: boolean; enableCarouselView: boolean;
setEnableCarouselView: (v: boolean) => void; setEnableCarouselView: (v: boolean) => void;
forceCompactEpisodeView: boolean;
setForceCompactEpisodeView: (v: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -389,6 +392,32 @@ export function AppearancePart(props: {
</p> </p>
</div> </div>
</div> </div>
{/* Force Compact Episode View */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.forceCompactEpisodeView")}
</p>
<p className="max-w-[25rem] font-medium">
{t(
"settings.appearance.options.forceCompactEpisodeViewDescription",
)}
</p>
<div
onClick={() =>
props.setForceCompactEpisodeView(!props.forceCompactEpisodeView)
}
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",
"cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.forceCompactEpisodeView} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.forceCompactEpisodeViewLabel")}
</p>
</div>
</div>
</div> </div>
{/* Second Column - Themes */} {/* Second Column - Themes */}

View file

@ -11,6 +11,7 @@ export interface PreferencesStore {
enableDetailsModal: boolean; enableDetailsModal: boolean;
enableImageLogos: boolean; enableImageLogos: boolean;
enableCarouselView: boolean; enableCarouselView: boolean;
forceCompactEpisodeView: boolean;
sourceOrder: string[]; sourceOrder: string[];
enableSourceOrder: boolean; enableSourceOrder: boolean;
proxyTmdb: boolean; proxyTmdb: boolean;
@ -25,6 +26,7 @@ export interface PreferencesStore {
setEnableDetailsModal(v: boolean): void; setEnableDetailsModal(v: boolean): void;
setEnableImageLogos(v: boolean): void; setEnableImageLogos(v: boolean): void;
setEnableCarouselView(v: boolean): void; setEnableCarouselView(v: boolean): void;
setForceCompactEpisodeView(v: boolean): void;
setSourceOrder(v: string[]): void; setSourceOrder(v: string[]): void;
setEnableSourceOrder(v: boolean): void; setEnableSourceOrder(v: boolean): void;
setProxyTmdb(v: boolean): void; setProxyTmdb(v: boolean): void;
@ -43,6 +45,7 @@ export const usePreferencesStore = create(
enableDetailsModal: false, enableDetailsModal: false,
enableImageLogos: true, enableImageLogos: true,
enableCarouselView: false, enableCarouselView: false,
forceCompactEpisodeView: false,
sourceOrder: [], sourceOrder: [],
enableSourceOrder: false, enableSourceOrder: false,
proxyTmdb: false, proxyTmdb: false,
@ -88,6 +91,11 @@ export const usePreferencesStore = create(
s.enableCarouselView = v; s.enableCarouselView = v;
}); });
}, },
setForceCompactEpisodeView(v) {
set((s) => {
s.forceCompactEpisodeView = v;
});
},
setSourceOrder(v) { setSourceOrder(v) {
set((s) => { set((s) => {
s.sourceOrder = v; s.sourceOrder = v;