- {props.icon ? (
+ {props.customIcon ? (
+
+ {props.customIcon}
+
+ ) : props.icon ? (
diff --git a/src/components/media/MediaBookmark.tsx b/src/components/media/MediaBookmark.tsx
index cdd558bc..64e8ce97 100644
--- a/src/components/media/MediaBookmark.tsx
+++ b/src/components/media/MediaBookmark.tsx
@@ -9,10 +9,14 @@ import { IconPatch } from "../buttons/IconPatch";
interface MediaBookmarkProps {
media: MediaItem;
+ group?: string[];
}
-export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
+export function MediaBookmarkButton({ media, group }: MediaBookmarkProps) {
const addBookmark = useBookmarkStore((s) => s.addBookmark);
+ const addBookmarkWithGroups = useBookmarkStore(
+ (s) => s.addBookmarkWithGroups,
+ );
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const meta: PlayerMeta | undefined = useMemo(() => {
@@ -31,8 +35,16 @@ export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
const toggleBookmark = useCallback(() => {
if (!meta) return;
if (isBookmarked) removeBookmark(meta.tmdbId);
+ else if (group && group.length > 0) addBookmarkWithGroups(meta, group);
else addBookmark(meta);
- }, [isBookmarked, meta, addBookmark, removeBookmark]);
+ }, [
+ isBookmarked,
+ meta,
+ addBookmark,
+ addBookmarkWithGroups,
+ removeBookmark,
+ group,
+ ]);
const buttonOpacityClass =
media.year === undefined ? "hover:opacity-100" : "hover:opacity-95";
diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx
index b9453a17..778e4f78 100644
--- a/src/components/media/MediaCard.tsx
+++ b/src/components/media/MediaCard.tsx
@@ -9,14 +9,14 @@ import { mediaItemToId } from "@/backend/metadata/tmdb";
import { DotList } from "@/components/text/DotList";
import { Flare } from "@/components/utils/Flare";
import { useSearchQuery } from "@/hooks/useSearchQuery";
+import { useOverlayStack } from "@/stores/interface/overlayStack";
import { usePreferencesStore } from "@/stores/preferences";
import { MediaItem } from "@/utils/mediaTypes";
import { MediaBookmarkButton } from "./MediaBookmark";
import { IconPatch } from "../buttons/IconPatch";
import { Icon, Icons } from "../Icon";
-import { DetailsModal } from "../overlays/details/DetailsModal";
-import { useModal } from "../overlays/Modal";
+import { DetailsModal } from "../overlays/detailsModal";
export interface MediaCardProps {
media: MediaItem;
@@ -223,7 +223,7 @@ export function MediaCard(props: MediaCardProps) {
id: number;
type: "movie" | "show";
} | null>(null);
- const detailsModal = useModal("details");
+ const { showModal } = useOverlayStack();
const enableDetailsModal = usePreferencesStore(
(state) => state.enableDetailsModal,
);
@@ -258,8 +258,8 @@ export function MediaCard(props: MediaCardProps) {
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
});
- detailsModal.show();
- }, [media, detailsModal, onShowDetails]);
+ showModal("details");
+ }, [media, showModal, onShowDetails]);
const handleCardClick = (e: React.MouseEvent) => {
if (enableDetailsModal && canLink) {
diff --git a/src/components/overlays/EditGroupOrderModal.tsx b/src/components/overlays/EditGroupOrderModal.tsx
new file mode 100644
index 00000000..85ced953
--- /dev/null
+++ b/src/components/overlays/EditGroupOrderModal.tsx
@@ -0,0 +1,52 @@
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/buttons/Button";
+import { Item, SortableList } from "@/components/form/SortableList";
+import { Modal, ModalCard } from "@/components/overlays/Modal";
+import { Heading2, Paragraph } from "@/components/utils/Text";
+
+interface EditGroupOrderModalProps {
+ id: string;
+ isShown: boolean;
+ items: Item[];
+ onCancel: () => void;
+ onSave: () => void;
+ onItemsChange: (newItems: Item[]) => void;
+}
+
+export function EditGroupOrderModal({
+ id,
+ isShown,
+ items,
+ onCancel,
+ onSave,
+ onItemsChange,
+}: EditGroupOrderModalProps) {
+ const { t } = useTranslation();
+
+ if (!isShown) return null;
+
+ return (
+
+
+
+ {t("home.bookmarks.groups.reorder.title")}
+
+
+ {t("home.bookmarks.groups.reorder.description")}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/overlays/Modal.tsx b/src/components/overlays/Modal.tsx
index cec5f000..11f0f0b9 100644
--- a/src/components/overlays/Modal.tsx
+++ b/src/components/overlays/Modal.tsx
@@ -7,15 +7,15 @@ import { Icons } from "@/components/Icon";
import { OverlayPortal } from "@/components/overlays/OverlayDisplay";
import { Flare } from "@/components/utils/Flare";
import { Heading2 } from "@/components/utils/Text";
-import { useQueryParam } from "@/hooks/useQueryParams";
+import { useOverlayStack } from "@/stores/interface/overlayStack";
export function useModal(id: string) {
- const [currentModal, setCurrentModal] = useQueryParam("m");
- const show = useCallback(() => setCurrentModal(id), [id, setCurrentModal]);
- const hide = useCallback(() => setCurrentModal(null), [setCurrentModal]);
+ const { showModal, hideModal, isModalVisible } = useOverlayStack();
+ const show = useCallback(() => showModal(id), [id, showModal]);
+ const hide = useCallback(() => hideModal(id), [id, hideModal]);
return {
id,
- isShown: currentModal === id,
+ isShown: isModalVisible(id),
show,
hide,
};
@@ -33,9 +33,17 @@ export function ModalCard(props: { children?: ReactNode }) {
export function Modal(props: { id: string; children?: ReactNode }) {
const modal = useModal(props.id);
+ const { modalStack } = useOverlayStack();
+ const modalIndex = modalStack.indexOf(props.id);
+ const zIndex = modalIndex >= 0 ? 1000 + modalIndex : 999;
return (
-
+
diff --git a/src/components/overlays/OverlayDisplay.tsx b/src/components/overlays/OverlayDisplay.tsx
index 78827bf2..9143094d 100644
--- a/src/components/overlays/OverlayDisplay.tsx
+++ b/src/components/overlays/OverlayDisplay.tsx
@@ -77,14 +77,55 @@ export function OverlayPortal(props: {
show?: boolean;
close?: () => void;
durationClass?: string;
+ zIndex?: number;
}) {
const [portalElement, setPortalElement] = useState(null);
+ const [isReady, setIsReady] = useState(false);
const ref = useRef(null);
const close = props.close;
+ const zIndex = props.zIndex ?? 999;
useEffect(() => {
const element = ref.current?.closest(".popout-location");
setPortalElement(element ?? document.body);
+
+ // Ensure DOM is ready before enabling focus trap
+ const timer = setTimeout(() => {
+ setIsReady(true);
+ }, 100); // Increased delay to ensure DOM is fully rendered
+
+ return () => clearTimeout(timer);
+ }, []);
+
+ // Add global error handler for unhandled promise rejections
+ useEffect(() => {
+ const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
+ if (
+ event.reason &&
+ typeof event.reason === "object" &&
+ "message" in event.reason
+ ) {
+ const message = event.reason.message;
+ if (
+ message &&
+ typeof message === "string" &&
+ message.includes("matches.call")
+ ) {
+ console.warn(
+ "Caught focus-trap matches.call error, preventing crash:",
+ event.reason,
+ );
+ event.preventDefault();
+ }
+ }
+ };
+
+ window.addEventListener("unhandledrejection", handleUnhandledRejection);
+ return () =>
+ window.removeEventListener(
+ "unhandledrejection",
+ handleUnhandledRejection,
+ );
}, []);
return (
@@ -92,8 +133,23 @@ export function OverlayPortal(props: {
{portalElement
? createPortal(
-
-
+
document.body,
+ returnFocusOnDeactivate: true,
+ escapeDeactivates: true,
+ preventScroll: true,
+ // Disable the problematic check that causes the matches.call error
+ checkCanFocusTrap: () => Promise.resolve(),
+ }}
+ >
+
({});
@@ -38,6 +40,7 @@ export function EpisodeCarousel({
[key: number]: HTMLParagraphElement | null;
}>({});
const updateItem = useProgressStore((s) => s.updateItem);
+ const confirmModal = useModal("season-watch-confirm");
const handleScroll = (direction: "left" | "right") => {
if (!carouselRef.current) return;
@@ -203,10 +206,66 @@ export function EpisodeCarousel({
}
};
+ // Toggle whole season watch status
+ const toggleSeasonWatchStatus = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ confirmModal.show();
+ };
+
+ const handleCancel = () => {
+ confirmModal.hide();
+ };
+
const currentSeasonEpisodes = episodes.filter(
(ep) => ep.season_number === selectedSeason,
);
+ const handleConfirm = (event: React.MouseEvent) => {
+ try {
+ const episodeWatchedStatus: boolean[] = [];
+ currentSeasonEpisodes.forEach((episode: any) => {
+ const episodeProgress =
+ progress[mediaId?.toString() ?? ""]?.episodes?.[episode.id];
+ const percentage = episodeProgress
+ ? (episodeProgress.progress.watched /
+ episodeProgress.progress.duration) *
+ 100
+ : 0;
+ const isAired = hasAired(episode.air_date);
+ const isWatched = percentage > 90;
+ if (isAired && !isWatched) {
+ episodeWatchedStatus.push(isWatched);
+ }
+ });
+
+ const hasUnwatched = episodeWatchedStatus.length >= 1;
+
+ currentSeasonEpisodes.forEach((episode: any) => {
+ const episodeProgress =
+ progress[mediaId?.toString() ?? ""]?.episodes?.[episode.id];
+ const percentage = episodeProgress
+ ? (episodeProgress.progress.watched /
+ episodeProgress.progress.duration) *
+ 100
+ : 0;
+ const isAired = hasAired(episode.air_date);
+ const isWatched = percentage > 90;
+ if (hasUnwatched && isAired && !isWatched) {
+ toggleWatchStatus(episode.id, event); // Mark unwatched as watched
+ } else if (!hasUnwatched && isAired && isWatched) {
+ toggleWatchStatus(episode.id, event); // Mark watched as unwatched
+ }
+ });
+
+ confirmModal.hide();
+ } catch (error) {
+ console.error("Error in handleConfirm:", error);
+ confirmModal.hide();
+ }
+ };
+
const toggleEpisodeExpansion = (
episodeId: number,
event: React.MouseEvent,
@@ -259,6 +318,32 @@ export function EpisodeCarousel({
};
}, [episodes, expandedEpisodes]);
+ useEffect(() => {
+ const episodeWatchedStatus: boolean[] = [];
+
+ currentSeasonEpisodes.forEach((episode: any) => {
+ const episodeProgress =
+ progress[mediaId?.toString() ?? ""]?.episodes?.[episode.id];
+ const percentage = episodeProgress
+ ? (episodeProgress.progress.watched /
+ episodeProgress.progress.duration) *
+ 100
+ : 0;
+ const isAired = hasAired(episode.air_date);
+ const isWatched = percentage > 90;
+
+ if (isAired && !isWatched) {
+ episodeWatchedStatus.push(isWatched);
+ }
+ });
+
+ if (episodeWatchedStatus.length >= 1) {
+ setSeasonWatched(true); // If no episodes are watched, we want to mark all as watched
+ } else {
+ setSeasonWatched(false); // if all episodes are watched, we want to mark all as unwatched
+ }
+ }, [currentSeasonEpisodes, episodes, mediaId, progress]);
+
return (
{/* Season Selector */}
@@ -323,17 +408,50 @@ export function EpisodeCarousel({
)}
- ({
- id: season.season_number.toString(),
- name: `${t("details.season")} ${season.season_number}`,
- }))}
- selectedItem={{
- id: selectedSeason.toString(),
- name: `${t("details.season")} ${selectedSeason}`,
- }}
- setSelectedItem={(item) => onSeasonChange(Number(item.id))}
- />
+
+ {/* Season Watched Confirmation */}
+
+
+
+
+ {SeasonWatched
+ ? t("media.seasonWatched")
+ : t("media.seasonUnwatched")}
+
+
+
+
+
+
+
+
+
+
({
+ id: season.season_number.toString(),
+ name: `${t("details.season")} ${season.season_number}`,
+ }))}
+ selectedItem={{
+ id: selectedSeason.toString(),
+ name: `${t("details.season")} ${selectedSeason}`,
+ }}
+ setSelectedItem={(item) => onSeasonChange(Number(item.id))}
+ />
+
{/* Episodes Carousel */}
@@ -359,7 +477,6 @@ export function EpisodeCarousel({
>
{/* Add padding before the first card */}
-
{currentSeasonEpisodes.map((episode) => {
const isActive =
showProgress?.episode?.id === episode.id.toString();
@@ -414,7 +531,7 @@ export function EpisodeCarousel({
{episode.episode_number}
{!isAired && (
-
+
{episode.air_date
? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})`
: `(${t("media.unreleased")})`}
diff --git a/src/components/overlays/details/PeopleCarousel.tsx b/src/components/overlays/detailsModal/components/carousels/PeopleCarousel.tsx
similarity index 100%
rename from src/components/overlays/details/PeopleCarousel.tsx
rename to src/components/overlays/detailsModal/components/carousels/PeopleCarousel.tsx
diff --git a/src/components/overlays/details/DetailsContent.tsx b/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx
similarity index 97%
rename from src/components/overlays/details/DetailsContent.tsx
rename to src/components/overlays/detailsModal/components/layout/DetailsContent.tsx
index 624d2933..55f08703 100644
--- a/src/components/overlays/details/DetailsContent.tsx
+++ b/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx
@@ -13,12 +13,12 @@ import { scrapeIMDb } from "@/utils/imdbScraper";
import { getTmdbLanguageCode } from "@/utils/language";
import { scrapeRottenTomatoes } from "@/utils/rottenTomatoesScraper";
-import { DetailsBody } from "./DetailsBody";
-import { DetailsInfo } from "./DetailsInfo";
-import { EpisodeCarousel } from "./EpisodeCarousel";
-import { CastCarousel } from "./PeopleCarousel";
-import { TrailerOverlay } from "./TrailerOverlay";
-import { DetailsContentProps } from "./types";
+import { DetailsContentProps } from "../../types";
+import { EpisodeCarousel } from "../carousels/EpisodeCarousel";
+import { CastCarousel } from "../carousels/PeopleCarousel";
+import { TrailerOverlay } from "../overlays/TrailerOverlay";
+import { DetailsBody } from "../sections/DetailsBody";
+import { DetailsInfo } from "../sections/DetailsInfo";
export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
const [imdbData, setImdbData] = useState(null);
diff --git a/src/components/overlays/details/DetailsModal.tsx b/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx
similarity index 79%
rename from src/components/overlays/details/DetailsModal.tsx
rename to src/components/overlays/detailsModal/components/layout/DetailsModal.tsx
index 2f1b7e6c..34cf0a29 100644
--- a/src/components/overlays/details/DetailsModal.tsx
+++ b/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx
@@ -1,5 +1,5 @@
import classNames from "classnames";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import {
@@ -16,18 +16,24 @@ import {
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Flare } from "@/components/utils/Flare";
+import { useOverlayStack } from "@/stores/interface/overlayStack";
-import { useModal } from "../Modal";
-import { OverlayPortal } from "../OverlayDisplay";
import { DetailsContent } from "./DetailsContent";
import { DetailsSkeleton } from "./DetailsSkeleton";
-import { DetailsModalProps } from "./types";
+import { OverlayPortal } from "../../../OverlayDisplay";
+import { DetailsModalProps } from "../../types";
export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
- const modal = useModal(id);
+ const { hideModal, isModalVisible, modalStack } = useOverlayStack();
const [detailsData, setDetailsData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
+ const modalIndex = modalStack.indexOf(id);
+ const zIndex = modalIndex >= 0 ? 1000 + modalIndex : 999;
+
+ const hide = useCallback(() => hideModal(id), [hideModal, id]);
+ const isShown = isModalVisible(id);
+
useEffect(() => {
const fetchDetails = async () => {
if (!data?.id || !data?.type) return;
@@ -106,23 +112,24 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
}
};
- if (modal.isShown && data?.id) {
+ if (isShown && data?.id) {
fetchDetails();
}
- }, [modal.isShown, data]);
+ }, [isShown, data]);
useEffect(() => {
- if (modal.isShown && !data?.id && !isLoading) {
- modal.hide();
+ if (isShown && !data?.id && !isLoading) {
+ hide();
}
- }, [modal, data, isLoading]);
+ }, [isShown, data, isLoading, hide]);
return (
@@ -132,27 +139,28 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
className={classNames(
"group -m-[0.705em] rounded-3xl bg-background-main",
"max-h-[900px] max-w-[1200px]",
- "bg-mediaCard-hoverBackground bg-opacity-60 backdrop-filter backdrop-blur-lg shadow-lg overflow-hidden",
+ "bg-mediaCard-hoverBackground/60 backdrop-filter backdrop-blur-lg shadow-lg overflow-hidden",
"h-[97%] w-[95%]",
+ "relative",
)}
>
-
+
+
+
+
-
-
-
{isLoading || !detailsData ? (
diff --git a/src/components/overlays/details/DetailsSkeleton.tsx b/src/components/overlays/detailsModal/components/layout/DetailsSkeleton.tsx
similarity index 100%
rename from src/components/overlays/details/DetailsSkeleton.tsx
rename to src/components/overlays/detailsModal/components/layout/DetailsSkeleton.tsx
diff --git a/src/components/overlays/details/TrailerOverlay.tsx b/src/components/overlays/detailsModal/components/overlays/TrailerOverlay.tsx
similarity index 94%
rename from src/components/overlays/details/TrailerOverlay.tsx
rename to src/components/overlays/detailsModal/components/overlays/TrailerOverlay.tsx
index 213af901..98bc0ed4 100644
--- a/src/components/overlays/details/TrailerOverlay.tsx
+++ b/src/components/overlays/detailsModal/components/overlays/TrailerOverlay.tsx
@@ -1,6 +1,6 @@
import { Icon, Icons } from "@/components/Icon";
-import { TrailerOverlayProps } from "./types";
+import { TrailerOverlayProps } from "../../types";
export function TrailerOverlay({ trailerUrl, onClose }: TrailerOverlayProps) {
return (
diff --git a/src/components/overlays/details/DetailsBody.tsx b/src/components/overlays/detailsModal/components/sections/DetailsBody.tsx
similarity index 53%
rename from src/components/overlays/details/DetailsBody.tsx
rename to src/components/overlays/detailsModal/components/sections/DetailsBody.tsx
index b80e1231..c9643cb5 100644
--- a/src/components/overlays/details/DetailsBody.tsx
+++ b/src/components/overlays/detailsModal/components/sections/DetailsBody.tsx
@@ -8,10 +8,12 @@ import {
} from "@/backend/metadata/traktApi";
import { Button } from "@/components/buttons/Button";
import { IconPatch } from "@/components/buttons/IconPatch";
+import { GroupDropdown } from "@/components/form/GroupDropdown";
import { Icon, Icons } from "@/components/Icon";
import { MediaBookmarkButton } from "@/components/media/MediaBookmark";
+import { useBookmarkStore } from "@/stores/bookmarks";
-import { DetailsBodyProps } from "./types";
+import { DetailsBodyProps } from "../../types";
export function DetailsBody({
data,
@@ -28,6 +30,58 @@ export function DetailsBody({
const [releaseInfo, setReleaseInfo] = useState(
null,
);
+ const addBookmarkWithGroups = useBookmarkStore(
+ (s) => s.addBookmarkWithGroups,
+ );
+
+ const bookmarks = useBookmarkStore((s) => s.bookmarks);
+ const currentGroups = bookmarks[data.id?.toString() ?? ""]?.group || [];
+
+ const allGroups = Array.from(
+ new Set(
+ Object.values(bookmarks)
+ .flatMap((b) => b.group || [])
+ .filter(Boolean),
+ ),
+ ) as string[];
+
+ const handleSelectGroups = (groups: string[]) => {
+ if (!data.id) return;
+ const meta = {
+ tmdbId: data.id.toString(),
+ title: data.title,
+ type: data.type || "movie",
+ releaseYear: data.releaseDate
+ ? new Date(data.releaseDate).getFullYear()
+ : 0,
+ poster: data.posterUrl,
+ };
+ addBookmarkWithGroups(meta, groups);
+ };
+
+ const handleCreateGroup = (group: string) => {
+ handleSelectGroups([...currentGroups, group]);
+ };
+
+ const handleRemoveGroup = (groupToRemove?: string) => {
+ if (!data.id) return;
+ const meta = {
+ tmdbId: data.id.toString(),
+ title: data.title,
+ type: data.type || "movie",
+ releaseYear: data.releaseDate
+ ? new Date(data.releaseDate).getFullYear()
+ : 0,
+ poster: data.posterUrl,
+ };
+ if (groupToRemove) {
+ const newGroups = currentGroups.filter((g) => g !== groupToRemove);
+ addBookmarkWithGroups(meta, newGroups);
+ } else {
+ // Remove all groups
+ addBookmarkWithGroups(meta, []);
+ }
+ };
useEffect(() => {
const fetchReleaseInfo = async () => {
@@ -152,66 +206,84 @@ export function DetailsBody({
{/* Action Buttons */}
-
-
-
- {imdbData?.trailer_url && (
+
+
+
+
+ {imdbData?.trailer_url && (
+
+ )}
+
- )}
-
-
+
+
+ {/* Group Dropdown */}
+
);
diff --git a/src/components/overlays/details/DetailsInfo.tsx b/src/components/overlays/detailsModal/components/sections/DetailsInfo.tsx
similarity index 95%
rename from src/components/overlays/details/DetailsInfo.tsx
rename to src/components/overlays/detailsModal/components/sections/DetailsInfo.tsx
index 1581e9e3..b77c2236 100644
--- a/src/components/overlays/details/DetailsInfo.tsx
+++ b/src/components/overlays/detailsModal/components/sections/DetailsInfo.tsx
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { Trans } from "react-i18next";
import { DetailsRatings } from "./DetailsRatings";
-import { DetailsInfoProps } from "./types";
+import { DetailsInfoProps } from "../../types";
export function DetailsInfo({
data,
@@ -66,7 +66,7 @@ export function DetailsInfo({
};
return (
-
+
{data.runtime && (
diff --git a/src/components/overlays/details/DetailsRatings.tsx b/src/components/overlays/detailsModal/components/sections/DetailsRatings.tsx
similarity index 98%
rename from src/components/overlays/details/DetailsRatings.tsx
rename to src/components/overlays/detailsModal/components/sections/DetailsRatings.tsx
index e9a5b5ff..7b050e4b 100644
--- a/src/components/overlays/details/DetailsRatings.tsx
+++ b/src/components/overlays/detailsModal/components/sections/DetailsRatings.tsx
@@ -4,7 +4,7 @@ import { PROVIDER_TO_IMAGE_MAP } from "@/backend/metadata/traktApi";
import { Icon, Icons } from "@/components/Icon";
import { getRTIcon } from "@/utils/rottenTomatoesScraper";
-import { DetailsRatingsProps } from "./types";
+import { DetailsRatingsProps } from "../../types";
export function DetailsRatings({
rtData,
diff --git a/src/components/overlays/detailsModal/index.ts b/src/components/overlays/detailsModal/index.ts
new file mode 100644
index 00000000..0aaebb3d
--- /dev/null
+++ b/src/components/overlays/detailsModal/index.ts
@@ -0,0 +1,28 @@
+// Main exports
+export { DetailsModal } from "./components/layout/DetailsModal";
+export type { DetailsModalProps, DetailsContentProps } from "./types";
+
+// Layout components
+export { DetailsContent } from "./components/layout/DetailsContent";
+export { DetailsSkeleton } from "./components/layout/DetailsSkeleton";
+
+// Section components
+export { DetailsBody } from "./components/sections/DetailsBody";
+export { DetailsInfo } from "./components/sections/DetailsInfo";
+export { DetailsRatings } from "./components/sections/DetailsRatings";
+
+// Carousel components
+export { EpisodeCarousel } from "./components/carousels/EpisodeCarousel";
+export { CastCarousel } from "./components/carousels/PeopleCarousel";
+
+// Overlay components
+export { TrailerOverlay } from "./components/overlays/TrailerOverlay";
+
+// Types
+export type {
+ DetailsBodyProps,
+ DetailsInfoProps,
+ DetailsRatingsProps,
+ TrailerOverlayProps,
+ EpisodeCarouselProps,
+} from "./types";
diff --git a/src/components/overlays/details/types.ts b/src/components/overlays/detailsModal/types.ts
similarity index 100%
rename from src/components/overlays/details/types.ts
rename to src/components/overlays/detailsModal/types.ts
diff --git a/src/components/overlays/notificationsModal/components/DetailView.tsx b/src/components/overlays/notificationsModal/components/DetailView.tsx
new file mode 100644
index 00000000..4b53e19a
--- /dev/null
+++ b/src/components/overlays/notificationsModal/components/DetailView.tsx
@@ -0,0 +1,121 @@
+import { Icon, Icons } from "@/components/Icon";
+import { Link } from "@/pages/migration/utils";
+
+import { DetailViewProps } from "../types";
+
+export function DetailView({
+ selectedNotification,
+ goBackToList,
+ getCategoryColor,
+ getCategoryLabel,
+ formatDate,
+ isRead,
+ toggleReadStatus,
+}: DetailViewProps) {
+ return (
+
+ {/* Header with back button and toggle read status */}
+
+
+
+
+
+
+
+ {/* Notification content */}
+
+
+ {getCategoryColor(selectedNotification.category) && (
+
+ )}
+ {getCategoryLabel(selectedNotification.category) && (
+ <>
+
+ {getCategoryLabel(selectedNotification.category)}
+
+ {selectedNotification.source && (
+ <>
+ •
+
+ {selectedNotification.source}
+
+ >
+ )}
+ •
+
+ {formatDate(selectedNotification.pubDate)}
+
+ >
+ )}
+ {!getCategoryLabel(selectedNotification.category) && (
+ <>
+ {selectedNotification.source && (
+ <>
+
+ {selectedNotification.source}
+
+ •
+ >
+ )}
+
+ {formatDate(selectedNotification.pubDate)}
+
+ >
+ )}
+
+
+
+
")
+ .replace(/\n- /g, "
• ")
+ .replace(/\n\*\*([^*]+)\*\*/g, "
$1
")
+ .replace(/^/, "
")
+ .replace(/$/, "
")
+ .replace(/
<\/p>/g, "")
+ .replace(
+ /
• /g,
+ '
•',
+ )
+ .replace(/<\/p>/g, "
"),
+ }}
+ />
+
+
+ {selectedNotification.link && (
+
+
+
+ Go to page
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/overlays/notificationsModal/components/ListView.tsx b/src/components/overlays/notificationsModal/components/ListView.tsx
new file mode 100644
index 00000000..21f30c77
--- /dev/null
+++ b/src/components/overlays/notificationsModal/components/ListView.tsx
@@ -0,0 +1,228 @@
+import { Icon, Icons } from "@/components/Icon";
+
+import { ListViewProps } from "../types";
+
+export function ListView({
+ notifications,
+ readNotifications,
+ unreadCount,
+ loading,
+ error,
+ containerRef,
+ markAllAsRead,
+ markAllAsUnread,
+ isShiftHeld,
+ onRefresh,
+ onOpenSettings,
+ openNotificationDetail,
+ getCategoryColor,
+ getCategoryLabel,
+ formatDate,
+}: ListViewProps) {
+ return (
+
+ {/* Header with refresh and mark all buttons */}
+
+
+
+ {unreadCount} unread notification{unreadCount !== 1 ? "s" : ""}
+
+
+ {isShiftHeld ? (
+
+ ) : (
+ unreadCount > 0 && (
+
+ )
+ )}
+
+
+
+
+
+
+
+
+ {/* Loading state */}
+ {loading && (
+
+
+ Loading...
+
+ )}
+
+ {/* Error state */}
+ {error && (
+
+
+
Failed to load notifications
+
{error}
+
+ )}
+
+ {/* Notifications list */}
+ {!loading && !error && (
+
+ {notifications.length === 0 ? (
+
+
+
No notifications available
+
+ ) : (
+ notifications.map((notification) => {
+ const isRead = readNotifications.has(notification.guid);
+ return (
+
openNotificationDetail(notification)}
+ >
+
+
+
+
+
+ {notification.title}
+
+ {!isRead && (
+
+ )}
+
+
+ {/* Mobile: Source • Category */}
+
+ {getCategoryColor(notification.category) && (
+
+ )}
+
+ {getCategoryLabel(notification.category)}
+
+ {notification.source && (
+ <>
+
+ •
+
+
+ {notification.source}
+
+ >
+ )}
+
+
+ {/* Desktop: Source above Category */}
+
+ {notification.source && (
+
+ {notification.source}
+
+ )}
+
+ {getCategoryColor(notification.category) && (
+
+ )}
+
+ {getCategoryLabel(notification.category)}
+
+
+
+
+
+
")
+ .replace(/\n- /g, "
• ")
+ .replace(
+ /\n\*\*([^*]+)\*\*/g,
+ "
$1
",
+ )
+ .replace(/^/, "
")
+ .replace(/$/, "
")
+ .replace(/
<\/p>/g, "")
+ .replace(
+ /
• /g,
+ '
•',
+ )
+ .replace(/<\/p>/g, "
")
+ .substring(0, 150) +
+ (notification.description.length > 150
+ ? "..."
+ : ""),
+ }}
+ />
+
+
+
+
+ {formatDate(notification.pubDate)}
+
+
+
+
+ );
+ })
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/overlays/notificationsModal/components/NotificationModal.tsx b/src/components/overlays/notificationsModal/components/NotificationModal.tsx
new file mode 100644
index 00000000..635635f4
--- /dev/null
+++ b/src/components/overlays/notificationsModal/components/NotificationModal.tsx
@@ -0,0 +1,424 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { Icon, Icons } from "@/components/Icon";
+
+import { DetailView } from "./DetailView";
+import { ListView } from "./ListView";
+import { SettingsView } from "./SettingsView";
+import { FancyModal } from "../../Modal";
+import { ModalView, NotificationItem, NotificationModalProps } from "../types";
+import {
+ fetchRssFeed,
+ formatDate,
+ getAllFeeds,
+ getCategoryColor,
+ getCategoryLabel,
+ getSourceName,
+} from "../utils";
+
+export function NotificationModal({ id }: NotificationModalProps) {
+ const [notifications, setNotifications] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [readNotifications, setReadNotifications] = useState>(
+ new Set(),
+ );
+ const [currentView, setCurrentView] = useState("list");
+ const [selectedNotification, setSelectedNotification] =
+ useState(null);
+ const [isShiftHeld, setIsShiftHeld] = useState(false);
+ const containerRef = useRef(null);
+
+ // Settings state
+ const [autoReadDays, setAutoReadDays] = useState(14);
+ const [customFeeds, setCustomFeeds] = useState([]);
+
+ // Load read notifications and settings from localStorage
+ useEffect(() => {
+ const savedRead = localStorage.getItem("read-notifications");
+ if (savedRead) {
+ try {
+ const readArray = JSON.parse(savedRead);
+ setReadNotifications(new Set(readArray));
+ } catch (e) {
+ console.error("Failed to parse read notifications:", e);
+ }
+ }
+
+ // Load settings
+ const savedAutoReadDays = localStorage.getItem(
+ "notification-auto-read-days",
+ );
+ if (savedAutoReadDays) {
+ try {
+ setAutoReadDays(parseInt(savedAutoReadDays, 10));
+ } catch (e) {
+ console.error("Failed to parse auto read days:", e);
+ }
+ }
+
+ const savedCustomFeeds = localStorage.getItem("notification-custom-feeds");
+ if (savedCustomFeeds) {
+ try {
+ setCustomFeeds(JSON.parse(savedCustomFeeds));
+ } catch (e) {
+ console.error("Failed to parse custom feeds:", e);
+ }
+ }
+ }, []);
+
+ // Handle shift key for mark all as unread button
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Shift") {
+ setIsShiftHeld(true);
+ }
+ };
+
+ const handleKeyUp = (e: KeyboardEvent) => {
+ if (e.key === "Shift") {
+ setIsShiftHeld(false);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ };
+ }, []);
+
+ // Fetch RSS feed function
+ const fetchNotifications = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const allNotifications: NotificationItem[] = [];
+ const autoReadGuids: string[] = [];
+
+ // Mark notifications older than autoReadDays as read
+ const autoReadDate = new Date();
+ autoReadDate.setDate(autoReadDate.getDate() - autoReadDays);
+
+ // Get all feeds (default + custom)
+ const feeds = getAllFeeds();
+
+ // Fetch from all feeds
+ for (const feedUrl of feeds) {
+ if (!feedUrl.trim()) continue;
+
+ try {
+ const xmlText = await fetchRssFeed(feedUrl);
+
+ // Basic validation that we got XML content
+ if (
+ xmlText &&
+ (xmlText.includes(" 0) {
+ items.forEach((item) => {
+ try {
+ // Handle both RSS and Atom formats
+ const guid =
+ item.querySelector("guid")?.textContent ||
+ item.querySelector("id")?.textContent ||
+ "";
+ const title =
+ item.querySelector("title")?.textContent || "";
+ const link =
+ item.querySelector("link")?.textContent ||
+ item.querySelector("link")?.getAttribute("href") ||
+ "";
+ const description =
+ item.querySelector("description")?.textContent ||
+ item.querySelector("content")?.textContent ||
+ item.querySelector("summary")?.textContent ||
+ "";
+ const pubDate =
+ item.querySelector("pubDate")?.textContent ||
+ item.querySelector("published")?.textContent ||
+ item.querySelector("updated")?.textContent ||
+ "";
+ const category =
+ item.querySelector("category")?.textContent || "";
+
+ // Skip items without essential data
+ // Use link as fallback for guid if guid is missing
+ const itemGuid = guid || link;
+ if (!itemGuid || !title) {
+ return;
+ }
+
+ // Parse the publication date
+ const notificationDate = new Date(pubDate);
+
+ allNotifications.push({
+ guid: itemGuid,
+ title,
+ link,
+ description,
+ pubDate,
+ category,
+ source: getSourceName(feedUrl),
+ });
+
+ // Collect GUIDs of notifications older than autoReadDays
+ if (notificationDate <= autoReadDate) {
+ autoReadGuids.push(itemGuid);
+ }
+ } catch (itemError) {
+ // Skip malformed items
+ console.warn(
+ "Skipping malformed RSS/Atom item:",
+ itemError,
+ );
+ }
+ });
+ }
+ }
+ }
+ } catch (customFeedError) {
+ // Silently fail for individual feed errors
+ }
+ }
+
+ setNotifications(allNotifications);
+
+ // Update read notifications after setting notifications
+ if (autoReadGuids.length > 0) {
+ setReadNotifications((prevReadSet) => {
+ const newReadSet = new Set(prevReadSet);
+ autoReadGuids.forEach((guid) => newReadSet.add(guid));
+
+ // Update localStorage
+ localStorage.setItem(
+ "read-notifications",
+ JSON.stringify(Array.from(newReadSet)),
+ );
+
+ return newReadSet;
+ });
+ }
+ } catch (err) {
+ console.error("RSS fetch error:", err);
+ setError(
+ err instanceof Error ? err.message : "Failed to load notifications",
+ );
+ // Set empty notifications to prevent crashes
+ setNotifications([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [autoReadDays]);
+
+ // Initial fetch
+ useEffect(() => {
+ fetchNotifications();
+ }, [fetchNotifications]);
+
+ // Refresh function
+ const handleRefresh = () => {
+ fetchNotifications();
+ };
+
+ // Save read notifications to cookie
+ const markAsRead = (guid: string) => {
+ const newReadSet = new Set(readNotifications);
+ newReadSet.add(guid);
+ setReadNotifications(newReadSet);
+
+ // Save to localStorage
+ localStorage.setItem(
+ "read-notifications",
+ JSON.stringify(Array.from(newReadSet)),
+ );
+ };
+
+ // Mark all as read
+ const markAllAsRead = () => {
+ const allGuids = notifications.map((n) => n.guid);
+ const newReadSet = new Set(allGuids);
+ setReadNotifications(newReadSet);
+ localStorage.setItem(
+ "read-notifications",
+ JSON.stringify(Array.from(newReadSet)),
+ );
+ };
+
+ // Mark all as unread
+ const markAllAsUnread = () => {
+ setReadNotifications(new Set());
+ localStorage.setItem("read-notifications", JSON.stringify([]));
+ };
+
+ // Navigate to detail view
+ const openNotificationDetail = (notification: NotificationItem) => {
+ setSelectedNotification(notification);
+ setCurrentView("detail");
+ markAsRead(notification.guid);
+ };
+
+ // Navigate back to list
+ const goBackToList = () => {
+ setCurrentView("list");
+ setSelectedNotification(null);
+ };
+
+ // Settings functions
+ const openSettings = () => {
+ setCurrentView("settings");
+ };
+
+ const closeSettings = () => {
+ setCurrentView("list");
+ };
+
+ // Save settings functions
+ const saveAutoReadDays = (days: number) => {
+ setAutoReadDays(days);
+ localStorage.setItem("notification-auto-read-days", days.toString());
+ };
+
+ const saveCustomFeeds = (feeds: string[]) => {
+ setCustomFeeds(feeds);
+ localStorage.setItem("notification-custom-feeds", JSON.stringify(feeds));
+ };
+
+ // Scroll to last read notification
+ useEffect(() => {
+ if (
+ notifications.length > 0 &&
+ containerRef.current &&
+ currentView === "list"
+ ) {
+ const lastReadIndex = notifications.findIndex(
+ (n) => !readNotifications.has(n.guid),
+ );
+ if (lastReadIndex > 0) {
+ const element = containerRef.current.children[
+ lastReadIndex
+ ] as HTMLElement;
+ if (element) {
+ // Use scrollTop instead of scrollIntoView to avoid scrolling the modal container
+ const container = containerRef.current;
+ const elementTop = element.offsetTop;
+ const containerHeight = container.clientHeight;
+ const elementHeight = element.clientHeight;
+
+ // Calculate the scroll position to center the element
+ const scrollTop =
+ elementTop - containerHeight / 2 + elementHeight / 2;
+
+ container.scrollTo({
+ top: Math.max(0, scrollTop),
+ behavior: "smooth",
+ });
+ }
+ }
+ }
+ }, [notifications, readNotifications, currentView]);
+
+ const unreadCount = notifications.filter(
+ (n) => !readNotifications.has(n.guid),
+ ).length;
+
+ // Don't render if there's a critical error
+ if (error && !loading) {
+ return (
+
+
+
+
Failed to load notifications
+
{error}
+
+
+
+ );
+ }
+
+ return (
+
+ {currentView === "list" ? (
+
+ ) : currentView === "detail" && selectedNotification ? (
+ {
+ if (readNotifications.has(selectedNotification.guid)) {
+ // Mark as unread
+ const newReadSet = new Set(readNotifications);
+ newReadSet.delete(selectedNotification.guid);
+ setReadNotifications(newReadSet);
+ localStorage.setItem(
+ "read-notifications",
+ JSON.stringify(Array.from(newReadSet)),
+ );
+ } else {
+ // Mark as read
+ markAsRead(selectedNotification.guid);
+ }
+ }}
+ />
+ ) : currentView === "settings" ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/overlays/notificationsModal/components/SettingsView.tsx b/src/components/overlays/notificationsModal/components/SettingsView.tsx
new file mode 100644
index 00000000..a35266bc
--- /dev/null
+++ b/src/components/overlays/notificationsModal/components/SettingsView.tsx
@@ -0,0 +1,151 @@
+import { Icon, Icons } from "@/components/Icon";
+
+import { SettingsViewProps } from "../types";
+
+export function SettingsView({
+ autoReadDays,
+ setAutoReadDays,
+ customFeeds,
+ setCustomFeeds,
+ markAllAsUnread,
+ onClose,
+}: SettingsViewProps) {
+ const addCustomFeed = () => {
+ setCustomFeeds([...customFeeds, ""]);
+ };
+
+ const changeCustomFeed = (index: number, val: string) => {
+ setCustomFeeds(
+ customFeeds.map((v, i) => {
+ if (i !== index) return v;
+ return val;
+ }),
+ );
+ };
+
+ const removeCustomFeed = (index: number) => {
+ setCustomFeeds(customFeeds.filter((v, i) => i !== index));
+ };
+
+ return (
+
+ {/* Header with back button */}
+
+
+
+
+ {/* Settings content */}
+
+ {/* Mark all as unread section */}
+
+
Mark All as Unread
+
+ Permanently mark all notifications as unread. This action cannot be
+ undone.
+
+
+
+
+ {/* Auto-read days section */}
+
+
Auto-Mark as Read
+
+ Automatically mark notifications as read after this many days.
+
+
+
+ setAutoReadDays(parseInt(e.target.value, 10) || 14)
+ }
+ className="bg-background-secondary border border-type-secondary rounded px-3 py-2 text-white w-20"
+ />
+ days
+
+
+
+ {/* Custom feeds section */}
+
+
Custom RSS Feeds
+
+ Add custom RSS feeds to receive notifications from other sources.
+
+
+ Note: This feature is experimental and may not work for all feeds.
+
+
+
+
+ {customFeeds.length === 0 ? (
+
+ No custom feeds added
+
+ ) : null}
+ {customFeeds.map((feed, i) => (
+
+ changeCustomFeed(i, e.target.value)}
+ placeholder="https://example.com/feed.xml"
+ className="bg-background-secondary border border-type-secondary rounded px-3 py-2 text-white text-sm"
+ />
+
+
+ ))}
+
+
+
+
+
+ {/* Recommended feeds section */}
+
+
Recommended Feeds
+
+ https://www.moviefone.com/feeds/movie-news.rss
+
+ https://www.moviefone.com/feeds/tv-news.rss
+
+ https://www.filmjabber.com/rss/rss-dvd-reviews.php
+
+ https://screenrant.com/feed/
+
+ https://www.darkhorizons.com/feed/
+
+
+
+
+ );
+}
diff --git a/src/components/overlays/notificationsModal/hooks/useNotifications.ts b/src/components/overlays/notificationsModal/hooks/useNotifications.ts
new file mode 100644
index 00000000..e47b47cd
--- /dev/null
+++ b/src/components/overlays/notificationsModal/hooks/useNotifications.ts
@@ -0,0 +1,148 @@
+import { useEffect, useState } from "react";
+
+import { useOverlayStack } from "@/stores/interface/overlayStack";
+
+import { NotificationItem } from "../types";
+import { fetchRssFeed, getAllFeeds, getSourceName } from "../utils";
+
+// Hook to manage notifications
+export function useNotifications() {
+ const { showModal, hideModal, isModalVisible } = useOverlayStack();
+ const modalId = "notifications";
+ const [notifications, setNotifications] = useState([]);
+
+ // Fetch notifications for badge count
+ useEffect(() => {
+ const fetchNotifications = async () => {
+ try {
+ const allNotifications: NotificationItem[] = [];
+
+ // Get all feeds (default + custom)
+ const feeds = getAllFeeds();
+
+ // Fetch from all feeds
+ for (const feedUrl of feeds) {
+ if (!feedUrl.trim()) continue;
+
+ try {
+ const xmlText = await fetchRssFeed(feedUrl);
+
+ // Basic validation that we got XML content
+ if (
+ xmlText &&
+ (xmlText.includes(" 0) {
+ items.forEach((item) => {
+ try {
+ // Handle both RSS and Atom formats
+ const guid =
+ item.querySelector("guid")?.textContent ||
+ item.querySelector("id")?.textContent ||
+ "";
+ const title =
+ item.querySelector("title")?.textContent || "";
+ const link =
+ item.querySelector("link")?.textContent ||
+ item.querySelector("link")?.getAttribute("href") ||
+ "";
+ const description =
+ item.querySelector("description")?.textContent ||
+ item.querySelector("content")?.textContent ||
+ item.querySelector("summary")?.textContent ||
+ "";
+ const pubDate =
+ item.querySelector("pubDate")?.textContent ||
+ item.querySelector("published")?.textContent ||
+ item.querySelector("updated")?.textContent ||
+ "";
+ const category =
+ item.querySelector("category")?.textContent || "";
+
+ // Skip items without essential data
+ // Use link as fallback for guid if guid is missing
+ const itemGuid = guid || link;
+ if (!itemGuid || !title) {
+ return;
+ }
+
+ allNotifications.push({
+ guid: itemGuid,
+ title,
+ link,
+ description,
+ pubDate,
+ category,
+ source: getSourceName(feedUrl),
+ });
+ } catch (itemError) {
+ // Skip malformed items silently
+ }
+ });
+ }
+ }
+ }
+ } catch (customFeedError) {
+ // Silently fail for individual feed errors
+ }
+ }
+
+ setNotifications(allNotifications);
+ } catch (err) {
+ // Silently fail for badge count
+ }
+ };
+
+ fetchNotifications();
+ }, []);
+
+ const openNotifications = () => {
+ showModal(modalId);
+ };
+
+ const closeNotifications = () => {
+ hideModal(modalId);
+ };
+
+ const isNotificationsOpen = () => {
+ return isModalVisible(modalId);
+ };
+
+ // Get unread count for badge
+ const getUnreadCount = () => {
+ try {
+ const savedRead = localStorage.getItem("read-notifications");
+ if (!savedRead) {
+ const count = notifications.length;
+ return count > 99 ? "99+" : count;
+ }
+
+ const readArray = JSON.parse(savedRead);
+ const readSet = new Set(readArray);
+
+ // Get the actual count from the notifications state
+ const count = notifications.filter(
+ (n: NotificationItem) => !readSet.has(n.guid),
+ ).length;
+
+ return count > 99 ? "99+" : count;
+ } catch {
+ return 0;
+ }
+ };
+
+ return {
+ openNotifications,
+ closeNotifications,
+ isNotificationsOpen,
+ getUnreadCount,
+ };
+}
diff --git a/src/components/overlays/notificationsModal/index.ts b/src/components/overlays/notificationsModal/index.ts
new file mode 100644
index 00000000..cde919ee
--- /dev/null
+++ b/src/components/overlays/notificationsModal/index.ts
@@ -0,0 +1,28 @@
+// Components
+export { NotificationModal } from "./components/NotificationModal";
+export { DetailView } from "./components/DetailView";
+export { ListView } from "./components/ListView";
+export { SettingsView } from "./components/SettingsView";
+
+// Hooks
+export { useNotifications } from "./hooks/useNotifications";
+
+// Types
+export type {
+ NotificationItem,
+ NotificationModalProps,
+ ModalView,
+ DetailViewProps,
+ SettingsViewProps,
+ ListViewProps,
+} from "./types";
+
+// Utils
+export {
+ getAllFeeds,
+ getFetchUrl,
+ getSourceName,
+ formatDate,
+ getCategoryColor,
+ getCategoryLabel,
+} from "./utils";
diff --git a/src/components/overlays/notificationsModal/types/index.ts b/src/components/overlays/notificationsModal/types/index.ts
new file mode 100644
index 00000000..3c589ac0
--- /dev/null
+++ b/src/components/overlays/notificationsModal/types/index.ts
@@ -0,0 +1,52 @@
+export interface NotificationItem {
+ guid: string;
+ title: string;
+ link: string;
+ description: string;
+ pubDate: string;
+ category: string;
+ source?: string;
+}
+
+export interface NotificationModalProps {
+ id: string;
+}
+
+export type ModalView = "list" | "detail" | "settings";
+
+export interface DetailViewProps {
+ selectedNotification: NotificationItem;
+ goBackToList: () => void;
+ getCategoryColor: (category: string) => string;
+ getCategoryLabel: (category: string) => string;
+ formatDate: (dateString: string) => string;
+ isRead: boolean;
+ toggleReadStatus: () => void;
+}
+
+export interface SettingsViewProps {
+ autoReadDays: number;
+ setAutoReadDays: (days: number) => void;
+ customFeeds: string[];
+ setCustomFeeds: (feeds: string[]) => void;
+ markAllAsUnread: () => void;
+ onClose: () => void;
+}
+
+export interface ListViewProps {
+ notifications: NotificationItem[];
+ readNotifications: Set;
+ unreadCount: number;
+ loading: boolean;
+ error: string | null;
+ containerRef: React.RefObject;
+ markAllAsRead: () => void;
+ markAllAsUnread: () => void;
+ isShiftHeld: boolean;
+ onRefresh: () => void;
+ onOpenSettings: () => void;
+ openNotificationDetail: (notification: NotificationItem) => void;
+ getCategoryColor: (category: string) => string;
+ getCategoryLabel: (category: string) => string;
+ formatDate: (dateString: string) => string;
+}
diff --git a/src/components/overlays/notificationsModal/utils/index.ts b/src/components/overlays/notificationsModal/utils/index.ts
new file mode 100644
index 00000000..8641a027
--- /dev/null
+++ b/src/components/overlays/notificationsModal/utils/index.ts
@@ -0,0 +1,102 @@
+import { proxiedFetch } from "@/backend/helpers/fetch";
+
+const DEFAULT_FEEDS = ["/notifications.xml"];
+
+export const getAllFeeds = (): string[] => {
+ try {
+ const savedCustomFeeds = localStorage.getItem("notification-custom-feeds");
+ if (savedCustomFeeds) {
+ const customFeeds = JSON.parse(savedCustomFeeds);
+ return [...DEFAULT_FEEDS, ...customFeeds];
+ }
+ } catch (e) {
+ // Silently fail and return default feeds
+ }
+ return DEFAULT_FEEDS;
+};
+
+export const getFetchUrl = (feedUrl: string): string => {
+ if (feedUrl.startsWith("/")) {
+ return feedUrl;
+ }
+ return feedUrl;
+};
+
+// New function to fetch RSS feeds using proxiedFetch
+export const fetchRssFeed = async (feedUrl: string): Promise => {
+ if (feedUrl.startsWith("/")) {
+ // For local feeds, use regular fetch
+ const response = await fetch(feedUrl);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return response.text();
+ }
+ // For external feeds, use proxiedFetch
+ const response = await proxiedFetch(feedUrl, {
+ responseType: "text",
+ });
+ return response as string;
+};
+
+export const getSourceName = (feedUrl: string): string => {
+ if (feedUrl === "/notifications.xml") {
+ return "P-Stream";
+ }
+
+ try {
+ const url = new URL(feedUrl);
+ return url.hostname.replace("www.", "");
+ } catch {
+ return "Unknown";
+ }
+};
+
+export const formatDate = (dateString: string) => {
+ try {
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return dateString;
+ }
+};
+
+export const getCategoryColor = (category: string) => {
+ if (!category || category.trim() === "") {
+ return "";
+ }
+
+ switch (category.toLowerCase()) {
+ case "announcement":
+ return "bg-blue-500";
+ case "feature":
+ return "bg-green-500";
+ case "update":
+ return "bg-yellow-500";
+ case "bugfix":
+ return "bg-red-500";
+ default:
+ return "";
+ }
+};
+
+export const getCategoryLabel = (category: string) => {
+ switch (category.toLowerCase()) {
+ case "announcement":
+ return "Announcement";
+ case "feature":
+ return "New Feature";
+ case "update":
+ return "Update";
+ case "bugfix":
+ return "Bug Fix";
+ default:
+ return category;
+ }
+};
diff --git a/src/components/player/atoms/Episodes.tsx b/src/components/player/atoms/Episodes.tsx
index 17a028ce..ce9385ef 100644
--- a/src/components/player/atoms/Episodes.tsx
+++ b/src/components/player/atoms/Episodes.tsx
@@ -447,7 +447,7 @@ export function EpisodesView({
E{ep.number}
{!isAired && (
-
+
{ep.air_date
? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})`
: `(${t("media.unreleased")})`}
@@ -575,7 +575,7 @@ export function EpisodesView({
E{ep.number}
{!isAired && (
-
+
{ep.air_date
? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})`
: `(${t("media.unreleased")})`}
diff --git a/src/components/player/atoms/SpeedChangedPopout.tsx b/src/components/player/atoms/SpeedChangedPopout.tsx
new file mode 100644
index 00000000..98194bd2
--- /dev/null
+++ b/src/components/player/atoms/SpeedChangedPopout.tsx
@@ -0,0 +1,46 @@
+import { t } from "i18next";
+
+import { Icon, Icons } from "@/components/Icon";
+import { Flare } from "@/components/utils/Flare";
+import { Transition } from "@/components/utils/Transition";
+import { useOverlayStack } from "@/stores/interface/overlayStack";
+import { usePlayerStore } from "@/stores/player/store";
+
+export function SpeedChangedPopout() {
+ const isSpeedBoosted = usePlayerStore((s) => s.interface.isSpeedBoosted);
+ const showSpeedIndicator = usePlayerStore(
+ (s) => s.interface.showSpeedIndicator,
+ );
+ const currentOverlay = useOverlayStack((s) => s.currentOverlay);
+ const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
+
+ return (
+
+
+
+
+
+
+
+ {isSpeedBoosted
+ ? t("player.menus.playback.speedBoosted")
+ : t("player.menus.playback.speedUnboosted", {
+ speed: playbackRate,
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/components/player/atoms/index.ts b/src/components/player/atoms/index.ts
index e563c6c7..7d6fbb8b 100644
--- a/src/components/player/atoms/index.ts
+++ b/src/components/player/atoms/index.ts
@@ -18,3 +18,4 @@ export * from "./NextEpisodeButton";
export * from "./Chromecast";
export * from "./CastingNotification";
export * from "./Captions";
+export * from "./SpeedChangedPopout";
diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx
index 3808a360..2c3fcbeb 100644
--- a/src/components/player/atoms/settings/CaptionsView.tsx
+++ b/src/components/player/atoms/settings/CaptionsView.tsx
@@ -10,6 +10,7 @@ import { Icon, Icons } from "@/components/Icon";
import { useCaptions } from "@/components/player/hooks/useCaptions";
import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
+import { fixUTF8Encoding } from "@/components/player/utils/captions";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import { useSubtitleStore } from "@/stores/subtitles";
@@ -151,13 +152,14 @@ export function CustomCaptionOption() {
if (!event.target || typeof event.target.result !== "string")
return;
- // Ensure the data is in UTF-8
+ // Ensure the data is in UTF-8 and fix any encoding issues
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8");
const utf8Bytes = encoder.encode(event.target.result);
const utf8Data = decoder.decode(utf8Bytes);
+ const fixedData = fixUTF8Encoding(utf8Data);
- const converted = convert(utf8Data, "srt");
+ const converted = convert(fixedData, "srt");
setCaption({
language: "custom",
srtData: converted,
@@ -203,13 +205,14 @@ export function CaptionsView({
reader.addEventListener("load", (e) => {
if (!e.target || typeof e.target.result !== "string") return;
- // Ensure the data is in UTF-8
+ // Ensure the data is in UTF-8 and fix any encoding issues
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8");
const utf8Bytes = encoder.encode(e.target.result);
const utf8Data = decoder.decode(utf8Bytes);
+ const fixedData = fixUTF8Encoding(utf8Data);
- const converted = convert(utf8Data, "srt");
+ const converted = convert(fixedData, "srt");
setCaption({
language: "custom",
diff --git a/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx b/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx
index a7cfca12..15150654 100644
--- a/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx
+++ b/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx
@@ -27,6 +27,7 @@ export function OpenSubtitlesCaptionView({
const { selectCaptionById } = useCaptions();
const captionList = usePlayerStore((s) => s.captionList);
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
+ const addExternalSubtitles = usePlayerStore((s) => s.addExternalSubtitles);
const captions = useMemo(
() =>
@@ -48,6 +49,10 @@ export function OpenSubtitlesCaptionView({
[selectCaptionById, setCurrentlyDownloading],
);
+ const [refreshReq, startRefresh] = useAsyncFn(async () => {
+ return addExternalSubtitles();
+ }, [addExternalSubtitles]);
+
const content = subtitleList.length
? subtitleList.map((v) => {
return (
@@ -98,6 +103,14 @@ export function OpenSubtitlesCaptionView({
{t("player.menus.subtitles.empty")}
+
) : (
diff --git a/src/components/player/internals/InfoButton.tsx b/src/components/player/internals/InfoButton.tsx
index 9731de19..093c08fa 100644
--- a/src/components/player/internals/InfoButton.tsx
+++ b/src/components/player/internals/InfoButton.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Icons } from "@/components/Icon";
-import { DetailsModal } from "@/components/overlays/details/DetailsModal";
+import { DetailsModal } from "@/components/overlays/detailsModal";
import { useModal } from "@/components/overlays/Modal";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx
index 9d395731..b68a05dc 100644
--- a/src/components/player/internals/KeyboardEvents.tsx
+++ b/src/components/player/internals/KeyboardEvents.tsx
@@ -31,6 +31,17 @@ export function KeyboardEvents() {
const volumeDebounce = useRef | undefined>();
const subtitleDebounce = useRef | undefined>();
+ // Speed boost
+ const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted);
+ const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator);
+ const speedIndicatorTimeoutRef = useRef<
+ ReturnType | undefined
+ >();
+ const boostTimeoutRef = useRef | undefined>();
+ const isPendingBoostRef = useRef(false);
+ const previousRateRef = useRef(1);
+ const isSpaceHeldRef = useRef(false);
+
const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay);
const dataRef = useRef({
@@ -51,6 +62,13 @@ export function KeyboardEvents() {
setShowDelayIndicator,
setCurrentOverlay,
isInWatchParty,
+ previousRateRef,
+ isSpaceHeldRef,
+ setSpeedBoosted,
+ setShowSpeedIndicator,
+ speedIndicatorTimeoutRef,
+ boostTimeoutRef,
+ isPendingBoostRef,
});
useEffect(() => {
@@ -72,6 +90,13 @@ export function KeyboardEvents() {
setShowDelayIndicator,
setCurrentOverlay,
isInWatchParty,
+ previousRateRef,
+ isSpaceHeldRef,
+ setSpeedBoosted,
+ setShowSpeedIndicator,
+ speedIndicatorTimeoutRef,
+ boostTimeoutRef,
+ isPendingBoostRef,
};
}, [
setShowVolume,
@@ -91,10 +116,12 @@ export function KeyboardEvents() {
setShowDelayIndicator,
setCurrentOverlay,
isInWatchParty,
+ setSpeedBoosted,
+ setShowSpeedIndicator,
]);
useEffect(() => {
- const keyEventHandler = (evt: KeyboardEvent) => {
+ const keydownEventHandler = (evt: KeyboardEvent) => {
if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT")
return;
@@ -132,6 +159,81 @@ export function KeyboardEvents() {
if (next) dataRef.current.display?.setPlaybackRate(next);
}
+ // Handle spacebar press for play/pause and hold for 2x speed - disabled in watch party
+ if (k === " " && !dataRef.current.isInWatchParty) {
+ // Skip if a button is targeted
+ if (
+ evt.target &&
+ (evt.target as HTMLInputElement).nodeName === "BUTTON"
+ ) {
+ return;
+ }
+
+ // Prevent the default spacebar behavior
+ evt.preventDefault();
+
+ // If already paused, play the video and return
+ if (dataRef.current.mediaPlaying.isPaused) {
+ dataRef.current.display?.play();
+ return;
+ }
+
+ // If we're already holding space, don't trigger boost again
+ if (dataRef.current.isSpaceHeldRef.current) {
+ return;
+ }
+
+ // Save current rate
+ dataRef.current.previousRateRef.current =
+ dataRef.current.mediaPlaying.playbackRate;
+
+ // Set pending boost flag
+ dataRef.current.isPendingBoostRef.current = true;
+
+ // Add delay before boosting speed
+ if (dataRef.current.boostTimeoutRef.current) {
+ clearTimeout(dataRef.current.boostTimeoutRef.current);
+ }
+
+ dataRef.current.boostTimeoutRef.current = setTimeout(() => {
+ // Only apply boost if the key is still held down
+ if (dataRef.current.isPendingBoostRef.current) {
+ dataRef.current.isSpaceHeldRef.current = true;
+ dataRef.current.isPendingBoostRef.current = false;
+
+ // Show speed indicator
+ dataRef.current.setSpeedBoosted(true);
+ dataRef.current.setShowSpeedIndicator(true);
+ dataRef.current.setCurrentOverlay("speed");
+
+ // Clear any existing timeout
+ if (dataRef.current.speedIndicatorTimeoutRef.current) {
+ clearTimeout(dataRef.current.speedIndicatorTimeoutRef.current);
+ }
+
+ dataRef.current.display?.setPlaybackRate(2);
+ }
+ }, 300); // 300ms delay before boost takes effect
+ }
+
+ // Handle spacebar press for play/pause only in watch party mode
+ if (k === " " && dataRef.current.isInWatchParty) {
+ // Skip if a button is targeted
+ if (
+ evt.target &&
+ (evt.target as HTMLInputElement).nodeName === "BUTTON"
+ ) {
+ return;
+ }
+
+ // Prevent the default spacebar behavior
+ evt.preventDefault();
+
+ // Simple play/pause toggle
+ const action = dataRef.current.mediaPlaying.isPaused ? "play" : "pause";
+ dataRef.current.display?.[action]();
+ }
+
// Video progress
if (k === "ArrowRight")
dataRef.current.display?.setTime(dataRef.current.time + 5);
@@ -148,7 +250,10 @@ export function KeyboardEvents() {
// Utils
if (keyL === "f") dataRef.current.display?.toggleFullscreen();
- if (k === " " || keyL === "k") {
+
+ // Remove duplicate spacebar handler that was conflicting
+ // with our improved implementation
+ if (keyL === "k" && !dataRef.current.isSpaceHeldRef.current) {
if (
evt.target &&
(evt.target as HTMLInputElement).nodeName === "BUTTON"
@@ -193,10 +298,53 @@ export function KeyboardEvents() {
}, 3000);
}
};
- window.addEventListener("keydown", keyEventHandler);
+
+ const keyupEventHandler = (evt: KeyboardEvent) => {
+ const k = evt.key;
+
+ // Handle spacebar release - only handle speed boost logic when not in watch party
+ if (k === " " && !dataRef.current.isInWatchParty) {
+ // If we haven't applied the boost yet but were about to, cancel it
+ if (dataRef.current.isPendingBoostRef.current) {
+ dataRef.current.isPendingBoostRef.current = false;
+ if (dataRef.current.boostTimeoutRef.current) {
+ clearTimeout(dataRef.current.boostTimeoutRef.current);
+ }
+
+ // The space key was released quickly, so trigger play/pause
+ const action = dataRef.current.mediaPlaying.isPaused
+ ? "play"
+ : "pause";
+ dataRef.current.display?.[action]();
+ } else if (dataRef.current.isSpaceHeldRef.current) {
+ // We were in boost mode, restore previous rate
+ dataRef.current.display?.setPlaybackRate(
+ dataRef.current.previousRateRef.current,
+ );
+ dataRef.current.isSpaceHeldRef.current = false;
+
+ // Update UI state
+ dataRef.current.setSpeedBoosted(false);
+
+ // Set a timeout to hide the speed indicator
+ if (dataRef.current.speedIndicatorTimeoutRef.current) {
+ clearTimeout(dataRef.current.speedIndicatorTimeoutRef.current);
+ }
+
+ dataRef.current.speedIndicatorTimeoutRef.current = setTimeout(() => {
+ dataRef.current.setShowSpeedIndicator(false);
+ dataRef.current.setCurrentOverlay(null);
+ }, 1500);
+ }
+ }
+ };
+
+ window.addEventListener("keydown", keydownEventHandler);
+ window.addEventListener("keyup", keyupEventHandler);
return () => {
- window.removeEventListener("keydown", keyEventHandler);
+ window.removeEventListener("keydown", keydownEventHandler);
+ window.removeEventListener("keyup", keyupEventHandler);
};
}, []);
diff --git a/src/components/player/internals/VideoClickTarget.tsx b/src/components/player/internals/VideoClickTarget.tsx
index 0d92a225..de736ad8 100644
--- a/src/components/player/internals/VideoClickTarget.tsx
+++ b/src/components/player/internals/VideoClickTarget.tsx
@@ -1,8 +1,9 @@
import classNames from "classnames";
-import { PointerEvent, useCallback } from "react";
+import { PointerEvent, useCallback, useRef, useState } from "react";
import { useEffectOnce, useTimeoutFn } from "react-use";
import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer";
+import { useOverlayStack } from "@/stores/interface/overlayStack";
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
@@ -10,10 +11,15 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
const show = useShouldShowVideoElement();
const display = usePlayerStore((s) => s.display);
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
+ const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
const updateInterfaceHovering = usePlayerStore(
(s) => s.updateInterfaceHovering,
);
+ const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted);
+ const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator);
const hovering = usePlayerStore((s) => s.interface.hovering);
+ const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay);
+
const [_, cancel, reset] = useTimeoutFn(() => {
updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
}, 3000);
@@ -21,12 +27,31 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
cancel();
});
+ const previousRateRef = useRef(playbackRate);
+ const isHoldingRef = useRef(false);
+ const speedIndicatorTimeoutRef = useRef(null);
+ const boostTimeoutRef = useRef(null);
+ const [isPendingBoost, setIsPendingBoost] = useState(false);
+
const toggleFullscreen = useCallback(() => {
display?.toggleFullscreen();
}, [display]);
const togglePause = useCallback(
(e: PointerEvent) => {
+ // Don't toggle pause if holding for speed change
+ if (isHoldingRef.current) {
+ isHoldingRef.current = false;
+ return;
+ }
+
+ // Cancel any pending boost if we're clicking to pause
+ if (isPendingBoost) {
+ clearTimeout(boostTimeoutRef.current!);
+ setIsPendingBoost(false);
+ isHoldingRef.current = false;
+ }
+
// pause on mouse click
if (e.pointerType === "mouse") {
if (e.button !== 0) return;
@@ -44,9 +69,136 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
cancel();
}
},
- [display, isPaused, hovering, updateInterfaceHovering, reset, cancel],
+ [
+ display,
+ isPaused,
+ hovering,
+ updateInterfaceHovering,
+ reset,
+ cancel,
+ isPendingBoost,
+ ],
);
+ const handlePointerDown = useCallback(
+ (e: PointerEvent) => {
+ if (e.pointerType === "mouse" && e.button === 0 && !isPaused) {
+ // Store current rate before changing
+ previousRateRef.current = playbackRate;
+
+ // Set a timeout before actually boosting speed
+ if (boostTimeoutRef.current) {
+ clearTimeout(boostTimeoutRef.current);
+ }
+
+ setIsPendingBoost(true);
+
+ boostTimeoutRef.current = setTimeout(() => {
+ // Only apply boost if we're still holding down
+ isHoldingRef.current = true;
+ setIsPendingBoost(false);
+
+ // Show speed indicator
+ setSpeedBoosted(true);
+ setShowSpeedIndicator(true);
+ setCurrentOverlay("speed");
+
+ if (speedIndicatorTimeoutRef.current) {
+ clearTimeout(speedIndicatorTimeoutRef.current);
+ }
+
+ // Set to 2x speed
+ display?.setPlaybackRate(2);
+ }, 300); // 300ms delay before boost takes effect
+ }
+ },
+ [
+ display,
+ playbackRate,
+ isPaused,
+ setSpeedBoosted,
+ setShowSpeedIndicator,
+ setCurrentOverlay,
+ ],
+ );
+
+ const handlePointerUp = useCallback(
+ (e: PointerEvent) => {
+ // If we have a pending boost that hasn't activated yet, clear it
+ if (isPendingBoost) {
+ clearTimeout(boostTimeoutRef.current!);
+ setIsPendingBoost(false);
+ togglePause(e);
+ return;
+ }
+
+ if (isHoldingRef.current && e.pointerType === "mouse" && e.button === 0) {
+ // Restore previous rate
+ display?.setPlaybackRate(previousRateRef.current);
+ isHoldingRef.current = false;
+
+ // Update state for speed indicator
+ setSpeedBoosted(false);
+
+ // Set a timeout to hide the speed indicator
+ if (speedIndicatorTimeoutRef.current) {
+ clearTimeout(speedIndicatorTimeoutRef.current);
+ }
+
+ speedIndicatorTimeoutRef.current = setTimeout(() => {
+ setShowSpeedIndicator(false);
+ setCurrentOverlay(null);
+ speedIndicatorTimeoutRef.current = null;
+ }, 1500);
+ } else {
+ // Regular click handler
+ togglePause(e);
+ }
+ },
+ [
+ display,
+ togglePause,
+ setSpeedBoosted,
+ setShowSpeedIndicator,
+ setCurrentOverlay,
+ isPendingBoost,
+ ],
+ );
+
+ // Handle case where mouse leaves the player while still pressed
+ const handlePointerLeave = useCallback(() => {
+ // Clear pending boost if mouse leaves
+ if (isPendingBoost) {
+ clearTimeout(boostTimeoutRef.current!);
+ setIsPendingBoost(false);
+ }
+
+ if (isHoldingRef.current) {
+ display?.setPlaybackRate(previousRateRef.current);
+ isHoldingRef.current = false;
+
+ // Update state for speed indicator
+ setSpeedBoosted(false);
+
+ // Set a timeout to hide the speed indicator
+ if (speedIndicatorTimeoutRef.current) {
+ clearTimeout(speedIndicatorTimeoutRef.current);
+ }
+
+ speedIndicatorTimeoutRef.current = setTimeout(() => {
+ setShowSpeedIndicator(false);
+ setCurrentOverlay(null);
+ speedIndicatorTimeoutRef.current = null;
+ }, 1500);
+ }
+ }, [
+ display,
+ setSpeedBoosted,
+ setShowSpeedIndicator,
+ setCurrentOverlay,
+ isPendingBoost,
+ ]);
+
if (!show) return null;
return (
@@ -56,7 +208,9 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
"cursor-none": !props.showingControls,
})}
onDoubleClick={toggleFullscreen}
- onPointerUp={togglePause}
+ onPointerDown={handlePointerDown}
+ onPointerUp={handlePointerUp}
+ onPointerLeave={handlePointerLeave}
/>
);
}
diff --git a/src/components/player/utils/captions.ts b/src/components/player/utils/captions.ts
index 09f79fbe..68247bb6 100644
--- a/src/components/player/utils/captions.ts
+++ b/src/components/player/utils/captions.ts
@@ -8,6 +8,63 @@ import { CaptionListItem } from "@/stores/player/slices/source";
export type CaptionCueType = ContentCaption;
export const sanitize = DOMPurify.sanitize;
+// UTF-8 character mapping for fixing corrupted special characters
+const utf8Map: Record = {
+ "ä": "ä",
+ "Ä": "Ä",
+ "ä": "ä",
+ "Ä": "Ä",
+ "ö": "ö",
+ "ö": "ö",
+ "Ã¥": "å",
+ "Ã¥": "å",
+ "é": "é",
+ "é": "é",
+ ú: "ú",
+ ú: "ú",
+ "ñ": "ñ",
+ "ñ": "ñ",
+ "á": "á",
+ "á": "á",
+ "ÃÂÂ": "Ã",
+ "ÃÂ": "Ã",
+ "ó": "ó",
+ "ó": "ó",
+ "ü": "ü",
+ "ü": "ü",
+ "ç": "ç",
+ "ç": "ç",
+ "è": "è",
+ "è": "è",
+ "ì": "ì",
+ "ì": "ì",
+ "ò": "ò",
+ "ò": "ò",
+ "ù": "ù",
+ "ù": "ù",
+ ÃÂ: "à ",
+ Ã: "à ",
+ "Â": "",
+ Â: "",
+ "Â ": "",
+};
+
+/**
+ * Fixes UTF-8 encoding issues in subtitle text
+ * Handles common cases where special characters and accents get corrupted
+ *
+ * Example:
+ * Input: "Hyvä on, ohjelma oli tässä."
+ * Output: "Hyvä on, ohjelma oli tässä."
+ */
+export function fixUTF8Encoding(text: string): string {
+ let fixedText = text;
+ Object.keys(utf8Map).forEach((bad) => {
+ fixedText = fixedText.split(bad).join(utf8Map[bad]);
+ });
+ return fixedText;
+}
+
export function captionIsVisible(
start: number,
end: number,
@@ -31,7 +88,9 @@ export function convertSubtitlesToVtt(text: string): string {
if (textTrimmed === "") {
throw new Error("Given text is empty");
}
- const vtt = convert(textTrimmed, "vtt");
+ // Fix UTF-8 encoding issues before conversion
+ const fixedText = fixUTF8Encoding(textTrimmed);
+ const vtt = convert(fixedText, "vtt");
if (detect(vtt) === "") {
throw new Error("Invalid subtitle format");
}
@@ -43,7 +102,9 @@ export function convertSubtitlesToSrt(text: string): string {
if (textTrimmed === "") {
throw new Error("Given text is empty");
}
- const srt = convert(textTrimmed, "srt");
+ // Fix UTF-8 encoding issues before conversion
+ const fixedText = fixUTF8Encoding(textTrimmed);
+ const srt = convert(fixedText, "srt");
if (detect(srt) === "") {
throw new Error("Invalid subtitle format");
}
diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts
index 3ce49163..0bd48018 100644
--- a/src/hooks/auth/useAuth.ts
+++ b/src/hooks/auth/useAuth.ts
@@ -9,6 +9,7 @@ import {
keysFromMnemonic,
signChallenge,
} from "@/backend/accounts/crypto";
+import { getGroupOrder } from "@/backend/accounts/groupOrder";
import { importBookmarks, importProgress } from "@/backend/accounts/import";
import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login";
import { progressMediaItemToInputs } from "@/backend/accounts/progress";
@@ -180,13 +181,21 @@ export function useAuth() {
throw err;
}
- const [bookmarks, progress, settings] = await Promise.all([
+ const [bookmarks, progress, settings, groupOrder] = await Promise.all([
getBookmarks(backendUrl, account),
getProgress(backendUrl, account),
getSettings(backendUrl, account),
+ getGroupOrder(backendUrl, account),
]);
- syncData(user.user, user.session, progress, bookmarks, settings);
+ syncData(
+ user.user,
+ user.session,
+ progress,
+ bookmarks,
+ settings,
+ groupOrder,
+ );
},
[backendUrl, syncData, logout],
);
diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts
index b1278168..b75a6d8a 100644
--- a/src/hooks/auth/useAuthData.ts
+++ b/src/hooks/auth/useAuthData.ts
@@ -11,6 +11,7 @@ import {
} from "@/backend/accounts/user";
import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
+import { useGroupOrderStore } from "@/stores/groupOrder";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress";
@@ -24,6 +25,7 @@ export function useAuthData() {
const setProxySet = useAuthStore((s) => s.setProxySet);
const clearBookmarks = useBookmarkStore((s) => s.clear);
const clearProgress = useProgressStore((s) => s.clear);
+ const clearGroupOrder = useGroupOrderStore((s) => s.clear);
const setTheme = useThemeStore((s) => s.setTheme);
const setAppLanguage = useLanguageStore((s) => s.setLanguage);
const importSubtitleLanguage = useSubtitleStore(
@@ -86,8 +88,15 @@ export function useAuthData() {
removeAccount();
clearBookmarks();
clearProgress();
+ clearGroupOrder();
setFebboxKey(null);
- }, [removeAccount, clearBookmarks, clearProgress, setFebboxKey]);
+ }, [
+ removeAccount,
+ clearBookmarks,
+ clearProgress,
+ clearGroupOrder,
+ setFebboxKey,
+ ]);
const syncData = useCallback(
async (
@@ -96,10 +105,15 @@ export function useAuthData() {
progress: ProgressResponse[],
bookmarks: BookmarkResponse[],
settings: SettingsResponse,
+ groupOrder: { groupOrder: string[] },
) => {
replaceBookmarks(bookmarkResponsesToEntries(bookmarks));
replaceItems(progressResponsesToEntries(progress));
+ if (groupOrder?.groupOrder) {
+ useGroupOrderStore.getState().setGroupOrder(groupOrder.groupOrder);
+ }
+
if (settings.applicationLanguage) {
setAppLanguage(settings.applicationLanguage);
}
diff --git a/src/index.tsx b/src/index.tsx
index 4a531912..2f3ecee1 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -25,6 +25,7 @@ import App from "@/setup/App";
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
import { BookmarkSyncer } from "@/stores/bookmarks/BookmarkSyncer";
+import { GroupSyncer } from "@/stores/groupOrder/GroupSyncer";
import { changeAppLanguage, useLanguageStore } from "@/stores/language";
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
@@ -185,6 +186,7 @@ root.render(
+
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index 4cd2c181..0c6f710f 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -4,8 +4,7 @@ import { useTranslation } from "react-i18next";
import { To, useNavigate } from "react-router-dom";
import { WideContainer } from "@/components/layout/WideContainer";
-import { DetailsModal } from "@/components/overlays/details/DetailsModal";
-import { useModal } from "@/components/overlays/Modal";
+import { DetailsModal } from "@/components/overlays/detailsModal";
import { useDebounce } from "@/hooks/useDebounce";
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
import { useSearchQuery } from "@/hooks/useSearchQuery";
@@ -21,6 +20,7 @@ import { WatchingPart } from "@/pages/parts/home/WatchingPart";
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
import { conf } from "@/setup/config";
+import { useOverlayStack } from "@/stores/interface/overlayStack";
import { usePreferencesStore } from "@/stores/preferences";
import { MediaItem } from "@/utils/mediaTypes";
@@ -63,7 +63,7 @@ export function HomePage() {
const [showBookmarks, setShowBookmarks] = useState(false);
const [showWatching, setShowWatching] = useState(false);
const [detailsData, setDetailsData] = useState();
- const detailsModal = useModal("details");
+ const { showModal } = useOverlayStack();
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
@@ -84,7 +84,7 @@ export function HomePage() {
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
});
- detailsModal.show();
+ showModal("details");
};
return (
@@ -102,7 +102,7 @@ export function HomePage() {
{/* Page Header */}
{enableFeatured ? (
void;
+}
+
+export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
+ const { t } = useTranslation();
+ const { t: randomT } = useRandomTranslation();
+ const emptyText = randomT(`home.search.empty`);
+ const navigate = useNavigate();
+ const progressItems = useProgressStore((s) => s.items);
+ const bookmarks = useBookmarkStore((s) => s.bookmarks);
+ const groupOrder = useGroupOrderStore((s) => s.groupOrder);
+ const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
+ const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
+ const [editing, setEditing] = useState(false);
+ const [gridRef] = useAutoAnimate();
+ const editOrderModal = useModal("bookmark-edit-order-all");
+ const [tempGroupOrder, setTempGroupOrder] = useState([]);
+ const backendUrl = useBackendUrl();
+ const account = useAuthStore((s) => s.account);
+ const [detailsData, setDetailsData] = useState();
+ const { showModal } = useOverlayStack();
+
+ const handleShowDetails = async (media: MediaItem) => {
+ if (onShowDetails) {
+ onShowDetails(media);
+ } else {
+ setDetailsData({
+ id: Number(media.id),
+ type: media.type === "movie" ? "movie" : "show",
+ });
+ showModal("details");
+ }
+ };
+
+ 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 { groupedItems, regularItems } = useMemo(() => {
+ const grouped: Record = {};
+ const regular: MediaItem[] = [];
+
+ items.forEach((item) => {
+ const bookmark = bookmarks[item.id];
+ if (Array.isArray(bookmark?.group)) {
+ bookmark.group.forEach((groupName) => {
+ if (!grouped[groupName]) {
+ grouped[groupName] = [];
+ }
+ grouped[groupName].push(item);
+ });
+ } else {
+ regular.push(item);
+ }
+ });
+
+ // Sort items within each group by date
+ Object.keys(grouped).forEach((group) => {
+ grouped[group].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 { groupedItems: grouped, regularItems: regular };
+ }, [items, bookmarks, progressItems]);
+
+ // group sorting
+ const allGroups = useMemo(() => {
+ const groups = new Set();
+
+ Object.values(bookmarks).forEach((bookmark) => {
+ if (Array.isArray(bookmark.group)) {
+ bookmark.group.forEach((group) => groups.add(group));
+ }
+ });
+
+ groups.add("bookmarks");
+
+ return Array.from(groups);
+ }, [bookmarks]);
+
+ const sortableItems = useMemo(() => {
+ const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
+
+ if (currentOrder.length === 0) {
+ return allGroups.map((group) => {
+ const { name } = parseGroupString(group);
+ return {
+ id: group,
+ name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
+ } as Item;
+ });
+ }
+
+ const orderMap = new Map(
+ currentOrder.map((group, index) => [group, index]),
+ );
+ const sortedGroups = allGroups.sort((groupA, groupB) => {
+ const orderA = orderMap.has(groupA)
+ ? orderMap.get(groupA)!
+ : Number.MAX_SAFE_INTEGER;
+ const orderB = orderMap.has(groupB)
+ ? orderMap.get(groupB)!
+ : Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ });
+
+ return sortedGroups.map((group) => {
+ const { name } = parseGroupString(group);
+ return {
+ id: group,
+ name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
+ } as Item;
+ });
+ }, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
+
+ const sortedSections = useMemo(() => {
+ const sections: Array<{
+ type: "grouped" | "regular";
+ group?: string;
+ items: MediaItem[];
+ }> = [];
+
+ const allSections = new Map();
+
+ Object.entries(groupedItems).forEach(([group, groupItems]) => {
+ allSections.set(group, groupItems);
+ });
+
+ if (regularItems.length > 0) {
+ allSections.set("bookmarks", regularItems);
+ }
+
+ if (groupOrder.length === 0) {
+ allSections.forEach((sectionItems, group) => {
+ if (group === "bookmarks") {
+ sections.push({ type: "regular", items: sectionItems });
+ } else {
+ sections.push({ type: "grouped", group, items: sectionItems });
+ }
+ });
+ } else {
+ const orderMap = new Map(
+ groupOrder.map((group, index) => [group, index]),
+ );
+
+ Array.from(allSections.entries())
+ .sort(([groupA], [groupB]) => {
+ const orderA = orderMap.has(groupA)
+ ? orderMap.get(groupA)!
+ : Number.MAX_SAFE_INTEGER;
+ const orderB = orderMap.has(groupB)
+ ? orderMap.get(groupB)!
+ : Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ })
+ .forEach(([group, sectionItems]) => {
+ if (group === "bookmarks") {
+ sections.push({ type: "regular", items: sectionItems });
+ } else {
+ sections.push({ type: "grouped", group, items: sectionItems });
+ }
+ });
+ }
+
+ return sections;
+ }, [groupedItems, regularItems, groupOrder]);
+
+ const handleEditGroupOrder = () => {
+ // Initialize with current order or default order
+ if (groupOrder.length === 0) {
+ const defaultOrder = allGroups.map((group) => group);
+ setTempGroupOrder(defaultOrder);
+ } else {
+ setTempGroupOrder([...groupOrder]);
+ }
+ editOrderModal.show();
+ };
+
+ const handleReorderClick = () => {
+ handleEditGroupOrder();
+ // Keep editing state active by setting it to true
+ setEditing(true);
+ };
+
+ const handleCancelOrder = () => {
+ editOrderModal.hide();
+ };
+
+ const handleSaveOrderClick = () => {
+ setGroupOrder(tempGroupOrder);
+ editOrderModal.hide();
+
+ // Save to backend
+ if (backendUrl && account) {
+ useGroupOrderStore
+ .getState()
+ .saveGroupOrderToBackend(backendUrl, account);
+ }
+ };
+
+ if (items.length === 0) {
+ return (
+
+
+
+
{emptyText}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t("home.bookmarks.sectionTitle")}
+
+
+ {editing && allGroups.length > 1 && (
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Grouped Bookmarks */}
+ {sortedSections.map((section) => {
+ if (section.type === "grouped") {
+ const { icon, name } = parseGroupString(section.group || "");
+ return (
+
+
+
+
+ }
+ >
+
+ {editing && allGroups.length > 1 && (
+
+ )}
+
+
+
+
+ {section.items.map((v) => (
+ ) =>
+ e.preventDefault()
+ }
+ >
+ removeBookmark(v.id)}
+ onShowDetails={handleShowDetails}
+ />
+
+ ))}
+
+
+ );
+ } // regular items
+ return (
+
+
+
+ {editing && allGroups.length > 1 && (
+
+ )}
+
+
+
+
+ {section.items.map((v) => (
+ ) =>
+ e.preventDefault()
+ }
+ >
+ removeBookmark(v.id)}
+ onShowDetails={handleShowDetails}
+ />
+
+ ))}
+
+
+ );
+ })}
+
+
+ {/* Edit Order Modal */}
+ {
+ const newOrder = newItems.map((item) => item.id);
+ setTempGroupOrder(newOrder);
+ }}
+ />
+
+ {detailsData && }
+
+
+ );
+}
diff --git a/src/pages/discover/AllMovieLists.tsx b/src/pages/discover/AllMovieLists.tsx
index ebcd3a8e..99e4553d 100644
--- a/src/pages/discover/AllMovieLists.tsx
+++ b/src/pages/discover/AllMovieLists.tsx
@@ -6,7 +6,7 @@ import { TmdbMovie, getLetterboxdLists } from "@/backend/metadata/letterboxd";
import { Icon, Icons } from "@/components/Icon";
import { WideContainer } from "@/components/layout/WideContainer";
import { MediaCard } from "@/components/media/MediaCard";
-import { DetailsModal } from "@/components/overlays/details/DetailsModal";
+import { DetailsModal } from "@/components/overlays/detailsModal";
import { useModal } from "@/components/overlays/Modal";
import { Heading1 } from "@/components/utils/Text";
import { useIsMobile } from "@/hooks/useIsMobile";
@@ -26,6 +26,11 @@ export function DiscoverMore() {
const { lastView } = useDiscoverStore();
const { isMobile } = useIsMobile();
+ // Track overflow states for Letterboxd lists
+ const [overflowStates, setOverflowStates] = useState<{
+ [key: string]: boolean;
+ }>({});
+
useEffect(() => {
const fetchLetterboxdLists = async () => {
try {
@@ -63,6 +68,41 @@ export function DiscoverMore() {
}
};
+ // Function to check overflow for a carousel
+ const checkOverflow = (element: HTMLDivElement | null, key: string) => {
+ if (!element) {
+ setOverflowStates((prev) => ({ ...prev, [key]: false }));
+ return;
+ }
+
+ const hasOverflow = element.scrollWidth > element.clientWidth;
+ setOverflowStates((prev) => ({ ...prev, [key]: hasOverflow }));
+ };
+
+ // Function to set carousel ref and check overflow
+ const setCarouselRef = (element: HTMLDivElement | null, key: string) => {
+ carouselRefs.current[key] = element;
+
+ // Check overflow after a short delay to ensure content is rendered
+ setTimeout(() => checkOverflow(element, key), 100);
+ };
+
+ // Effect to recheck overflow on window resize
+ useEffect(() => {
+ const handleResize = () => {
+ // Recheck overflow for all carousels
+ Object.keys(carouselRefs.current).forEach((key) => {
+ const element = carouselRefs.current[key];
+ if (element) {
+ checkOverflow(element, key);
+ }
+ });
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
return (
@@ -118,9 +158,7 @@ export function DiscoverMore() {
{
- carouselRefs.current[list.listUrl] = el;
- }}
+ ref={(el) => setCarouselRef(el, list.listUrl)}
onWheel={handleWheel}
>
@@ -152,6 +190,7 @@ export function DiscoverMore() {
)}
diff --git a/src/pages/discover/Discover.tsx b/src/pages/discover/Discover.tsx
index 00ed9019..0c3a8b81 100644
--- a/src/pages/discover/Discover.tsx
+++ b/src/pages/discover/Discover.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
-import { DetailsModal } from "@/components/overlays/details/DetailsModal";
+import { DetailsModal } from "@/components/overlays/detailsModal";
import { useModal } from "@/components/overlays/Modal";
import { SubPageLayout } from "../layouts/SubPageLayout";
diff --git a/src/pages/discover/MoreContent.tsx b/src/pages/discover/MoreContent.tsx
index be277694..5869b854 100644
--- a/src/pages/discover/MoreContent.tsx
+++ b/src/pages/discover/MoreContent.tsx
@@ -9,7 +9,7 @@ import { Icon, Icons } from "@/components/Icon";
import { WideContainer } from "@/components/layout/WideContainer";
import { MediaCard } from "@/components/media/MediaCard";
import { MediaGrid } from "@/components/media/MediaGrid";
-import { DetailsModal } from "@/components/overlays/details/DetailsModal";
+import { DetailsModal } from "@/components/overlays/detailsModal";
import { useModal } from "@/components/overlays/Modal";
import { Heading1 } from "@/components/utils/Text";
import {
diff --git a/src/pages/discover/components/CarouselNavButtons.tsx b/src/pages/discover/components/CarouselNavButtons.tsx
index bd9d3261..7b6030aa 100644
--- a/src/pages/discover/components/CarouselNavButtons.tsx
+++ b/src/pages/discover/components/CarouselNavButtons.tsx
@@ -6,6 +6,7 @@ interface CarouselNavButtonsProps {
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
+ hasOverflow?: boolean;
}
interface NavButtonProps {
@@ -42,6 +43,7 @@ function NavButton({ direction, onClick }: NavButtonProps) {
export function CarouselNavButtons({
categorySlug,
carouselRefs,
+ hasOverflow = true,
}: CarouselNavButtonsProps) {
const handleScroll = (direction: "left" | "right") => {
const carousel = carouselRefs.current[categorySlug];
@@ -74,6 +76,11 @@ export function CarouselNavButtons({
});
};
+ // Don't render buttons if there's no overflow
+ if (!hasOverflow) {
+ return null;
+ }
+
return (
<>
handleScroll("left")} />
diff --git a/src/pages/discover/components/FeaturedCarousel.tsx b/src/pages/discover/components/FeaturedCarousel.tsx
index 960a7cbc..c9730482 100644
--- a/src/pages/discover/components/FeaturedCarousel.tsx
+++ b/src/pages/discover/components/FeaturedCarousel.tsx
@@ -142,12 +142,13 @@ export function FeaturedCarousel({
const enableImageLogos = usePreferencesStore(
(state) => state.enableImageLogos,
);
- const userLanguage = useLanguageStore.getState().language;
+ const userLanguage = useLanguageStore((s) => s.language);
const formattedLanguage = getTmdbLanguageCode(userLanguage);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const [releaseInfo, setReleaseInfo] = useState(
null,
);
+ const [contentOpacity, setContentOpacity] = useState(1);
const currentMedia = media[currentIndex];
@@ -198,7 +199,12 @@ export function FeaturedCarousel({
useEffect(() => {
const fetchFeaturedMedia = async () => {
setIsLoading(true);
- setLogoUrl(undefined); // Clear logo when media changes
+ // Clear all previous data when transitioning
+ setLogoUrl(undefined);
+ setImdbRatings({});
+ setReleaseInfo(null);
+ setCurrentIndex(0);
+ setContentOpacity(1);
if (logoFetchController.current) {
logoFetchController.current.abort(); // Cancel any in-progress logo fetches
}
@@ -372,7 +378,18 @@ export function FeaturedCarousel({
}, [formattedLanguage, effectiveCategory]);
const handlePrevSlide = () => {
- setCurrentIndex((prev) => (prev - 1 + media.length) % media.length);
+ setContentOpacity(0);
+ setImdbRatings({});
+ setReleaseInfo(null);
+
+ // Wait for fade out, then change index and fade in
+ setTimeout(() => {
+ setCurrentIndex((prev) => (prev - 1 + media.length) % media.length);
+ // Clear logo after index change so new logo can load
+ setLogoUrl(undefined);
+ setTimeout(() => setContentOpacity(1), 100);
+ }, 150);
+
// Reset autoplay timer
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
@@ -385,7 +402,18 @@ export function FeaturedCarousel({
};
const handleNextSlide = () => {
- setCurrentIndex((prev) => (prev + 1) % media.length);
+ setContentOpacity(0);
+ setImdbRatings({});
+ setReleaseInfo(null);
+
+ // Wait for fade out, then change index and fade in
+ setTimeout(() => {
+ setCurrentIndex((prev) => (prev + 1) % media.length);
+ // Clear logo after index change so new logo can load
+ setLogoUrl(undefined);
+ setTimeout(() => setContentOpacity(1), 100);
+ }, 150);
+
// Reset autoplay timer
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
@@ -482,7 +510,17 @@ export function FeaturedCarousel({
useEffect(() => {
if (isAutoPlaying && media.length > 0) {
autoPlayInterval.current = setInterval(() => {
- setCurrentIndex((prev) => (prev + 1) % media.length);
+ setContentOpacity(0);
+ setImdbRatings({});
+ setReleaseInfo(null);
+
+ // Wait for fade out, then change index and fade in
+ setTimeout(() => {
+ setCurrentIndex((prev) => (prev + 1) % media.length);
+ // Clear logo after index change so new logo can load
+ setLogoUrl(undefined);
+ setTimeout(() => setContentOpacity(1), 100);
+ }, 150);
}, SLIDE_DURATION);
}
@@ -639,7 +677,18 @@ export function FeaturedCarousel({
key={`dot-${item.id}`}
type="button"
onClick={() => {
- setCurrentIndex(index);
+ setContentOpacity(0);
+ setImdbRatings({});
+ setReleaseInfo(null);
+
+ // Wait for fade out, then change index and fade in
+ setTimeout(() => {
+ setCurrentIndex(index);
+ // Clear logo after index change so new logo can load
+ setLogoUrl(undefined);
+ setTimeout(() => setContentOpacity(1), 100);
+ }, 150);
+
// Reset autoplay timer when clicking dots
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
@@ -663,9 +712,10 @@ export function FeaturedCarousel({
{/* Content Overlay */}
diff --git a/src/pages/discover/components/MediaCarousel.tsx b/src/pages/discover/components/MediaCarousel.tsx
index bba0c086..e9f4e214 100644
--- a/src/pages/discover/components/MediaCarousel.tsx
+++ b/src/pages/discover/components/MediaCarousel.tsx
@@ -110,6 +110,9 @@ export function MediaCarousel({
const { isMobile } = useIsMobile();
const browser = !!window.chrome;
+ // Track overflow state
+ const [hasOverflow, setHasOverflow] = useState(false);
+
// State for selected options
const [selectedProviderId, setSelectedProviderId] = useState
("");
const [selectedProviderName, setSelectedProviderName] = useState("");
@@ -142,22 +145,26 @@ export function MediaCarousel({
});
// Handle provider/genre selection
- const handleProviderChange = (id: string, name: string) => {
+ const handleProviderChange = React.useCallback((id: string, name: string) => {
setSelectedProviderId(id);
setSelectedProviderName(name);
- };
+ }, []);
- const handleGenreChange = (id: string, name: string) => {
+ const handleGenreChange = React.useCallback((id: string, name: string) => {
setSelectedGenreId(id);
setSelectedGenreName(name);
- };
+ }, []);
// Get related buttons based on type
- const relatedButtons = showProviders
- ? providers.map((p) => ({ id: p.id, name: p.name }))
- : showGenres
- ? genres.map((g) => ({ id: g.id.toString(), name: g.name }))
- : undefined;
+ const relatedButtons = React.useMemo(() => {
+ if (showProviders) {
+ return providers.map((p) => ({ id: p.id, name: p.name }));
+ }
+ if (showGenres) {
+ return genres.map((g) => ({ id: g.id.toString(), name: g.name }));
+ }
+ return undefined;
+ }, [showProviders, showGenres, providers, genres]);
// Set initial provider/genre selection
useEffect(() => {
@@ -174,14 +181,16 @@ export function MediaCarousel({
genres,
selectedProviderId,
selectedGenreId,
+ handleProviderChange,
+ handleGenreChange,
]);
// Get the appropriate button click handler
- const onButtonClick = showProviders
- ? handleProviderChange
- : showGenres
- ? handleGenreChange
- : undefined;
+ const onButtonClick = React.useMemo(() => {
+ if (showProviders) return handleProviderChange;
+ if (showGenres) return handleGenreChange;
+ return undefined;
+ }, [showProviders, showGenres, handleProviderChange, handleGenreChange]);
// Split buttons into visible and dropdown based on window width
const { visibleButtons, dropdownButtons } = React.useMemo(() => {
@@ -195,14 +204,21 @@ export function MediaCarousel({
}, [relatedButtons, windowWidth]);
// Determine content type and ID based on selection
- const contentType =
- showProviders && selectedProviderId
- ? "provider"
- : showGenres && selectedGenreId
- ? "genre"
- : showRecommendations && selectedRecommendationId
- ? "recommendations"
- : content.type;
+ const contentType = React.useMemo(() => {
+ if (showProviders && selectedProviderId) return "provider";
+ if (showGenres && selectedGenreId) return "genre";
+ if (showRecommendations && selectedRecommendationId)
+ return "recommendations";
+ return content.type;
+ }, [
+ showProviders,
+ selectedProviderId,
+ showGenres,
+ selectedGenreId,
+ showRecommendations,
+ selectedRecommendationId,
+ content.type,
+ ]);
// Fetch media using our hook
const { media, sectionTitle } = useDiscoverMedia({
@@ -217,17 +233,21 @@ export function MediaCarousel({
});
// Find active button
- const activeButton = relatedButtons?.find(
- (btn) =>
- btn.name === selectedGenre?.name ||
- btn.name === sectionTitle.split(" on ")[1],
- );
+ const activeButton = React.useMemo(() => {
+ return relatedButtons?.find(
+ (btn) =>
+ btn.name === selectedGenre?.name ||
+ btn.name === sectionTitle.split(" on ")[1],
+ );
+ }, [relatedButtons, selectedGenre?.name, sectionTitle]);
// Convert buttons to dropdown options
- const dropdownOptions: OptionItem[] = dropdownButtons.map((button) => ({
- id: button.id,
- name: button.name,
- }));
+ const dropdownOptions: OptionItem[] = React.useMemo(() => {
+ return dropdownButtons.map((button) => ({
+ id: button.id,
+ name: button.name,
+ }));
+ }, [dropdownButtons]);
// Set selected genre if active button is in dropdown
React.useEffect(() => {
@@ -255,50 +275,100 @@ export function MediaCarousel({
}
}, [showRecommendations, recommendationSources, selectedRecommendationId]);
- const categorySlug = `${sectionTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
- let isScrolling = false;
+ const categorySlug = React.useMemo(() => {
+ return `${sectionTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
+ }, [sectionTitle, isTVShow]);
- const handleWheel = (e: React.WheelEvent) => {
- if (isScrolling) return;
- isScrolling = true;
-
- if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- e.stopPropagation();
- e.preventDefault();
+ // Function to check overflow for the carousel
+ const checkOverflow = React.useCallback((element: HTMLDivElement | null) => {
+ if (!element) {
+ setHasOverflow(false);
+ return;
}
- if (browser) {
- setTimeout(() => {
- isScrolling = false;
- }, 345);
- } else {
- isScrolling = false;
- }
- };
+ const hasHorizontalOverflow = element.scrollWidth > element.clientWidth;
+ setHasOverflow(hasHorizontalOverflow);
+ }, []);
- const handleMoreClick = () => {
+ // Function to set carousel ref and check overflow
+ const setCarouselRef = React.useCallback(
+ (element: HTMLDivElement | null) => {
+ carouselRefs.current[categorySlug] = element;
+
+ // Check overflow after a short delay to ensure content is rendered
+ setTimeout(() => checkOverflow(element), 100);
+ },
+ [carouselRefs, categorySlug, checkOverflow],
+ );
+
+ // Effect to recheck overflow on window resize
+ useEffect(() => {
+ const handleResize = () => {
+ const element = carouselRefs.current[categorySlug];
+ if (element) {
+ checkOverflow(element);
+ }
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [carouselRefs, categorySlug, checkOverflow]);
+ const isScrollingRef = React.useRef(false);
+
+ const handleWheel = React.useCallback(
+ (e: React.WheelEvent) => {
+ if (isScrollingRef.current) return;
+ isScrollingRef.current = true;
+
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+
+ if (browser) {
+ setTimeout(() => {
+ isScrollingRef.current = false;
+ }, 345);
+ } else {
+ isScrollingRef.current = false;
+ }
+ },
+ [browser],
+ );
+
+ const handleMoreClick = React.useCallback(() => {
setLastView({
url: window.location.pathname,
scrollPosition: window.scrollY,
});
- };
+ }, [setLastView]);
// Generate more link
- const generatedMoreLink =
- moreLink ||
- (() => {
- const baseLink = `/discover/more`;
- if (showProviders && selectedProviderId) {
- return `${baseLink}/provider/${selectedProviderId}/${mediaType}`;
- }
- if (showGenres && selectedGenreId) {
- return `${baseLink}/genre/${selectedGenreId}/${mediaType}`;
- }
- if (showRecommendations && selectedRecommendationId) {
- return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`;
- }
- return `${baseLink}/${content.type}/${mediaType}`;
- })();
+ const generatedMoreLink = React.useMemo(() => {
+ if (moreLink) return moreLink;
+
+ const baseLink = `/discover/more`;
+ if (showProviders && selectedProviderId) {
+ return `${baseLink}/provider/${selectedProviderId}/${mediaType}`;
+ }
+ if (showGenres && selectedGenreId) {
+ return `${baseLink}/genre/${selectedGenreId}/${mediaType}`;
+ }
+ if (showRecommendations && selectedRecommendationId) {
+ return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`;
+ }
+ return `${baseLink}/${content.type}/${mediaType}`;
+ }, [
+ moreLink,
+ showProviders,
+ selectedProviderId,
+ showGenres,
+ selectedGenreId,
+ showRecommendations,
+ selectedRecommendationId,
+ mediaType,
+ content.type,
+ ]);
// Loading state
if (!isIntersecting || !sectionTitle) {
@@ -498,9 +568,7 @@ export function MediaCarousel({
{
- carouselRefs.current[categorySlug] = el;
- }}
+ ref={setCarouselRef}
onWheel={handleWheel}
>
@@ -555,6 +623,7 @@ export function MediaCarousel({
)}
diff --git a/src/pages/discover/components/RandomMovieButton.tsx b/src/pages/discover/components/RandomMovieButton.tsx
index bcfeecc0..a5c7114f 100644
--- a/src/pages/discover/components/RandomMovieButton.tsx
+++ b/src/pages/discover/components/RandomMovieButton.tsx
@@ -18,7 +18,7 @@ export function RandomMovieButton() {
useState(null);
const [movies, setMovies] = useState([]);
const navigate = useNavigate();
- const userLanguage = useLanguageStore.getState().language;
+ const userLanguage = useLanguageStore((s) => s.language);
const formattedLanguage = getTmdbLanguageCode(userLanguage);
// Fetch popular movies for random selection
diff --git a/src/pages/discover/components/ScrollToTopButton.tsx b/src/pages/discover/components/ScrollToTopButton.tsx
index 37c926a5..ab8b46c1 100644
--- a/src/pages/discover/components/ScrollToTopButton.tsx
+++ b/src/pages/discover/components/ScrollToTopButton.tsx
@@ -27,28 +27,30 @@ export function ScrollToTopButton() {
};
return (
-
+
);
diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx
index 2f08aa20..a2ae09ea 100644
--- a/src/pages/discover/discoverContent.tsx
+++ b/src/pages/discover/discoverContent.tsx
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import { Button } from "@/components/buttons/Button";
import { WideContainer } from "@/components/layout/WideContainer";
-import { DetailsModal } from "@/components/overlays/details/DetailsModal";
+import { DetailsModal } from "@/components/overlays/detailsModal";
import { useModal } from "@/components/overlays/Modal";
import { useDiscoverStore } from "@/stores/discover";
import { useProgressStore } from "@/stores/progress";
diff --git a/src/pages/discover/hooks/useDiscoverMedia.ts b/src/pages/discover/hooks/useDiscoverMedia.ts
index ed8a1b68..4ea07b56 100644
--- a/src/pages/discover/hooks/useDiscoverMedia.ts
+++ b/src/pages/discover/hooks/useDiscoverMedia.ts
@@ -242,7 +242,7 @@ export function useDiscoverOptions(mediaType: MediaType) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState
(null);
- const userLanguage = useLanguageStore.getState().language;
+ const userLanguage = useLanguageStore((s) => s.language);
const formattedLanguage = getTmdbLanguageCode(userLanguage);
const providers = mediaType === "movie" ? MOVIE_PROVIDERS : TV_PROVIDERS;
@@ -297,7 +297,7 @@ export function useDiscoverMedia({
useState(contentType);
const { t } = useTranslation();
- const userLanguage = useLanguageStore.getState().language;
+ const userLanguage = useLanguageStore((s) => s.language);
const formattedLanguage = getTmdbLanguageCode(userLanguage);
// Reset media when content type or media type changes
diff --git a/src/pages/discover/hooks/useTMDBData.tsx b/src/pages/discover/hooks/useTMDBData.tsx
index f6b7066b..5889ea94 100644
--- a/src/pages/discover/hooks/useTMDBData.tsx
+++ b/src/pages/discover/hooks/useTMDBData.tsx
@@ -21,7 +21,7 @@ export function useTMDBData(
[categoryName: string]: Movie[] | TVShow[];
}>({});
const [isLoading, setIsLoading] = useState(false);
- const userLanguage = useLanguageStore.getState().language;
+ const userLanguage = useLanguageStore((s) => s.language);
const formattedLanguage = getTmdbLanguageCode(userLanguage);
// Unified fetch function
@@ -108,7 +108,7 @@ export function useLazyTMDBData(
) {
const [media, setMedia] = useState([]);
const [isLoading, setIsLoading] = useState(false);
- const userLanguage = useLanguageStore.getState().language;
+ const userLanguage = useLanguageStore((s) => s.language);
const formattedLanguage = getTmdbLanguageCode(userLanguage);
const fetchMedia = useCallback(
diff --git a/src/pages/parts/errors/ErrorCard.tsx b/src/pages/parts/errors/ErrorCard.tsx
index 4e358901..7cca736f 100644
--- a/src/pages/parts/errors/ErrorCard.tsx
+++ b/src/pages/parts/errors/ErrorCard.tsx
@@ -5,6 +5,10 @@ import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { Modal } from "@/components/overlays/Modal";
import { DisplayError } from "@/components/player/display/displayInterface";
+import {
+ formatErrorDebugInfo,
+ gatherErrorDebugInfo,
+} from "@/utils/errorDebugInfo";
export function ErrorCard(props: {
error: DisplayError | string;
@@ -25,7 +29,13 @@ export function ErrorCard(props: {
function copyError() {
if (!props.error || !navigator.clipboard) return;
- navigator.clipboard.writeText(`\`\`\`${errorMessage}\`\`\``);
+
+ const debugInfo = gatherErrorDebugInfo(props.error);
+ const formattedDebugInfo = formatErrorDebugInfo(debugInfo);
+
+ const fullErrorReport = `\`\`\`\n${errorMessage}\n\n${formattedDebugInfo}\n\`\`\``;
+
+ navigator.clipboard.writeText(fullErrorReport);
setHasCopied(true);
@@ -57,7 +67,7 @@ export function ErrorCard(props: {
<>
- {t("actions.copy")}
+ {t("player.playbackError.copyDebugInfo")}
>
)}
@@ -74,7 +84,7 @@ export function ErrorCard(props: {
{errorMessage}
- Check console for more details
+ {t("player.playbackError.debugInfo")}
);
}
diff --git a/src/pages/parts/home/BookmarksCarousel.tsx b/src/pages/parts/home/BookmarksCarousel.tsx
index dd244e20..baa1912f 100644
--- a/src/pages/parts/home/BookmarksCarousel.tsx
+++ b/src/pages/parts/home/BookmarksCarousel.tsx
@@ -1,16 +1,37 @@
-import React, { useMemo, useRef, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
import { EditButton } from "@/components/buttons/EditButton";
-import { Icons } from "@/components/Icon";
+import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
+import { Item } from "@/components/form/SortableList";
+import { Icon, Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
+import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
+import { useModal } from "@/components/overlays/Modal";
+import { UserIcon, UserIcons } from "@/components/UserIcon";
+import { Flare } from "@/components/utils/Flare";
+import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useIsMobile } from "@/hooks/useIsMobile";
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
+import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
+import { useGroupOrderStore } from "@/stores/groupOrder";
import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
+function parseGroupString(group: string): { icon: UserIcons; name: string } {
+ const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
+ if (match) {
+ const iconKey = match[1].toUpperCase() as keyof typeof UserIcons;
+ const icon = UserIcons[iconKey] || UserIcons.BOOKMARK;
+ const name = match[2].trim();
+ return { icon, name };
+ }
+ return { icon: UserIcons.BOOKMARK, name: group };
+}
+
interface BookmarksCarouselProps {
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
@@ -19,6 +40,7 @@ interface BookmarksCarouselProps {
}
const LONG_PRESS_DURATION = 500; // 0.5 seconds
+const MAX_ITEMS_PER_SECTION = 20; // Limit items per section
function MediaCardSkeleton() {
return (
@@ -31,6 +53,36 @@ function MediaCardSkeleton() {
);
}
+function MoreBookmarksCard() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+ {t("home.bookmarks.showAll")}
+
+
+
+
+
+
+ );
+}
+
export function BookmarksCarousel({
carouselRefs,
onShowDetails,
@@ -41,6 +93,25 @@ export function BookmarksCarousel({
const [editing, setEditing] = useState(false);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const pressTimerRef = useRef(null);
+ const backendUrl = useBackendUrl();
+ const account = useAuthStore((s) => s.account);
+
+ // Create refs for overflow detection
+ const groupedCarouselRefs = useRef<{
+ [key: string]: HTMLDivElement | null;
+ }>({});
+ const regularCarouselRef = useRef(null);
+
+ // Track overflow state for each section
+ const [overflowStates, setOverflowStates] = useState<{
+ [key: string]: boolean;
+ }>({});
+
+ // Group order editing state
+ const groupOrder = useGroupOrderStore((s) => s.groupOrder);
+ const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
+ const editOrderModal = useModal("bookmark-edit-order-carousel");
+ const [tempGroupOrder, setTempGroupOrder] = useState([]);
const { isMobile } = useIsMobile();
@@ -73,6 +144,152 @@ export function BookmarksCarousel({
return output;
}, [bookmarks, progressItems]);
+ const { groupedItems, regularItems } = useMemo(() => {
+ const grouped: Record = {};
+ const regular: MediaItem[] = [];
+
+ items.forEach((item) => {
+ const bookmark = bookmarks[item.id];
+ if (Array.isArray(bookmark?.group)) {
+ bookmark.group.forEach((groupName) => {
+ if (!grouped[groupName]) {
+ grouped[groupName] = [];
+ }
+ grouped[groupName].push(item);
+ });
+ } else {
+ regular.push(item);
+ }
+ });
+
+ // Sort items within each group by date
+ Object.keys(grouped).forEach((group) => {
+ grouped[group].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 { groupedItems: grouped, regularItems: regular };
+ }, [items, bookmarks, progressItems]);
+
+ // group sorting
+ const allGroups = useMemo(() => {
+ const groups = new Set();
+
+ Object.values(bookmarks).forEach((bookmark) => {
+ if (Array.isArray(bookmark.group)) {
+ bookmark.group.forEach((group) => groups.add(group));
+ }
+ });
+
+ groups.add("bookmarks");
+
+ return Array.from(groups);
+ }, [bookmarks]);
+
+ const sortableItems = useMemo(() => {
+ const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
+
+ if (currentOrder.length === 0) {
+ return allGroups.map((group) => {
+ const { name } = parseGroupString(group);
+ return {
+ id: group,
+ name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
+ } as Item;
+ });
+ }
+
+ const orderMap = new Map(
+ currentOrder.map((group, index) => [group, index]),
+ );
+ const sortedGroups = allGroups.sort((groupA, groupB) => {
+ const orderA = orderMap.has(groupA)
+ ? orderMap.get(groupA)!
+ : Number.MAX_SAFE_INTEGER;
+ const orderB = orderMap.has(groupB)
+ ? orderMap.get(groupB)!
+ : Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ });
+
+ return sortedGroups.map((group) => {
+ const { name } = parseGroupString(group);
+ return {
+ id: group,
+ name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
+ } as Item;
+ });
+ }, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
+
+ // Create a unified list of sections including both grouped and regular bookmarks
+ const sortedSections = useMemo(() => {
+ const sections: Array<{
+ type: "grouped" | "regular";
+ group?: string;
+ items: MediaItem[];
+ }> = [];
+
+ // Create a combined map of all sections (grouped + regular)
+ const allSections = new Map();
+
+ // Add grouped sections
+ Object.entries(groupedItems).forEach(([group, groupItems]) => {
+ allSections.set(group, groupItems);
+ });
+
+ // Add regular bookmarks as "bookmarks" group
+ if (regularItems.length > 0) {
+ allSections.set("bookmarks", regularItems);
+ }
+
+ // Sort sections based on group order
+ if (groupOrder.length === 0) {
+ // No order set, use default order
+ allSections.forEach((sectionItems, group) => {
+ if (group === "bookmarks") {
+ sections.push({ type: "regular", items: sectionItems });
+ } else {
+ sections.push({ type: "grouped", group, items: sectionItems });
+ }
+ });
+ } else {
+ // Use the saved order
+ const orderMap = new Map(
+ groupOrder.map((group, index) => [group, index]),
+ );
+
+ Array.from(allSections.entries())
+ .sort(([groupA], [groupB]) => {
+ const orderA = orderMap.has(groupA)
+ ? orderMap.get(groupA)!
+ : Number.MAX_SAFE_INTEGER;
+ const orderB = orderMap.has(groupB)
+ ? orderMap.get(groupB)!
+ : Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ })
+ .forEach(([group, sectionItems]) => {
+ if (group === "bookmarks") {
+ sections.push({ type: "regular", items: sectionItems });
+ } else {
+ sections.push({ type: "grouped", group, items: sectionItems });
+ }
+ });
+ }
+
+ return sections;
+ }, [groupedItems, regularItems, groupOrder]);
+ // kill me
+
const handleWheel = (e: React.WheelEvent) => {
if (isScrolling) return;
isScrolling = true;
@@ -112,8 +329,11 @@ export function BookmarksCarousel({
};
const handleMouseDown = (e: React.MouseEvent) => {
- e.preventDefault(); // Prevent default mouse action
- pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
+ // Only trigger long press for left mouse button (button 0)
+ if (e.button === 0) {
+ e.preventDefault(); // Prevent default mouse action
+ pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
+ }
};
const handleMouseUp = () => {
@@ -123,6 +343,82 @@ export function BookmarksCarousel({
}
};
+ const handleEditGroupOrder = () => {
+ // Initialize with current order or default order
+ if (groupOrder.length === 0) {
+ const defaultOrder = allGroups.map((group) => group);
+ setTempGroupOrder(defaultOrder);
+ } else {
+ setTempGroupOrder([...groupOrder]);
+ }
+ editOrderModal.show();
+ };
+
+ const handleReorderClick = () => {
+ handleEditGroupOrder();
+ // Keep editing state active by setting it to true
+ setEditing(true);
+ };
+
+ const handleCancelOrder = () => {
+ editOrderModal.hide();
+ };
+
+ const handleSaveOrderClick = () => {
+ setGroupOrder(tempGroupOrder);
+ editOrderModal.hide();
+
+ // Save to backend
+ if (backendUrl && account) {
+ useGroupOrderStore
+ .getState()
+ .saveGroupOrderToBackend(backendUrl, account);
+ }
+ };
+
+ // Function to check overflow for a carousel
+ const checkOverflow = (element: HTMLDivElement | null, key: string) => {
+ if (!element) {
+ setOverflowStates((prev) => ({ ...prev, [key]: false }));
+ return;
+ }
+
+ const hasOverflow = element.scrollWidth > element.clientWidth;
+ setOverflowStates((prev) => ({ ...prev, [key]: hasOverflow }));
+ };
+
+ // Function to set carousel ref and check overflow
+ const setCarouselRef = (element: HTMLDivElement | null, key: string) => {
+ // Set the ref for the main carousel refs
+ carouselRefs.current[key] = element;
+
+ // Set the ref for overflow detection
+ if (key === "bookmarks") {
+ regularCarouselRef.current = element;
+ } else {
+ groupedCarouselRefs.current[key] = element;
+ }
+
+ // Check overflow after a short delay to ensure content is rendered
+ setTimeout(() => checkOverflow(element, key), 100);
+ };
+
+ // Effect to recheck overflow on window resize
+ useEffect(() => {
+ const handleResize = () => {
+ // Recheck overflow for all carousels
+ Object.keys(carouselRefs.current).forEach((key) => {
+ const element = carouselRefs.current[key];
+ if (element) {
+ checkOverflow(element, key);
+ }
+ });
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [carouselRefs]);
+
const categorySlug = "bookmarks";
const SKELETON_COUNT = 10;
@@ -130,69 +426,185 @@ export function BookmarksCarousel({
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)}
+ {/* Grouped Bookmarks Carousels */}
+ {sortedSections.map((section) => {
+ if (section.type === "grouped") {
+ const { icon, name } = parseGroupString(section.group || "");
+ return (
+
+
+
+
+ }
+ className="ml-4 md:ml-12 mt-2 -mb-5"
+ >
+
+ {editing && allGroups.length > 1 && (
+
+ )}
+
- ))
- : Array.from({ length: SKELETON_COUNT }).map(() => (
-
+
+
setCarouselRef(el, section.group || "bookmarks")}
+ onWheel={handleWheel}
+ >
+
+
+ {section.items
+ .slice(0, MAX_ITEMS_PER_SECTION)
+ .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)}
+ />
+
+ ))}
+
+ {section.items.length > MAX_ITEMS_PER_SECTION && (
+
+ )}
+
+
+
+
+ {!isMobile && (
+
+ )}
+
+
+ );
+ } // regular items
+ return (
+
+
+
+ {editing && allGroups.length > 1 && (
+
+ )}
+
- ))}
+
+
+
+
setCarouselRef(el, categorySlug)}
+ onWheel={handleWheel}
+ >
+
-
-
+ {section.items.length > 0
+ ? section.items
+ .slice(0, MAX_ITEMS_PER_SECTION)
+ .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 && (
-
- )}
-
+ {section.items.length > MAX_ITEMS_PER_SECTION && (
+
+ )}
+
+
+
+
+ {!isMobile && (
+
+ )}
+
+
+ );
+ })}
+
+ {/* Edit Order Modal */}
+
{
+ const newOrder = newItems.map((item) => item.id);
+ setTempGroupOrder(newOrder);
+ }}
+ />
>
);
}
diff --git a/src/pages/parts/home/BookmarksPart.tsx b/src/pages/parts/home/BookmarksPart.tsx
index e068608c..bd4ab3fd 100644
--- a/src/pages/parts/home/BookmarksPart.tsx
+++ b/src/pages/parts/home/BookmarksPart.tsx
@@ -3,14 +3,33 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
+import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
+import { Item } from "@/components/form/SortableList";
import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
+import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
+import { useModal } from "@/components/overlays/Modal";
+import { UserIcon, UserIcons } from "@/components/UserIcon";
+import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
+import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
+import { useGroupOrderStore } from "@/stores/groupOrder";
import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
+function parseGroupString(group: string): { icon: UserIcons; name: string } {
+ const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
+ if (match) {
+ const iconKey = match[1].toUpperCase() as keyof typeof UserIcons;
+ const icon = UserIcons[iconKey] || UserIcons.BOOKMARK;
+ const name = match[2].trim();
+ return { icon, name };
+ }
+ return { icon: UserIcons.BOOKMARK, name: group };
+}
+
const LONG_PRESS_DURATION = 700; // 0.7 seconds
export function BookmarksPart({
@@ -23,9 +42,15 @@ export function BookmarksPart({
const { t } = useTranslation();
const progressItems = useProgressStore((s) => s.items);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
+ const groupOrder = useGroupOrderStore((s) => s.groupOrder);
+ const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate();
+ const editOrderModal = useModal("bookmark-edit-order");
+ const [tempGroupOrder, setTempGroupOrder] = useState([]);
+ const backendUrl = useBackendUrl();
+ const account = useAuthStore((s) => s.account);
const pressTimerRef = useRef(null);
@@ -51,6 +76,145 @@ export function BookmarksPart({
return output;
}, [bookmarks, progressItems]);
+ const { groupedItems, regularItems } = useMemo(() => {
+ const grouped: Record = {};
+ const regular: MediaItem[] = [];
+
+ items.forEach((item) => {
+ const bookmark = bookmarks[item.id];
+ if (Array.isArray(bookmark?.group)) {
+ bookmark.group.forEach((groupName) => {
+ if (!grouped[groupName]) {
+ grouped[groupName] = [];
+ }
+ grouped[groupName].push(item);
+ });
+ } else {
+ regular.push(item);
+ }
+ });
+
+ // Sort items within each group by date
+ Object.keys(grouped).forEach((group) => {
+ grouped[group].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 { groupedItems: grouped, regularItems: regular };
+ }, [items, bookmarks, progressItems]);
+
+ // group sorting
+ const allGroups = useMemo(() => {
+ const groups = new Set();
+
+ Object.values(bookmarks).forEach((bookmark) => {
+ if (Array.isArray(bookmark.group)) {
+ bookmark.group.forEach((group) => groups.add(group));
+ }
+ });
+
+ groups.add("bookmarks");
+
+ return Array.from(groups);
+ }, [bookmarks]);
+
+ const sortableItems = useMemo(() => {
+ const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
+
+ if (currentOrder.length === 0) {
+ return allGroups.map((group) => {
+ const { name } = parseGroupString(group);
+ return {
+ id: group,
+ name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
+ } as Item;
+ });
+ }
+
+ const orderMap = new Map(
+ currentOrder.map((group, index) => [group, index]),
+ );
+ const sortedGroups = allGroups.sort((groupA, groupB) => {
+ const orderA = orderMap.has(groupA)
+ ? orderMap.get(groupA)!
+ : Number.MAX_SAFE_INTEGER;
+ const orderB = orderMap.has(groupB)
+ ? orderMap.get(groupB)!
+ : Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ });
+
+ return sortedGroups.map((group) => {
+ const { name } = parseGroupString(group);
+ return {
+ id: group,
+ name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
+ } as Item;
+ });
+ }, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
+
+ const sortedSections = useMemo(() => {
+ const sections: Array<{
+ type: "grouped" | "regular";
+ group?: string;
+ items: MediaItem[];
+ }> = [];
+
+ const allSections = new Map();
+
+ Object.entries(groupedItems).forEach(([group, groupItems]) => {
+ allSections.set(group, groupItems);
+ });
+
+ if (regularItems.length > 0) {
+ allSections.set("bookmarks", regularItems);
+ }
+
+ if (groupOrder.length === 0) {
+ allSections.forEach((sectionItems, group) => {
+ if (group === "bookmarks") {
+ sections.push({ type: "regular", items: sectionItems });
+ } else {
+ sections.push({ type: "grouped", group, items: sectionItems });
+ }
+ });
+ } else {
+ const orderMap = new Map(
+ groupOrder.map((group, index) => [group, index]),
+ );
+
+ Array.from(allSections.entries())
+ .sort(([groupA], [groupB]) => {
+ const orderA = orderMap.has(groupA)
+ ? orderMap.get(groupA)!
+ : Number.MAX_SAFE_INTEGER;
+ const orderB = orderMap.has(groupB)
+ ? orderMap.get(groupB)!
+ : Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ })
+ .forEach(([group, sectionItems]) => {
+ if (group === "bookmarks") {
+ sections.push({ type: "regular", items: sectionItems });
+ } else {
+ sections.push({ type: "grouped", group, items: sectionItems });
+ }
+ });
+ }
+
+ return sections;
+ }, [groupedItems, regularItems, groupOrder]);
+ // kill me
+
useEffect(() => {
onItemsChange(items.length > 0);
}, [items, onItemsChange]);
@@ -87,42 +251,160 @@ export function BookmarksPart({
}
};
+ const handleEditGroupOrder = () => {
+ // Initialize with current order or default order
+ if (groupOrder.length === 0) {
+ const defaultOrder = allGroups.map((group) => group);
+ setTempGroupOrder(defaultOrder);
+ } else {
+ setTempGroupOrder([...groupOrder]);
+ }
+ editOrderModal.show();
+ };
+
+ const handleReorderClick = () => {
+ handleEditGroupOrder();
+ // Keep editing state active by setting it to true
+ setEditing(true);
+ };
+
+ const handleCancelOrder = () => {
+ editOrderModal.hide();
+ };
+
+ const handleSaveOrderClick = () => {
+ setGroupOrder(tempGroupOrder);
+ editOrderModal.hide();
+
+ // Save to backend
+ if (backendUrl && account) {
+ useGroupOrderStore
+ .getState()
+ .saveGroupOrderToBackend(backendUrl, account);
+ }
+ };
+
if (items.length === 0) return null;
return (
-
-
-
-
- {items.map((v) => (
- ) =>
- e.preventDefault()
- }
- onTouchStart={handleTouchStart}
- onTouchEnd={handleTouchEnd}
- onMouseDown={handleMouseDown}
- onMouseUp={handleMouseUp}
- >
-
removeBookmark(v.id)}
- onShowDetails={onShowDetails}
- />
+ {/* Grouped Bookmarks */}
+ {sortedSections.map((section) => {
+ if (section.type === "grouped") {
+ const { icon, name } = parseGroupString(section.group || "");
+ return (
+
+
+
+
+ }
+ >
+
+ {editing && allGroups.length > 1 && (
+
+ )}
+
+
+
+
+ {section.items.map((v) => (
+ ) =>
+ e.preventDefault()
+ }
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
+ onMouseDown={handleMouseDown}
+ onMouseUp={handleMouseUp}
+ >
+ removeBookmark(v.id)}
+ onShowDetails={onShowDetails}
+ />
+
+ ))}
+
+
+ );
+ } // regular items
+ return (
+
+
+
+ {editing && allGroups.length > 1 && (
+
+ )}
+
+
+
+
+ {section.items.map((v) => (
+ ) =>
+ e.preventDefault()
+ }
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
+ onMouseDown={handleMouseDown}
+ onMouseUp={handleMouseUp}
+ >
+ removeBookmark(v.id)}
+ onShowDetails={onShowDetails}
+ />
+
+ ))}
+
- ))}
-
+ );
+ })}
+
+ {/* Edit Order Modal */}
+ {
+ const newOrder = newItems.map((item) => item.id);
+ setTempGroupOrder(newOrder);
+ }}
+ />
);
}
diff --git a/src/pages/parts/home/WatchingCarousel.tsx b/src/pages/parts/home/WatchingCarousel.tsx
index 51a21a7a..3cb18906 100644
--- a/src/pages/parts/home/WatchingCarousel.tsx
+++ b/src/pages/parts/home/WatchingCarousel.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useRef, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
@@ -42,6 +42,9 @@ export function WatchingCarousel({
const removeItem = useProgressStore((s) => s.removeItem);
const pressTimerRef = useRef(null);
+ // Track overflow state
+ const [hasOverflow, setHasOverflow] = useState(false);
+
const { isMobile } = useIsMobile();
const itemsLength = useProgressStore((state) => {
@@ -108,8 +111,11 @@ export function WatchingCarousel({
};
const handleMouseDown = (e: React.MouseEvent) => {
- e.preventDefault(); // Prevent default mouse action
- pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
+ // Only trigger long press for left mouse button (button 0)
+ if (e.button === 0) {
+ e.preventDefault(); // Prevent default mouse action
+ pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
+ }
};
const handleMouseUp = () => {
@@ -119,6 +125,38 @@ export function WatchingCarousel({
}
};
+ // Function to check overflow for the carousel
+ const checkOverflow = (element: HTMLDivElement | null) => {
+ if (!element) {
+ setHasOverflow(false);
+ return;
+ }
+
+ const hasHorizontalOverflow = element.scrollWidth > element.clientWidth;
+ setHasOverflow(hasHorizontalOverflow);
+ };
+
+ // Function to set carousel ref and check overflow
+ const setCarouselRef = (element: HTMLDivElement | null) => {
+ carouselRefs.current[categorySlug] = element;
+
+ // Check overflow after a short delay to ensure content is rendered
+ setTimeout(() => checkOverflow(element), 100);
+ };
+
+ // Effect to recheck overflow on window resize
+ useEffect(() => {
+ const handleResize = () => {
+ const element = carouselRefs.current[categorySlug];
+ if (element) {
+ checkOverflow(element);
+ }
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [carouselRefs, categorySlug]);
+
if (itemsLength === 0) return null;
return (
@@ -140,9 +178,7 @@ export function WatchingCarousel({
{
- carouselRefs.current[categorySlug] = el;
- }}
+ ref={setCarouselRef}
onWheel={handleWheel}
>
@@ -183,6 +219,7 @@ export function WatchingCarousel({
)}
diff --git a/src/pages/parts/home/WatchingPart.tsx b/src/pages/parts/home/WatchingPart.tsx
index 048278e3..ac9041d4 100644
--- a/src/pages/parts/home/WatchingPart.tsx
+++ b/src/pages/parts/home/WatchingPart.tsx
@@ -68,8 +68,11 @@ export function WatchingPart({
};
const handleMouseDown = (e: React.MouseEvent) => {
- e.preventDefault(); // Prevent default mouse action
- pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
+ // Only trigger long press for left mouse button (button 0)
+ if (e.button === 0) {
+ e.preventDefault(); // Prevent default mouse action
+ pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
+ }
};
const handleMouseUp = () => {
diff --git a/src/pages/parts/player/PlaybackErrorPart.tsx b/src/pages/parts/player/PlaybackErrorPart.tsx
index 42f06c55..5081e829 100644
--- a/src/pages/parts/player/PlaybackErrorPart.tsx
+++ b/src/pages/parts/player/PlaybackErrorPart.tsx
@@ -29,12 +29,35 @@ export function PlaybackErrorPart() {
}
}, [playbackError, settingsRouter]);
+ const handleOpenSourcePicker = () => {
+ settingsRouter.open();
+ settingsRouter.navigate("/source");
+ };
+
return (
{t("player.playbackError.badge")}
{t("player.playbackError.title")}
{t("player.playbackError.text")}
+
+
+
+
- {
- e.preventDefault();
- window.location.reload();
- }}
- >
- {t("errors.reloadPage")}
-
{/* Error */}
+
+
{!showDowntime && (
{/* functional routes */}
@@ -182,6 +185,8 @@ function App() {
/>
} />
} />
+ {/* Bookmarks page */}
+ } />
{/* Settings page */}
;
updateQueue: BookmarkUpdateItem[];
addBookmark(meta: PlayerMeta): void;
+ addBookmarkWithGroups(meta: PlayerMeta, groups?: string[]): void;
removeBookmark(id: string): void;
replaceBookmarks(items: Record): void;
clear(): void;
@@ -74,6 +77,30 @@ export const useBookmarkStore = create(
};
});
},
+ addBookmarkWithGroups(meta, groups) {
+ set((s) => {
+ updateId += 1;
+ s.updateQueue.push({
+ id: updateId.toString(),
+ action: "add",
+ tmdbId: meta.tmdbId,
+ type: meta.type,
+ title: meta.title,
+ year: meta.releaseYear,
+ poster: meta.poster,
+ group: groups,
+ });
+
+ s.bookmarks[meta.tmdbId] = {
+ type: meta.type,
+ title: meta.title,
+ year: meta.releaseYear,
+ poster: meta.poster,
+ updatedAt: Date.now(),
+ group: groups,
+ };
+ });
+ },
replaceBookmarks(items: Record) {
set((s) => {
s.bookmarks = items;
diff --git a/src/stores/groupOrder/GroupSyncer.tsx b/src/stores/groupOrder/GroupSyncer.tsx
new file mode 100644
index 00000000..ce6ae64f
--- /dev/null
+++ b/src/stores/groupOrder/GroupSyncer.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useRef } from "react";
+
+import { updateGroupOrder } from "@/backend/accounts/groupOrder";
+import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
+import { useAuthStore } from "@/stores/auth";
+import { useGroupOrderStore } from "@/stores/groupOrder";
+
+const syncIntervalMs = 5 * 1000;
+
+export function GroupSyncer() {
+ const url = useBackendUrl();
+ const groupOrder = useGroupOrderStore((s) => s.groupOrder);
+ const lastSyncedOrder = useRef([]);
+ const isInitialized = useRef(false);
+
+ // Initialize lastSyncedOrder on first render
+ useEffect(() => {
+ if (!isInitialized.current) {
+ lastSyncedOrder.current = [...groupOrder];
+ isInitialized.current = true;
+ }
+ }, [groupOrder]);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ (async () => {
+ if (!url) return;
+
+ const user = useAuthStore.getState();
+ if (!user.account) return; // not logged in, dont sync to server
+
+ // Check if group order has changed since last sync
+ const currentOrder = useGroupOrderStore.getState().groupOrder;
+ const hasChanged =
+ JSON.stringify(currentOrder) !==
+ JSON.stringify(lastSyncedOrder.current);
+
+ if (hasChanged) {
+ try {
+ await updateGroupOrder(url, user.account, currentOrder);
+ lastSyncedOrder.current = [...currentOrder];
+ } catch (err) {
+ console.error("Failed to sync group order:", err);
+ }
+ }
+ })();
+ }, syncIntervalMs);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, [url]);
+
+ return null;
+}
diff --git a/src/stores/groupOrder/index.ts b/src/stores/groupOrder/index.ts
new file mode 100644
index 00000000..c754870e
--- /dev/null
+++ b/src/stores/groupOrder/index.ts
@@ -0,0 +1,65 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { immer } from "zustand/middleware/immer";
+
+import { getGroupOrder, updateGroupOrder } from "@/backend/accounts/groupOrder";
+import { AccountWithToken } from "@/stores/auth";
+
+export interface GroupOrderStore {
+ groupOrder: string[];
+ setGroupOrder(order: string[]): void;
+ saveGroupOrderToBackend(
+ backendUrl: string,
+ account: AccountWithToken,
+ ): Promise;
+ loadGroupOrderFromBackend(
+ backendUrl: string,
+ account: AccountWithToken,
+ ): Promise;
+ clear(): void;
+}
+
+export const useGroupOrderStore = create(
+ persist(
+ immer((set) => ({
+ groupOrder: [],
+ setGroupOrder(order: string[]) {
+ set((s) => {
+ s.groupOrder = order;
+ });
+ },
+ async saveGroupOrderToBackend(
+ backendUrl: string,
+ account: AccountWithToken,
+ ) {
+ if (!account || !backendUrl) {
+ throw new Error("No authenticated account or backend URL");
+ }
+
+ const currentState = useGroupOrderStore.getState();
+ await updateGroupOrder(backendUrl, account, currentState.groupOrder);
+ },
+ async loadGroupOrderFromBackend(
+ backendUrl: string,
+ account: AccountWithToken,
+ ) {
+ if (!account || !backendUrl) {
+ throw new Error("No authenticated account or backend URL");
+ }
+
+ const response = await getGroupOrder(backendUrl, account);
+ set((s) => {
+ s.groupOrder = response.groupOrder;
+ });
+ },
+ clear() {
+ set((s) => {
+ s.groupOrder = [];
+ });
+ },
+ })),
+ {
+ name: "__MW::groupOrder",
+ },
+ ),
+);
diff --git a/src/stores/interface/overlayStack.ts b/src/stores/interface/overlayStack.ts
index ccbf2e7b..22d9c280 100644
--- a/src/stores/interface/overlayStack.ts
+++ b/src/stores/interface/overlayStack.ts
@@ -1,13 +1,42 @@
import { create } from "zustand";
+import { immer } from "zustand/middleware/immer";
-type OverlayType = "volume" | "subtitle" | null;
+type OverlayType = "volume" | "subtitle" | "speed" | null;
interface OverlayStackStore {
currentOverlay: OverlayType;
+ modalStack: string[];
setCurrentOverlay: (overlay: OverlayType) => void;
+ showModal: (id: string) => void;
+ hideModal: (id: string) => void;
+ isModalVisible: (id: string) => boolean;
+ getTopModal: () => string | null;
}
-export const useOverlayStack = create((set) => ({
- currentOverlay: null,
- setCurrentOverlay: (overlay) => set({ currentOverlay: overlay }),
-}));
+export const useOverlayStack = create()(
+ immer((set, get) => ({
+ currentOverlay: null,
+ modalStack: [],
+ setCurrentOverlay: (overlay) =>
+ set((state) => {
+ state.currentOverlay = overlay;
+ }),
+ showModal: (id: string) =>
+ set((state) => {
+ if (!state.modalStack.includes(id)) {
+ state.modalStack.push(id);
+ }
+ }),
+ hideModal: (id: string) =>
+ set((state) => {
+ state.modalStack = state.modalStack.filter((modalId) => modalId !== id);
+ }),
+ isModalVisible: (id: string) => {
+ return get().modalStack.includes(id);
+ },
+ getTopModal: () => {
+ const stack = get().modalStack;
+ return stack.length > 0 ? stack[stack.length - 1] : null;
+ },
+ })),
+);
diff --git a/src/stores/player/slices/interface.ts b/src/stores/player/slices/interface.ts
index 0787d0fe..fd9ede66 100644
--- a/src/stores/player/slices/interface.ts
+++ b/src/stores/player/slices/interface.ts
@@ -32,6 +32,8 @@ export interface InterfaceSlice {
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
isHoveringControls: boolean; // is the cursor hovered over any controls?
timeFormat: VideoPlayerTimeFormat; // Time format of the video player
+ isSpeedBoosted: boolean; // is playback speed temporarily boosted to 2x
+ showSpeedIndicator: boolean; // should the speed indicator be shown
};
updateInterfaceHovering(newState: PlayerHoverState): void;
setSeeking(seeking: boolean): void;
@@ -42,6 +44,8 @@ export interface InterfaceSlice {
setLastVolume(state: number): void;
hideNextEpisodeButton(): void;
setShouldStartFromBeginning(val: boolean): void;
+ setSpeedBoosted(state: boolean): void;
+ setShowSpeedIndicator(state: boolean): void;
}
export const createInterfaceSlice: MakeSlice = (set, get) => ({
@@ -61,6 +65,8 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({
canAirplay: false,
hideNextEpisodeBtn: false,
shouldStartFromBeginning: false,
+ isSpeedBoosted: false,
+ showSpeedIndicator: false,
},
setShouldStartFromBeginning(val) {
@@ -112,4 +118,14 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({
s.interface.hideNextEpisodeBtn = true;
});
},
+ setSpeedBoosted(state) {
+ set((s) => {
+ s.interface.isSpeedBoosted = state;
+ });
+ },
+ setShowSpeedIndicator(state) {
+ set((s) => {
+ s.interface.showSpeedIndicator = state;
+ });
+ },
});
diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts
index 9c2335dd..8c04dc6b 100644
--- a/src/stores/player/slices/source.ts
+++ b/src/stores/player/slices/source.ts
@@ -1,3 +1,4 @@
+/* eslint-disable no-console */
import { ScrapeMedia } from "@p-stream/providers";
import { MakeSlice } from "@/stores/player/slices/types";
@@ -98,6 +99,7 @@ export interface SourceSlice {
enableAutomaticQuality(): void;
redisplaySource(startAt: number): void;
setCaptionAsTrack(asTrack: boolean): void;
+ addExternalSubtitles(): Promise;
}
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
@@ -184,6 +186,12 @@ export const createSourceSlice: MakeSlice = (set, get) => ({
});
const store = get();
store.redisplaySource(startAt);
+
+ // Trigger external subtitle scraping after stream is loaded
+ // This runs asynchronously so it doesn't block the stream loading
+ setTimeout(() => {
+ store.addExternalSubtitles();
+ }, 100);
},
redisplaySource(startAt: number) {
const store = get();
@@ -235,4 +243,29 @@ export const createSourceSlice: MakeSlice = (set, get) => ({
s.caption.asTrack = asTrack;
});
},
+ async addExternalSubtitles() {
+ const store = get();
+ if (!store.meta) return;
+
+ try {
+ const { scrapeExternalSubtitles } = await import(
+ "@/utils/externalSubtitles"
+ );
+ const externalCaptions = await scrapeExternalSubtitles(store.meta);
+
+ if (externalCaptions.length > 0) {
+ set((s) => {
+ // Add external captions to the existing list, avoiding duplicates
+ const existingIds = new Set(s.captionList.map((c) => c.id));
+ const newCaptions = externalCaptions.filter(
+ (c) => !existingIds.has(c.id),
+ );
+ s.captionList = [...s.captionList, ...newCaptions];
+ });
+ console.log(`Added ${externalCaptions.length} external captions`);
+ }
+ } catch (error) {
+ console.error("Failed to scrape external subtitles:", error);
+ }
+ },
});
diff --git a/src/utils/errorDebugInfo.ts b/src/utils/errorDebugInfo.ts
new file mode 100644
index 00000000..64aee83b
--- /dev/null
+++ b/src/utils/errorDebugInfo.ts
@@ -0,0 +1,195 @@
+import { detect } from "detect-browser";
+
+import { usePlayerStore } from "@/stores/player/store";
+
+export interface ErrorDebugInfo {
+ timestamp: string;
+ error: {
+ message: string;
+ type: string;
+ stackTrace?: string;
+ };
+ device: {
+ userAgent: string;
+ browser: string;
+ os: string;
+ isMobile: boolean;
+ isTV: boolean;
+ screenResolution: string;
+ viewportSize: string;
+ };
+ player: {
+ status: string;
+ sourceId: string | null;
+ currentQuality: string | null;
+ meta: {
+ title: string;
+ type: string;
+ tmdbId: string;
+ imdbId?: string;
+ releaseYear: number;
+ season?: number;
+ episode?: number;
+ } | null;
+ };
+ network: {
+ online: boolean;
+ connectionType?: string;
+ effectiveType?: string;
+ downlink?: number;
+ rtt?: number;
+ };
+
+ performance: {
+ memory?: {
+ usedJSHeapSize: number;
+ totalJSHeapSize: number;
+ jsHeapSizeLimit: number;
+ };
+ timing: {
+ navigationStart: number;
+ loadEventEnd: number;
+ domContentLoadedEventEnd: number;
+ };
+ };
+}
+
+export function gatherErrorDebugInfo(error: any): ErrorDebugInfo {
+ const browserInfo = detect();
+ const isMobile = window.innerWidth <= 768;
+ const isTV =
+ /SmartTV|Tizen|WebOS|SamsungBrowser|HbbTV|Viera|NetCast|AppleTV|Android TV|GoogleTV|Roku|PlayStation|Xbox|Opera TV|AquosBrowser|Hisense|SonyBrowser|SharpBrowser|AFT|Chromecast/i.test(
+ navigator.userAgent,
+ );
+
+ const playerStore = usePlayerStore.getState();
+
+ // Get network information
+ const connection =
+ (navigator as any).connection ||
+ (navigator as any).mozConnection ||
+ (navigator as any).webkitConnection;
+
+ // Get performance information
+ const performanceInfo = performance.getEntriesByType(
+ "navigation",
+ )[0] as PerformanceNavigationTiming;
+ const memory = (performance as any).memory;
+
+ return {
+ timestamp: new Date().toISOString(),
+ error: {
+ message: error?.message || error?.key || String(error),
+ type: error?.type || "unknown",
+ stackTrace: error?.stackTrace || error?.stack,
+ },
+ device: {
+ userAgent: navigator.userAgent,
+ browser: browserInfo?.name || "unknown",
+ os: browserInfo?.os || "unknown",
+ isMobile,
+ isTV,
+ screenResolution: `${window.screen.width}x${window.screen.height}`,
+ viewportSize: `${window.innerWidth}x${window.innerHeight}`,
+ },
+ player: {
+ status: playerStore.status,
+ sourceId: playerStore.sourceId,
+ currentQuality: playerStore.currentQuality,
+ meta: playerStore.meta
+ ? {
+ title: playerStore.meta.title,
+ type: playerStore.meta.type,
+ tmdbId: playerStore.meta.tmdbId,
+ imdbId: playerStore.meta.imdbId,
+ releaseYear: playerStore.meta.releaseYear,
+ season: playerStore.meta.season?.number,
+ episode: playerStore.meta.episode?.number,
+ }
+ : null,
+ },
+ network: {
+ online: navigator.onLine,
+ connectionType: connection?.type,
+ effectiveType: connection?.effectiveType,
+ downlink: connection?.downlink,
+ rtt: connection?.rtt,
+ },
+ performance: {
+ memory: memory
+ ? {
+ usedJSHeapSize: memory.usedJSHeapSize,
+ totalJSHeapSize: memory.totalJSHeapSize,
+ jsHeapSizeLimit: memory.jsHeapSizeLimit,
+ }
+ : undefined,
+ timing: {
+ navigationStart: performanceInfo?.fetchStart || 0,
+ loadEventEnd: performanceInfo?.loadEventEnd || 0,
+ domContentLoadedEventEnd:
+ performanceInfo?.domContentLoadedEventEnd || 0,
+ },
+ },
+ };
+}
+
+export function formatErrorDebugInfo(info: ErrorDebugInfo): string {
+ const sections = [
+ `=== ERROR DEBUG INFO ===`,
+ `Timestamp: ${info.timestamp}`,
+ ``,
+ `=== ERROR DETAILS ===`,
+ `Type: ${info.error.type}`,
+ `Message: ${info.error.message}`,
+ info.error.stackTrace ? `Stack Trace:\n${info.error.stackTrace}` : "",
+ ``,
+ `=== DEVICE INFO ===`,
+ `Browser: ${info.device.browser} (${info.device.os})`,
+ `User Agent: ${info.device.userAgent}`,
+ `Screen: ${info.device.screenResolution}`,
+ `Viewport: ${info.device.viewportSize}`,
+ `Mobile: ${info.device.isMobile}`,
+ `TV: ${info.device.isTV}`,
+ ``,
+ `=== PLAYER STATE ===`,
+ `Status: ${info.player.status}`,
+ `Source ID: ${info.player.sourceId || "null"}`,
+ `Quality: ${info.player.currentQuality || "null"}`,
+ info.player.meta
+ ? [
+ `Media: ${info.player.meta.title} (${info.player.meta.type})`,
+ `TMDB ID: ${info.player.meta.tmdbId}`,
+ info.player.meta.imdbId ? `IMDB ID: ${info.player.meta.imdbId}` : "",
+ `Year: ${info.player.meta.releaseYear}`,
+ info.player.meta.season ? `Season: ${info.player.meta.season}` : "",
+ info.player.meta.episode
+ ? `Episode: ${info.player.meta.episode}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join("\n")
+ : "No media loaded",
+ ``,
+ `=== NETWORK INFO ===`,
+ `Online: ${info.network.online}`,
+ info.network.connectionType
+ ? `Connection Type: ${info.network.connectionType}`
+ : "",
+ info.network.effectiveType
+ ? `Effective Type: ${info.network.effectiveType}`
+ : "",
+ info.network.downlink ? `Downlink: ${info.network.downlink} Mbps` : "",
+ info.network.rtt ? `RTT: ${info.network.rtt} ms` : "",
+ ``,
+ `=== PERFORMANCE ===`,
+ info.performance.memory
+ ? [
+ `Memory Used: ${Math.round(info.performance.memory.usedJSHeapSize / 1024 / 1024)} MB`,
+ `Memory Total: ${Math.round(info.performance.memory.totalJSHeapSize / 1024 / 1024)} MB`,
+ `Memory Limit: ${Math.round(info.performance.memory.jsHeapSizeLimit / 1024 / 1024)} MB`,
+ ].join("\n")
+ : "Memory info not available",
+ ];
+
+ return sections.filter(Boolean).join("\n");
+}
diff --git a/src/utils/externalSubtitles.ts b/src/utils/externalSubtitles.ts
new file mode 100644
index 00000000..d3b33819
--- /dev/null
+++ b/src/utils/externalSubtitles.ts
@@ -0,0 +1,256 @@
+/* eslint-disable no-console */
+import { type SubtitleData, searchSubtitles } from "wyzie-lib";
+
+import { CaptionListItem, PlayerMeta } from "@/stores/player/slices/source";
+
+// Helper function to convert language names to language codes
+function labelToLanguageCode(languageName: string): string {
+ const languageMap: Record = {
+ English: "en",
+ Spanish: "es",
+ French: "fr",
+ German: "de",
+ Italian: "it",
+ Portuguese: "pt",
+ Russian: "ru",
+ Japanese: "ja",
+ Korean: "ko",
+ Chinese: "zh",
+ Arabic: "ar",
+ Hindi: "hi",
+ Turkish: "tr",
+ Dutch: "nl",
+ Polish: "pl",
+ Swedish: "sv",
+ Norwegian: "no",
+ Danish: "da",
+ Finnish: "fi",
+ Greek: "el",
+ Hebrew: "he",
+ Thai: "th",
+ Vietnamese: "vi",
+ Indonesian: "id",
+ Malay: "ms",
+ Filipino: "tl",
+ Ukrainian: "uk",
+ Romanian: "ro",
+ Czech: "cs",
+ Hungarian: "hu",
+ Bulgarian: "bg",
+ Croatian: "hr",
+ Serbian: "sr",
+ Slovak: "sk",
+ Slovenian: "sl",
+ Estonian: "et",
+ Latvian: "lv",
+ Lithuanian: "lt",
+ Icelandic: "is",
+ Maltese: "mt",
+ Georgian: "ka",
+ Armenian: "hy",
+ Azerbaijani: "az",
+ Kazakh: "kk",
+ Kyrgyz: "ky",
+ Uzbek: "uz",
+ Tajik: "tg",
+ Turkmen: "tk",
+ Mongolian: "mn",
+ Persian: "fa",
+ Urdu: "ur",
+ Bengali: "bn",
+ Tamil: "ta",
+ Telugu: "te",
+ Marathi: "mr",
+ Gujarati: "gu",
+ Kannada: "kn",
+ Malayalam: "ml",
+ Punjabi: "pa",
+ Sinhala: "si",
+ Nepali: "ne",
+ Burmese: "my",
+ Khmer: "km",
+ Lao: "lo",
+ Tibetan: "bo",
+ Uyghur: "ug",
+ Kurdish: "ku",
+ Pashto: "ps",
+ Dari: "prs",
+ Sindhi: "sd",
+ Kashmiri: "ks",
+ Dogri: "doi",
+ Konkani: "kok",
+ Manipuri: "mni",
+ Bodo: "brx",
+ Sanskrit: "sa",
+ Santhali: "sat",
+ Maithili: "mai",
+ Bhojpuri: "bho",
+ Awadhi: "awa",
+ Chhattisgarhi: "hne",
+ Magahi: "mag",
+ Rajasthani: "raj",
+ Malvi: "mup",
+ Bundeli: "bns",
+ Bagheli: "bfy",
+ Pahari: "phr",
+ Kumaoni: "kfy",
+ Garhwali: "gbm",
+ Kangri: "xnr",
+ };
+
+ return languageMap[languageName] || languageName.toLowerCase();
+}
+
+const timeout = (ms: number, source: string) =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ console.error(`${source} captions request timed out after ${ms}ms`);
+ resolve(null);
+ }, ms);
+ });
+
+export async function scrapeWyzieCaptions(
+ tmdbId: string | number,
+ imdbId: string,
+ season?: number,
+ episode?: number,
+): Promise {
+ try {
+ const searchParams: any = {
+ encoding: "utf-8",
+ source: "all",
+ imdb_id: imdbId,
+ };
+
+ if (tmdbId && !imdbId) {
+ searchParams.tmdb_id =
+ typeof tmdbId === "string" ? parseInt(tmdbId, 10) : tmdbId;
+ }
+
+ if (season && episode) {
+ searchParams.season = season;
+ searchParams.episode = episode;
+ }
+
+ console.log("Searching Wyzie subtitles with params:", searchParams);
+ const wyzieSubtitles: SubtitleData[] = await searchSubtitles(searchParams);
+
+ const wyzieCaptions: CaptionListItem[] = wyzieSubtitles.map((subtitle) => ({
+ id: subtitle.id,
+ language: subtitle.language,
+ url: subtitle.url,
+ type:
+ subtitle.format === "srt" || subtitle.format === "vtt"
+ ? subtitle.format
+ : "srt",
+ needsProxy: false,
+ opensubtitles: true,
+ // Additional metadata from Wyzie
+ display: subtitle.display,
+ media: subtitle.media,
+ isHearingImpaired: subtitle.isHearingImpaired,
+ source:
+ typeof subtitle.source === "number"
+ ? subtitle.source.toString()
+ : subtitle.source,
+ encoding: subtitle.encoding,
+ }));
+
+ return wyzieCaptions;
+ } catch (error) {
+ console.error("Error fetching Wyzie subtitles:", error);
+ return [];
+ }
+}
+
+export async function scrapeOpenSubtitlesCaptions(
+ imdbId: string,
+ season?: number,
+ episode?: number,
+): Promise {
+ try {
+ const url = `https://rest.opensubtitles.org/search/${
+ season && episode ? `episode-${episode}/` : ""
+ }imdbid-${imdbId.slice(2)}${season && episode ? `/season-${season}` : ""}`;
+
+ const response = await fetch(url, {
+ headers: {
+ "X-User-Agent": "VLSub 0.10.2",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`OpenSubtitles API returned ${response.status}`);
+ }
+
+ const data = await response.json();
+ const openSubtitlesCaptions: CaptionListItem[] = [];
+
+ for (const caption of data) {
+ const downloadUrl = caption.SubDownloadLink.replace(".gz", "").replace(
+ "download/",
+ "download/subencoding-utf8/",
+ );
+ const language = labelToLanguageCode(caption.LanguageName);
+
+ if (!downloadUrl || !language) continue;
+
+ openSubtitlesCaptions.push({
+ id: downloadUrl,
+ language,
+ url: downloadUrl,
+ type: caption.SubFormat || "srt",
+ needsProxy: false,
+ opensubtitles: true,
+ });
+ }
+
+ return openSubtitlesCaptions;
+ } catch (error) {
+ console.error("Error fetching OpenSubtitles:", error);
+ return [];
+ }
+}
+
+export async function scrapeExternalSubtitles(
+ meta: PlayerMeta,
+): Promise {
+ try {
+ // Extract IMDb ID from meta
+ const imdbId = meta.imdbId;
+ if (!imdbId) {
+ console.log("No IMDb ID available for external subtitle scraping");
+ return [];
+ }
+
+ const season = meta.season?.number;
+ const episode = meta.episode?.number;
+ const tmdbId = meta.tmdbId;
+
+ // Fetch both Wyzie and OpenSubtitles captions with timeouts
+ const [wyzieCaptions, openSubsCaptions] = await Promise.all([
+ Promise.race([
+ scrapeWyzieCaptions(tmdbId, imdbId, season, episode),
+ timeout(2000, "Wyzie"),
+ ]),
+ Promise.race([
+ scrapeOpenSubtitlesCaptions(imdbId, season, episode),
+ timeout(5000, "OpenSubtitles"),
+ ]),
+ ]);
+
+ const allCaptions: CaptionListItem[] = [];
+
+ if (wyzieCaptions) allCaptions.push(...wyzieCaptions);
+ if (openSubsCaptions) allCaptions.push(...openSubsCaptions);
+
+ console.log(
+ `Found ${allCaptions.length} external captions (Wyzie: ${wyzieCaptions?.length || 0}, OpenSubtitles: ${openSubsCaptions?.length || 0})`,
+ );
+
+ return allCaptions;
+ } catch (error) {
+ console.error("Error in scrapeExternalSubtitles:", error);
+ return [];
+ }
+}