diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 03e9731a..b1f172d7 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -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": { diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index 1595910c..0dbf0c35 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -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, + }, }; } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index ca65cab0..e9819979 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -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 ? : null} - + {s.loading ? ( ) : s.searching ? ( - + enableCarouselView ? ( + + + + ) : ( + + ) ) : (
- - + {enableCarouselView ? ( + <> + + + + ) : ( + <> + + + + )}
)} {!(showBookmarks || showWatching) && !enableDiscover ? ( @@ -252,25 +285,23 @@ export function HomePage() {
))} {/* there... perfect. */} - - {enableDiscover && !search ? ( -
+ {enableDiscover && !search ? ( -
- ) : ( -
-
- {!search && ( - - )} + ) : ( +
+
+ {!search && ( + + )} +
-
- )} + )} + {detailsData && } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c84d6810..e4683e68 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 ( @@ -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} />
diff --git a/src/pages/discover/Discover.tsx b/src/pages/discover/Discover.tsx index 7acbec77..c9c8c4db 100644 --- a/src/pages/discover/Discover.tsx +++ b/src/pages/discover/Discover.tsx @@ -49,7 +49,7 @@ export function Discover() {
{/* Main Content */} -
+
diff --git a/src/pages/discover/components/FeaturedCarousel.tsx b/src/pages/discover/components/FeaturedCarousel.tsx index 16ece2c3..63a8b31a 100644 --- a/src/pages/discover/components/FeaturedCarousel.tsx +++ b/src/pages/discover/components/FeaturedCarousel.tsx @@ -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("/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(`/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(`/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(`/tv/${item.id}`, { + const showPromises = selectedShowIds.map(({ id }) => + get(`/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 diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx index 9aa7056b..d884fc77 100644 --- a/src/pages/discover/discoverContent.tsx +++ b/src/pages/discover/discoverContent.tsx @@ -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 */} -
+ + {/* Movies Tab */} {renderMoviesContent()} @@ -720,7 +721,7 @@ export function DiscoverContent() { {renderEditorPicksContent()} -
+ diff --git a/src/pages/parts/home/BookmarksCarousel.tsx b/src/pages/parts/home/BookmarksCarousel.tsx new file mode 100644 index 00000000..ccf0e1a8 --- /dev/null +++ b/src/pages/parts/home/BookmarksCarousel.tsx @@ -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 ( +
+
+
+
+
+
+ ); +} + +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(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) => { + 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) => { + 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 ( + <> + +
+ +
+
+
+
{ + carouselRefs.current[categorySlug] = el; + }} + onWheel={handleWheel} + > +
+ + {items.length > 0 + ? items.map((media) => ( +
) => + 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" + > + removeBookmark(media.id)} + /> +
+ )) + : Array.from({ length: SKELETON_COUNT }).map(() => ( + + ))} + +
+
+ + {!isMobile && ( + + )} +
+ + ); +} diff --git a/src/pages/parts/home/WatchingCarousel.tsx b/src/pages/parts/home/WatchingCarousel.tsx new file mode 100644 index 00000000..a9540874 --- /dev/null +++ b/src/pages/parts/home/WatchingCarousel.tsx @@ -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 ( +
+
+
+
+
+
+ ); +} + +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(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) => { + 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) => { + 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 ( + <> + +
+ +
+
+
+
{ + carouselRefs.current[categorySlug] = el; + }} + onWheel={handleWheel} + > +
+ + {items.length > 0 + ? items.map((media) => ( +
) => + 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" + > + removeItem(media.id)} + /> +
+ )) + : Array.from({ length: SKELETON_COUNT }).map(() => ( + + ))} + +
+
+ + {!isMobile && ( + + )} +
+ + ); +} diff --git a/src/pages/parts/settings/AppearancePart.tsx b/src/pages/parts/settings/AppearancePart.tsx index ac971242..00dcbe02 100644 --- a/src/pages/parts/settings/AppearancePart.tsx +++ b/src/pages/parts/settings/AppearancePart.tsx @@ -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: {

+ + {/* Carousel View */} +
+

+ {t("settings.appearance.options.carouselView")} +

+

+ {t("settings.appearance.options.carouselViewDescription")} +

+
+ 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", + )} + > + +

+ {t("settings.appearance.options.carouselViewLabel")} +

+
+
{/* Second Column - Themes */} diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 159d4e8c..b1958501 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -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;