+
{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;