mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-29 04:08:44 +00:00
add carousel view for watching and bookmarks
This commit is contained in:
parent
bcfed9b0b8
commit
3cc435332c
11 changed files with 566 additions and 64 deletions
|
|
@ -782,7 +782,10 @@
|
|||
"logos": "Image logos",
|
||||
"logosDescription": "Show image logos instead of text titles in the details modal and featured section. Enabled by default.",
|
||||
"logosNotice": "Most of the time, logos are English only. Other languages might want to disable this!",
|
||||
"logosLabel": "Image logos"
|
||||
"logosLabel": "Image logos",
|
||||
"carouselView": "Carousel view",
|
||||
"carouselViewDescription": "Display your currently watching and bookmark sections as carousels instead of a grid. Disabled by default.",
|
||||
"carouselViewLabel": "Carousel view"
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export function useSettingsState(
|
|||
proxyTmdb: boolean,
|
||||
enableSkipCredits: boolean,
|
||||
enableImageLogos: boolean,
|
||||
enableCarouselView: boolean,
|
||||
) {
|
||||
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
|
||||
useDerived(proxyUrls);
|
||||
|
|
@ -150,6 +151,12 @@ export function useSettingsState(
|
|||
] = useDerived(enableSourceOrder);
|
||||
const [proxyTmdbState, setProxyTmdbState, resetProxyTmdb, proxyTmdbChanged] =
|
||||
useDerived(proxyTmdb);
|
||||
const [
|
||||
enableCarouselViewState,
|
||||
setEnableCarouselViewState,
|
||||
resetEnableCarouselView,
|
||||
enableCarouselViewChanged,
|
||||
] = useDerived(enableCarouselView);
|
||||
|
||||
function reset() {
|
||||
resetTheme();
|
||||
|
|
@ -171,6 +178,7 @@ export function useSettingsState(
|
|||
resetSourceOrder();
|
||||
resetEnableSourceOrder();
|
||||
resetProxyTmdb();
|
||||
resetEnableCarouselView();
|
||||
}
|
||||
|
||||
const changed =
|
||||
|
|
@ -191,7 +199,8 @@ export function useSettingsState(
|
|||
enableImageLogosChanged ||
|
||||
sourceOrderChanged ||
|
||||
enableSourceOrderChanged ||
|
||||
proxyTmdbChanged;
|
||||
proxyTmdbChanged ||
|
||||
enableCarouselViewChanged;
|
||||
|
||||
return {
|
||||
reset,
|
||||
|
|
@ -286,5 +295,10 @@ export function useSettingsState(
|
|||
set: setProxyTmdbState,
|
||||
changed: proxyTmdbChanged,
|
||||
},
|
||||
enableCarouselView: {
|
||||
state: enableCarouselViewState,
|
||||
set: setEnableCarouselViewState,
|
||||
changed: enableCarouselViewChanged,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { To, useNavigate } from "react-router-dom";
|
||||
|
|
@ -13,8 +13,10 @@ import { FeaturedCarousel } from "@/pages/discover/components/FeaturedCarousel";
|
|||
import type { FeaturedMedia } from "@/pages/discover/components/FeaturedCarousel";
|
||||
import DiscoverContent from "@/pages/discover/discoverContent";
|
||||
import { HomeLayout } from "@/pages/layouts/HomeLayout";
|
||||
import { BookmarksCarousel } from "@/pages/parts/home/BookmarksCarousel";
|
||||
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart";
|
||||
import { HeroPart } from "@/pages/parts/home/HeroPart";
|
||||
import { WatchingCarousel } from "@/pages/parts/home/WatchingCarousel";
|
||||
import { WatchingPart } from "@/pages/parts/home/WatchingPart";
|
||||
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
|
||||
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
||||
|
|
@ -64,6 +66,11 @@ export function HomePage() {
|
|||
const detailsModal = useModal("details");
|
||||
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
|
||||
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
const enableCarouselView = usePreferencesStore(
|
||||
(state) => state.enableCarouselView,
|
||||
);
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
const handleClick = (path: To) => {
|
||||
window.scrollTo(0, 0);
|
||||
|
|
@ -218,24 +225,50 @@ export function HomePage() {
|
|||
)}
|
||||
{conf().SHOW_AD ? <AdsPart /> : null}
|
||||
</div>
|
||||
<WideContainer>
|
||||
<WideContainer ultraWide={enableCarouselView}>
|
||||
{s.loading ? (
|
||||
<SearchLoadingPart />
|
||||
) : s.searching ? (
|
||||
<SearchListPart
|
||||
searchQuery={search}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
enableCarouselView ? (
|
||||
<WideContainer>
|
||||
<SearchListPart
|
||||
searchQuery={search}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</WideContainer>
|
||||
) : (
|
||||
<SearchListPart
|
||||
searchQuery={search}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
<WatchingPart
|
||||
onItemsChange={setShowWatching}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
<BookmarksPart
|
||||
onItemsChange={setShowBookmarks}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
{enableCarouselView ? (
|
||||
<>
|
||||
<WatchingCarousel
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
<BookmarksCarousel
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WatchingPart
|
||||
onItemsChange={setShowWatching}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
<BookmarksPart
|
||||
onItemsChange={setShowBookmarks}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!(showBookmarks || showWatching) && !enableDiscover ? (
|
||||
|
|
@ -252,25 +285,23 @@ export function HomePage() {
|
|||
<div className="pb-20" />
|
||||
))}
|
||||
{/* there... perfect. */}
|
||||
</WideContainer>
|
||||
{enableDiscover && !search ? (
|
||||
<div className="w-full max-w-[100dvw] justify-center items-center">
|
||||
{enableDiscover && !search ? (
|
||||
<DiscoverContent />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center items-center h-40 space-y-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{!search && (
|
||||
<Button
|
||||
className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center"
|
||||
onClick={() => handleClick("/discover")}
|
||||
>
|
||||
{t("home.search.discover")}
|
||||
</Button>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex flex-col justify-center items-center h-40 space-y-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{!search && (
|
||||
<Button
|
||||
className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center"
|
||||
onClick={() => handleClick("/discover")}
|
||||
>
|
||||
{t("home.search.discover")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</WideContainer>
|
||||
|
||||
{detailsData && <DetailsModal id="details" data={detailsData} />}
|
||||
</HomeLayout>
|
||||
|
|
|
|||
|
|
@ -170,6 +170,11 @@ export function SettingsPage() {
|
|||
const proxyTmdb = usePreferencesStore((s) => s.proxyTmdb);
|
||||
const setProxyTmdb = usePreferencesStore((s) => s.setProxyTmdb);
|
||||
|
||||
const enableCarouselView = usePreferencesStore((s) => s.enableCarouselView);
|
||||
const setEnableCarouselView = usePreferencesStore(
|
||||
(s) => s.setEnableCarouselView,
|
||||
);
|
||||
|
||||
const account = useAuthStore((s) => s.account);
|
||||
const updateProfile = useAuthStore((s) => s.setAccountProfile);
|
||||
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
|
||||
|
|
@ -214,6 +219,7 @@ export function SettingsPage() {
|
|||
proxyTmdb,
|
||||
enableSkipCredits,
|
||||
enableImageLogos,
|
||||
enableCarouselView,
|
||||
);
|
||||
|
||||
const availableSources = useMemo(() => {
|
||||
|
|
@ -298,6 +304,7 @@ export function SettingsPage() {
|
|||
setEnableSourceOrder(state.enableSourceOrder.state);
|
||||
setFebboxToken(state.febboxToken.state);
|
||||
setProxyTmdb(state.proxyTmdb.state);
|
||||
setEnableCarouselView(state.enableCarouselView.state);
|
||||
|
||||
if (state.profile.state) {
|
||||
updateProfile(state.profile.state);
|
||||
|
|
@ -337,6 +344,7 @@ export function SettingsPage() {
|
|||
setBackendUrl,
|
||||
setEnableSourceOrder,
|
||||
setProxyTmdb,
|
||||
setEnableCarouselView,
|
||||
]);
|
||||
return (
|
||||
<SubPageLayout>
|
||||
|
|
@ -400,6 +408,8 @@ export function SettingsPage() {
|
|||
setEnableDetailsModal={state.enableDetailsModal.set}
|
||||
enableImageLogos={state.enableImageLogos.state}
|
||||
setEnableImageLogos={state.enableImageLogos.set}
|
||||
enableCarouselView={state.enableCarouselView.state}
|
||||
setEnableCarouselView={state.enableCarouselView.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-captions" className="mt-28">
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function Discover() {
|
|||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="relative z-20">
|
||||
<div className="relative z-20 px-10">
|
||||
<DiscoverContent />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -120,11 +120,11 @@ export function FeaturedCarousel({
|
|||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
|
||||
const SLIDE_QUANTITY = 10;
|
||||
const FETCH_QUANTITY = 20;
|
||||
const SLIDE_QUANTITY_EDITOR_PICKS_MOVIES = 6;
|
||||
const SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS = 4;
|
||||
const SLIDE_DURATION = 8000;
|
||||
|
||||
// Fetch featured media
|
||||
useEffect(() => {
|
||||
const fetchFeaturedMedia = async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -138,41 +138,65 @@ export function FeaturedCarousel({
|
|||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
});
|
||||
setMedia(
|
||||
data.results.slice(0, SLIDE_QUANTITY).map((movie: any) => ({
|
||||
// Fetch movie items and randomly select
|
||||
const allMovies = data.results
|
||||
.slice(0, FETCH_QUANTITY)
|
||||
.map((movie: any) => ({
|
||||
...movie,
|
||||
type: "movie" as const,
|
||||
})),
|
||||
);
|
||||
}));
|
||||
// Shuffle
|
||||
const shuffledMovies = [...allMovies].sort(() => 0.5 - Math.random());
|
||||
setMedia(shuffledMovies.slice(0, SLIDE_QUANTITY));
|
||||
} else if (effectiveCategory === "tvshows") {
|
||||
const data = await get<any>("/tv/popular", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
});
|
||||
setMedia(
|
||||
data.results.slice(0, SLIDE_QUANTITY).map((show: any) => ({
|
||||
// Fetch show items
|
||||
const allShows = data.results
|
||||
.slice(0, FETCH_QUANTITY)
|
||||
.map((show: any) => ({
|
||||
...show,
|
||||
type: "show" as const,
|
||||
})),
|
||||
);
|
||||
}));
|
||||
// Shuffle
|
||||
const shuffledShows = [...allShows].sort(() => 0.5 - Math.random());
|
||||
setMedia(shuffledShows.slice(0, SLIDE_QUANTITY));
|
||||
} else if (effectiveCategory === "editorpicks") {
|
||||
// Fetch editor picks movies
|
||||
const moviePromises = EDITOR_PICKS_MOVIES.slice(
|
||||
0,
|
||||
SLIDE_QUANTITY_EDITOR_PICKS_MOVIES,
|
||||
).map((item) =>
|
||||
get<any>(`/movie/${item.id}`, {
|
||||
// Shuffle editor picks Ids
|
||||
const allMovieIds = EDITOR_PICKS_MOVIES.map((item) => ({
|
||||
id: item.id,
|
||||
type: "movie" as const,
|
||||
}));
|
||||
const allShowIds = EDITOR_PICKS_TV_SHOWS.map((item) => ({
|
||||
id: item.id,
|
||||
type: "show" as const,
|
||||
}));
|
||||
|
||||
// Combine and shuffle
|
||||
const combinedIds = [...allMovieIds, ...allShowIds].sort(
|
||||
() => 0.5 - Math.random(),
|
||||
);
|
||||
|
||||
// Select the quantity
|
||||
const selectedMovieIds = combinedIds
|
||||
.filter((item) => item.type === "movie")
|
||||
.slice(0, SLIDE_QUANTITY_EDITOR_PICKS_MOVIES);
|
||||
const selectedShowIds = combinedIds
|
||||
.filter((item) => item.type === "show")
|
||||
.slice(0, SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS);
|
||||
|
||||
// Fetch items
|
||||
const moviePromises = selectedMovieIds.map(({ id }) =>
|
||||
get<any>(`/movie/${id}`, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch editor picks TV shows
|
||||
const showPromises = EDITOR_PICKS_TV_SHOWS.slice(
|
||||
0,
|
||||
SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS,
|
||||
).map((item) =>
|
||||
get<any>(`/tv/${item.id}`, {
|
||||
const showPromises = selectedShowIds.map(({ id }) =>
|
||||
get<any>(`/tv/${id}`, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
}),
|
||||
|
|
@ -192,11 +216,7 @@ export function FeaturedCarousel({
|
|||
type: "show" as const,
|
||||
}));
|
||||
|
||||
// Combine and shuffle
|
||||
const combined = [...movies, ...shows].sort(
|
||||
() => 0.5 - Math.random(),
|
||||
);
|
||||
setMedia(combined);
|
||||
setMedia([...movies, ...shows]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching featured media:", error);
|
||||
|
|
@ -260,7 +280,7 @@ export function FeaturedCarousel({
|
|||
setTouchEnd(null);
|
||||
};
|
||||
|
||||
// Fetch logo when current media changes
|
||||
// Fetch clear logo when current media changes
|
||||
useEffect(() => {
|
||||
const fetchLogo = async () => {
|
||||
// Cancel any in-progress logo fetch
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { DetailsModal } from "@/components/overlays/DetailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
|
|
@ -704,8 +705,8 @@ export function DiscoverContent() {
|
|||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
{/* Content Section with Lazy Loading Tabs */}
|
||||
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
|
||||
|
||||
<WideContainer ultraWide classNames="!px-0">
|
||||
{/* Movies Tab */}
|
||||
<LazyTabContent isActive={isMoviesTab}>
|
||||
{renderMoviesContent()}
|
||||
|
|
@ -720,7 +721,7 @@ export function DiscoverContent() {
|
|||
<LazyTabContent isActive={isEditorPicksTab}>
|
||||
{renderEditorPicksContent()}
|
||||
</LazyTabContent>
|
||||
</div>
|
||||
</WideContainer>
|
||||
|
||||
<ScrollToTopButton />
|
||||
|
||||
|
|
|
|||
198
src/pages/parts/home/BookmarksCarousel.tsx
Normal file
198
src/pages/parts/home/BookmarksCarousel.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
interface BookmarksCarouselProps {
|
||||
isMobile: boolean;
|
||||
carouselRefs: React.MutableRefObject<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}
|
||||
|
||||
const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
||||
|
||||
function MediaCardSkeleton() {
|
||||
return (
|
||||
<div className="relative mt-4 group cursor-default user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="w-full aspect-[2/3] bg-mediaCard-hoverBackground rounded-lg" />
|
||||
<div className="mt-2 h-4 bg-mediaCard-hoverBackground rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookmarksCarousel({
|
||||
isMobile,
|
||||
carouselRefs,
|
||||
onShowDetails,
|
||||
}: BookmarksCarouselProps) {
|
||||
const { t } = useTranslation();
|
||||
const browser = !!window.chrome;
|
||||
let isScrolling = false;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const bookmarksLength = useBookmarkStore(
|
||||
(state) => Object.keys(state.bookmarks).length,
|
||||
);
|
||||
|
||||
const progressItems = useProgressStore((state) => state.items);
|
||||
const bookmarks = useBookmarkStore((state) => state.bookmarks);
|
||||
|
||||
const items = useMemo(() => {
|
||||
let output: MediaItem[] = [];
|
||||
Object.entries(bookmarks).forEach((entry) => {
|
||||
output.push({
|
||||
id: entry[0],
|
||||
...entry[1],
|
||||
});
|
||||
});
|
||||
output = output.sort((a, b) => {
|
||||
const bookmarkA = bookmarks[a.id];
|
||||
const bookmarkB = bookmarks[b.id];
|
||||
const progressA = progressItems[a.id];
|
||||
const progressB = progressItems[b.id];
|
||||
|
||||
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
|
||||
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
|
||||
|
||||
return dateB - dateA;
|
||||
});
|
||||
return output;
|
||||
}, [bookmarks, progressItems]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (isScrolling) return;
|
||||
isScrolling = true;
|
||||
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
setTimeout(() => {
|
||||
isScrolling = false;
|
||||
}, 345);
|
||||
} else {
|
||||
isScrolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLongPress = () => {
|
||||
// Find the button by ID and simulate a click
|
||||
const editButton = document.getElementById("edit-button-bookmark");
|
||||
if (editButton) {
|
||||
(editButton as HTMLButtonElement).click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // Prevent default touch action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (pressTimerRef.current) {
|
||||
clearTimeout(pressTimerRef.current);
|
||||
pressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (pressTimerRef.current) {
|
||||
clearTimeout(pressTimerRef.current);
|
||||
pressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const categorySlug = "bookmarks";
|
||||
const SKELETON_COUNT = 10;
|
||||
|
||||
if (bookmarksLength === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
|
||||
icon={Icons.BOOKMARK}
|
||||
className="ml-2 md:ml-8 mt-2 -mb-10"
|
||||
>
|
||||
<div className="mr-6">
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-bookmark"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
||||
{items.length > 0
|
||||
? items.map((media) => (
|
||||
<div
|
||||
key={media.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
|
||||
>
|
||||
<WatchedMediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onShowDetails={onShowDetails}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(media.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: Array.from({ length: SKELETON_COUNT }).map(() => (
|
||||
<MediaCardSkeleton
|
||||
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<CarouselNavButtons
|
||||
categorySlug={categorySlug}
|
||||
carouselRefs={carouselRefs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
src/pages/parts/home/WatchingCarousel.tsx
Normal file
190
src/pages/parts/home/WatchingCarousel.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
import { shouldShowProgress } from "@/stores/progress/utils";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
interface WatchingCarouselProps {
|
||||
isMobile: boolean;
|
||||
carouselRefs: React.MutableRefObject<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}
|
||||
|
||||
const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
||||
|
||||
function MediaCardSkeleton() {
|
||||
return (
|
||||
<div className="relative mt-4 group cursor-default user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="w-full aspect-[2/3] bg-mediaCard-hoverBackground rounded-lg" />
|
||||
<div className="mt-2 h-4 bg-mediaCard-hoverBackground rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WatchingCarousel({
|
||||
isMobile,
|
||||
carouselRefs,
|
||||
onShowDetails,
|
||||
}: WatchingCarouselProps) {
|
||||
const { t } = useTranslation();
|
||||
const browser = !!window.chrome;
|
||||
let isScrolling = false;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const removeItem = useProgressStore((s) => s.removeItem);
|
||||
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const itemsLength = useProgressStore((state) => {
|
||||
return Object.entries(state.items).filter(
|
||||
(entry) => shouldShowProgress(entry[1]).show,
|
||||
).length;
|
||||
});
|
||||
|
||||
const progressItems = useProgressStore((state) => state.items);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const output: MediaItem[] = [];
|
||||
Object.entries(progressItems)
|
||||
.filter((entry) => shouldShowProgress(entry[1]).show)
|
||||
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
.forEach((entry) => {
|
||||
output.push({
|
||||
id: entry[0],
|
||||
...entry[1],
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [progressItems]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (isScrolling) return;
|
||||
isScrolling = true;
|
||||
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
setTimeout(() => {
|
||||
isScrolling = false;
|
||||
}, 345);
|
||||
} else {
|
||||
isScrolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
const categorySlug = "continue-watching";
|
||||
const SKELETON_COUNT = 10;
|
||||
|
||||
const handleLongPress = () => {
|
||||
// Find the button by ID and simulate a click
|
||||
const editButton = document.getElementById("edit-button-watching");
|
||||
if (editButton) {
|
||||
(editButton as HTMLButtonElement).click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // Prevent default touch action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (pressTimerRef.current) {
|
||||
clearTimeout(pressTimerRef.current);
|
||||
pressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (pressTimerRef.current) {
|
||||
clearTimeout(pressTimerRef.current);
|
||||
pressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (itemsLength === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title={t("home.continueWatching.sectionTitle")}
|
||||
icon={Icons.CLOCK}
|
||||
className="ml-2 md:ml-8 mt-2 -mb-10"
|
||||
>
|
||||
<div className="mr-6">
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-watching"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
||||
{items.length > 0
|
||||
? items.map((media) => (
|
||||
<div
|
||||
key={media.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
|
||||
>
|
||||
<WatchedMediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onShowDetails={onShowDetails}
|
||||
closable={editing}
|
||||
onClose={() => removeItem(media.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: Array.from({ length: SKELETON_COUNT }).map(() => (
|
||||
<MediaCardSkeleton
|
||||
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<CarouselNavButtons
|
||||
categorySlug={categorySlug}
|
||||
carouselRefs={carouselRefs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -213,6 +213,9 @@ export function AppearancePart(props: {
|
|||
|
||||
enableImageLogos: boolean;
|
||||
setEnableImageLogos: (v: boolean) => void;
|
||||
|
||||
enableCarouselView: boolean;
|
||||
setEnableCarouselView: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -362,6 +365,30 @@ export function AppearancePart(props: {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel View */}
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.appearance.options.carouselView")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.appearance.options.carouselViewDescription")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() =>
|
||||
props.setEnableCarouselView(!props.enableCarouselView)
|
||||
}
|
||||
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.enableCarouselView} />
|
||||
<p className="flex-1 text-white font-bold">
|
||||
{t("settings.appearance.options.carouselViewLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second Column - Themes */}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface PreferencesStore {
|
|||
enableFeatured: boolean;
|
||||
enableDetailsModal: boolean;
|
||||
enableImageLogos: boolean;
|
||||
enableCarouselView: boolean;
|
||||
sourceOrder: string[];
|
||||
enableSourceOrder: boolean;
|
||||
proxyTmdb: boolean;
|
||||
|
|
@ -21,6 +22,7 @@ export interface PreferencesStore {
|
|||
setEnableFeatured(v: boolean): void;
|
||||
setEnableDetailsModal(v: boolean): void;
|
||||
setEnableImageLogos(v: boolean): void;
|
||||
setEnableCarouselView(v: boolean): void;
|
||||
setSourceOrder(v: string[]): void;
|
||||
setEnableSourceOrder(v: boolean): void;
|
||||
setProxyTmdb(v: boolean): void;
|
||||
|
|
@ -36,6 +38,7 @@ export const usePreferencesStore = create(
|
|||
enableFeatured: true, // enabled for testing
|
||||
enableDetailsModal: false,
|
||||
enableImageLogos: true,
|
||||
enableCarouselView: true, // enabled for testing
|
||||
sourceOrder: [],
|
||||
enableSourceOrder: false,
|
||||
proxyTmdb: false,
|
||||
|
|
@ -74,6 +77,11 @@ export const usePreferencesStore = create(
|
|||
s.enableImageLogos = v;
|
||||
});
|
||||
},
|
||||
setEnableCarouselView(v) {
|
||||
set((s) => {
|
||||
s.enableCarouselView = v;
|
||||
});
|
||||
},
|
||||
setSourceOrder(v) {
|
||||
set((s) => {
|
||||
s.sourceOrder = v;
|
||||
|
|
|
|||
Loading…
Reference in a new issue