diff --git a/package.json b/package.json index d545a619..d4491a2b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "semver": "^7.7.2", "slugify": "^1.6.6", "subsrt-ts": "^2.1.2", + "wyzie-lib": "^2.2.5", "zustand": "^4.5.7" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4411a8c9..93b4a097 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: subsrt-ts: specifier: ^2.1.2 version: 2.1.2 + wyzie-lib: + specifier: ^2.2.5 + version: 2.2.5 zustand: specifier: ^4.5.7 version: 4.5.7(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1) diff --git a/public/notifications.xml b/public/notifications.xml new file mode 100644 index 00000000..63d07f2e --- /dev/null +++ b/public/notifications.xml @@ -0,0 +1,124 @@ + + + + P-Stream Notifications + https://pstream.mov + Site updates and important notifications for P-Stream users + en + Mon, 28 Jul 2025 21:53:00 GMT + + + + notification-8-1-25 + Welcome to the P-Stream Beta! + P-Stream is now in beta! This is a test of the new notification system. + +You can now receive notifications for new updates and important announcements. + +You can also view the notifications in the notifications page. + Fri, 01 Aug 2025 21:00:00 GMT + announcement + + + + notification-028 + P-Stream v5.0.4 released! + A bunch more quality-of-life improvements and fixes have been released. +- Accounts are no longer required for migration downloads and uploads. +- Right-click to open the details modal has been re-added. +- Increased hold-to-edit time for media cards. +- Re-added jiggle physics while editing. +- Custom passphrase support. +- Improved subtitle selection experience. +- Bug fixes and behind-the-scenes changes. + +A few new sources have also been added, including Flixer and VidSrc.vip. + Tue, 15 Jul 2025 20:58:00 GMT + update + + + + notification-026 + New Domain: pstream.mov + https://pstream.mov + The new domain is https://pstream.mov/ + +Most pages should now be working. If you had an account on the old site, your data will be there. If you did not have an account, you can download your data from pstream.org and upload it to the new site. + Mon, 14 Jul 2025 17:30:00 GMT + announcement + + + + notification-022 + P-Stream v5.0.2 released! + This is another minor update with great quality-of-life features. +- Added "open in..." button to the download menu. +- Added the ability to set a custom playback speed. +- Added buttons to mark episodes as watched or unwatched. +- Added custom color pickers to account styles and more user icons. +- Estimate quality for non-standard resolution sources. +- Re-added email to the DMCA page. +- Added an autoplay toggle to playback settings. +- Fixed end time to account for playback speed. +- Updated some translations and fixed many bugs. + Mon, 07 Jul 2025 19:55:00 GMT + update + + + + notification-008 + P-Stream v5.0.1 + Some minor updates this time: +- Added network images to the details modal. +- Added a letterboxd/trakt list page: "Discover All Lists." +- Fixed audio description not displaying. +- Automatically open the settings menu when failing to play. +- Added a compact episodes view. +- Updated the "About" page. +- Added low-performance/bandwidth mode. +- The share button on the details modal now opens the iOS share sheet. +- Fixed various bugs. + Tue, 24 Jun 2025 18:20:00 GMT + update + + + + notification-003-2 + New Movie Lists Page + https://pstream.mov/discover/all + A new page has been added to discover many movie lists powered by Letterboxd. A couple of minor bugs were also fixed. + Sat, 07 Jun 2025 18:41:00 GMT + update + + + + notification-005-2 + 5.0.0 Released! + This release is the largest facelift to the project yet, with many new features and fixes. + +New Features +- Discover Page Overhaul: The Discover page has been completely rewritten. +- New Trakt Sections: Explore new "Latest Releases" and "Top 4K" sections. +- Featured Movies/Shows Carousel: A new slideshow of new and popular movies or shows is now on the Discover page. +- Quality Indicators: See the video quality (HD or CAM) on the Details Modal or Featured Carousel. +- Expanded Descriptions: Episode descriptions can now be expanded. +- Watchparty (Beta)! Easily invite anyone to watch with you. Now joinable from the home page. +- You can now click on actors and directors on a movie or show's detail page to explore their other work. + +Improvements +- (Most) User preferences now sync. +- UI & UX Refresh: New player settings design and an overhauled Details Modal. +- You can now toggle Carousel View to show "Currently Watching" and "Bookmarked" lists as carousels. + +Bug Fixes +- Fixed several issues with loading skeletons. +- Implemented a fix to better handle subtitle files. +- Fixed various layout issues. +- Febbox Token now actually syncs with your account. +- Many other bugs. + Fri, 06 Jun 2025 11:29:00 GMT + update + + + + diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 10007e97..05e62b15 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -141,7 +141,8 @@ "actions": { "copied": "Copied", "copy": "Copy", - "cancel": "Cancel" + "cancel": "Cancel", + "confirm": "Confirm" }, "auth": { "createAccount": "Don't have an account yet 😬 <0>Create an account.", @@ -240,7 +241,25 @@ }, "home": { "bookmarks": { - "sectionTitle": "Bookmarks" + "sectionTitle": "Bookmarks", + "showAll": "Show all", + "groups": { + "dropdown": { + "placeholderButton": "Add to group", + "empty": "No groups yet", + "addButton": "Add", + "removeFromGroup": "Remove from group", + "removeAll": "Remove all" + }, + "reorder": { + "button": "Reorder", + "done": "Done", + "title": "Edit Group Order", + "description": "Drag and drop to reorder your bookmark groups", + "cancel": "Cancel", + "save": "Save" + } + } }, "continueWatching": { "sectionTitle": "Continue Watching..." @@ -316,7 +335,9 @@ "show": "Show" }, "episodeShort": "E", - "seasonShort": "S" + "seasonShort": "S", + "seasonWatched": "Are you sure you want to mark the season as watched?", + "seasonUnwatched": "Are you sure you want to mark the season as unwatched?" }, "details": { "resume": "Resume", @@ -614,7 +635,9 @@ "playback": { "speedLabel": "Playback speed", "title": "Playback settings", - "disabled": "(Disabled in watch party)" + "disabled": "(Disabled in watch party)", + "speedBoosted": "Playback speed boosted to 2x", + "speedUnboosted": "Playback speed reset to {{speed}}x" }, "quality": { "automaticLabel": "Automatic quality", @@ -667,6 +690,8 @@ "unknownLanguage": "Unknown", "dropSubtitleFile": "Drop subtitle file here! >_<", "scrapeButton": "Scrape subtitles", + "refresh": "Refresh External Subtitles", + "refreshing": "Refreshing...", "empty": "There are no provided subtitles for this.", "notFound": "None of the available options match your query", "useNativeSubtitles": "Use native video subtitles", @@ -724,6 +749,8 @@ "errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.", "errorNotSupported": "The media or media provider object is not supported." }, + "copyDebugInfo": "Copy debug info", + "debugInfo": "Check console for more details.", "homeButton": "Go home", "text": "There was an error trying to play the media 😖. Please try again or try a different source!", "title": "Failed to play video!" diff --git a/src/backend/accounts/bookmarks.ts b/src/backend/accounts/bookmarks.ts index 79495415..bb64e712 100644 --- a/src/backend/accounts/bookmarks.ts +++ b/src/backend/accounts/bookmarks.ts @@ -15,6 +15,7 @@ export interface BookmarkMetaInput { export interface BookmarkInput { tmdbId: string; meta: BookmarkMetaInput; + group?: string[]; } export function bookmarkMediaToInput( @@ -29,6 +30,7 @@ export function bookmarkMediaToInput( year: item.year ?? 0, }, tmdbId, + group: item.group, }; } diff --git a/src/backend/accounts/crypto.ts b/src/backend/accounts/crypto.ts index ed78fdfb..ca5b72fd 100644 --- a/src/backend/accounts/crypto.ts +++ b/src/backend/accounts/crypto.ts @@ -26,7 +26,15 @@ async function seedFromMnemonic(mnemonic: string) { } export function verifyValidMnemonic(mnemonic: string) { - return validateMnemonic(mnemonic, wordlist); + // First try to validate as BIP39 mnemonic + if (validateMnemonic(mnemonic, wordlist)) { + return true; + } + + // If not a valid BIP39 mnemonic, check if it's a valid custom passphrase + const validPassphraseRegex = + /^[a-zA-Z0-9\s\-_.,!?@#$%^&*()+=:;"'<>[\]{}|\\/`~]+$/; + return mnemonic.length >= 8 && validPassphraseRegex.test(mnemonic); } export async function keysFromSeed(seed: Uint8Array): Promise { diff --git a/src/backend/accounts/groupOrder.ts b/src/backend/accounts/groupOrder.ts new file mode 100644 index 00000000..a502a5d6 --- /dev/null +++ b/src/backend/accounts/groupOrder.ts @@ -0,0 +1,29 @@ +import { ofetch } from "ofetch"; + +import { getAuthHeaders } from "@/backend/accounts/auth"; +import { AccountWithToken } from "@/stores/auth"; + +export interface GroupOrderResponse { + groupOrder: string[]; +} + +export function updateGroupOrder( + url: string, + account: AccountWithToken, + groupOrder: string[], +) { + return ofetch(`/users/${account.userId}/group-order`, { + method: "PUT", + body: groupOrder, + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} + +export function getGroupOrder(url: string, account: AccountWithToken) { + return ofetch(`/users/${account.userId}/group-order`, { + method: "GET", + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} diff --git a/src/backend/accounts/user.ts b/src/backend/accounts/user.ts index 3edad0b7..c7450bcd 100644 --- a/src/backend/accounts/user.ts +++ b/src/backend/accounts/user.ts @@ -34,6 +34,7 @@ export interface BookmarkResponse { poster?: string; type: "show" | "movie"; }; + group: string[]; updatedAt: string; } @@ -62,6 +63,7 @@ export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) { const entries = responses.map((bookmark) => { const item: BookmarkMediaItem = { ...bookmark.meta, + group: bookmark.group.length > 0 ? bookmark.group : undefined, updatedAt: new Date(bookmark.updatedAt).getTime(), }; return [bookmark.tmdbId, item] as const; diff --git a/src/backend/helpers/subs.ts b/src/backend/helpers/subs.ts index a49aa247..32b9480d 100644 --- a/src/backend/helpers/subs.ts +++ b/src/backend/helpers/subs.ts @@ -1,7 +1,10 @@ import { list } from "subsrt-ts"; import { proxiedFetch } from "@/backend/helpers/fetch"; -import { convertSubtitlesToSrt } from "@/components/player/utils/captions"; +import { + convertSubtitlesToSrt, + fixUTF8Encoding, +} from "@/components/player/utils/captions"; import { CaptionListItem } from "@/stores/player/slices/source"; import { SimpleCache } from "@/utils/cache"; @@ -62,13 +65,14 @@ export async function downloadCaption( } if (!data) throw new Error("failed to get caption data"); - // 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(data); const utf8Data = decoder.decode(utf8Bytes); + const fixedData = fixUTF8Encoding(utf8Data); - const output = convertSubtitlesToSrt(utf8Data); + const output = convertSubtitlesToSrt(fixedData); downloadCache.set(caption.url, output, expirySeconds); return output; } @@ -93,11 +97,12 @@ export async function downloadWebVTT(url: string): Promise { const decoder = new TextDecoder(charset); const data = decoder.decode(buffer); - // Ensure the data is in UTF-8 + // Ensure the data is in UTF-8 and fix any encoding issues const encoder = new TextEncoder(); const utf8Bytes = encoder.encode(data); const utf8Data = decoder.decode(utf8Bytes); + const fixedData = fixUTF8Encoding(utf8Data); - downloadCache.set(url, utf8Data, expirySeconds); - return utf8Data; + downloadCache.set(url, fixedData, expirySeconds); + return fixedData; } diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 95d45610..35cfaca2 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -79,6 +79,8 @@ export enum Icons { TMDB = "tmdb", IMDB = "imdb", EAR = "ear", + BELL = "bell", + RELOAD = "reload", } export interface IconProps { @@ -175,6 +177,8 @@ const iconList: Record = { tmdb: ``, imdb: ` `, ear: ` `, + bell: ``, + reload: ``, }; function ChromeCastButton() { diff --git a/src/components/UserIcon.tsx b/src/components/UserIcon.tsx index 64d54a4f..797ea1e2 100644 --- a/src/components/UserIcon.tsx +++ b/src/components/UserIcon.tsx @@ -5,7 +5,7 @@ import { Icon, Icons } from "@/components/Icon"; export enum UserIcons { CAT = "cat", WEED = "weed", - USER_GROUP = "userGroup", + USER_GROUP = "user_group", COUCH = "couch", MOBILE = "mobile", TICKET = "ticket", @@ -20,7 +20,8 @@ export enum UserIcons { RISING_STAR = "rising_star", CLOUD_ARROW_UP = "cloud_arrow_up", WAND = "wand", - CLAPPER_BOARD = "clapperBoard", + CLAPPER_BOARD = "clapper_board", + BOOKMARK = "bookmark", } export interface UserIconProps { @@ -31,22 +32,23 @@ export interface UserIconProps { const iconList: Record = { cat: ``, weed: ``, - userGroup: ``, + user_group: ``, couch: ``, mobile: ``, ticket: ``, saturn: ``, headphones: ``, tv: ``, - ghost: ``, + ghost: ``, coffee: ``, - fire: ``, - megaphone: ``, + fire: ``, + megaphone: ``, dragon: ``, rising_star: ``, cloud_arrow_up: ``, - wand: ``, - clapperBoard: ``, + wand: ``, + clapper_board: ``, + bookmark: ``, }; export const UserIcon = memo((props: UserIconProps) => { diff --git a/src/components/buttons/EditButton.tsx b/src/components/buttons/EditButton.tsx index 42908063..ed98a4e6 100644 --- a/src/components/buttons/EditButton.tsx +++ b/src/components/buttons/EditButton.tsx @@ -1,4 +1,5 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; +import classNames from "classnames"; import { useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -25,7 +26,12 @@ export function EditButton(props: EditButtonProps) { ref={buttonRef} type="button" onClick={onClick} - className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105" + className={classNames( + "h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105", + { + "hidden sm:flex": props.editing, + }, + )} id={props.id} // Assign id to the button > diff --git a/src/components/buttons/EditButtonWithText.tsx b/src/components/buttons/EditButtonWithText.tsx new file mode 100644 index 00000000..860120e4 --- /dev/null +++ b/src/components/buttons/EditButtonWithText.tsx @@ -0,0 +1,43 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +export interface EditButtonWithTextProps { + editing: boolean; + onEdit?: (editing: boolean) => void; + id?: string; + text: string; + secondaryText?: string; +} + +export function EditButtonWithText(props: EditButtonWithTextProps) { + const { t } = useTranslation(); + const [parent] = useAutoAnimate(); + const buttonRef = useRef(null); + + const onClick = useCallback(() => { + props.onEdit?.(!props.editing); + }, [props]); + + return ( + + ); +} diff --git a/src/components/form/GroupDropdown.tsx b/src/components/form/GroupDropdown.tsx new file mode 100644 index 00000000..00263654 --- /dev/null +++ b/src/components/form/GroupDropdown.tsx @@ -0,0 +1,230 @@ +import { t } from "i18next"; +import React, { useEffect, useRef, useState } from "react"; + +import { Icon, Icons } from "@/components/Icon"; +import { UserIcon, UserIcons } from "@/components/UserIcon"; + +interface GroupDropdownProps { + groups: string[]; + currentGroups: string[]; + onSelectGroups: (groups: string[]) => void; + onCreateGroup: (group: string, icon: UserIcons) => void; + onRemoveGroup: (groupToRemove?: string) => void; +} + +const userIconList = Object.values(UserIcons); + +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] || userIconList[0]; + const name = match[2].trim(); + return { icon, name }; + } + return { icon: userIconList[0], name: group }; +} + +export function GroupDropdown({ + groups, + currentGroups, + onSelectGroups, + onCreateGroup, + onRemoveGroup, +}: GroupDropdownProps) { + const [open, setOpen] = useState(false); + const [newGroup, setNewGroup] = useState(""); + const [showInput, setShowInput] = useState(false); + const [selectedIcon, setSelectedIcon] = useState(userIconList[0]); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setOpen(false); + setShowInput(false); + setNewGroup(""); + setSelectedIcon(userIconList[0]); + } + }; + + if (open) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [open]); + + const handleToggleGroup = (group: string) => { + let newGroups; + if (currentGroups.includes(group)) { + newGroups = currentGroups.filter((g) => g !== group); + } else { + newGroups = [...currentGroups, group]; + } + onSelectGroups(newGroups); + }; + + const handleCreate = (group: string, icon: UserIcons) => { + const groupString = `[${icon}]${group}`; + onCreateGroup(groupString, icon); + setOpen(false); + setShowInput(false); + setNewGroup(""); + setSelectedIcon(userIconList[0]); + }; + + return ( +
+ + {open && ( +
+ {groups.length === 0 && !showInput && ( +
+ {t("home.bookmarks.groups.dropdown.empty")} +
+ )} + {groups.map((group) => { + const { icon, name } = parseGroupString(group); + return ( + + ); + })} +
+
+ setNewGroup(e.target.value)} + className="flex-1 px-2 py-1 rounded bg-background-main text-white border border-background-secondary text-xs min-w-0 placeholder:text-type-secondary" + placeholder="Group name" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(newGroup, selectedIcon); + if (e.key === "Escape") setShowInput(false); + }} + style={{ minWidth: 0 }} + /> + +
+ {newGroup.trim().length > 0 && ( +
+ {userIconList.map((icon) => ( + + ))} +
+ )} +
+ {currentGroups.length > 0 && ( +
+
+ {t("home.bookmarks.groups.dropdown.removeFromGroup")} +
+
+ {currentGroups.map((group) => { + const { icon, name } = parseGroupString(group); + return ( + + ); + })} + +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index fd60f9e2..d2520a72 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -6,6 +6,7 @@ import { NoUserAvatar, UserAvatar } from "@/components/Avatar"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { LinksDropdown } from "@/components/LinksDropdown"; +import { useNotifications } from "@/components/overlays/notificationsModal"; import { Lightbar } from "@/components/utils/Lightbar"; import { useAuth } from "@/hooks/auth/useAuth"; import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; @@ -27,6 +28,7 @@ export function Navigation(props: NavigationProps) { const navigate = useNavigate(); const { loggedIn } = useAuth(); const [scrollPosition, setScrollPosition] = useState(0); + const { openNotifications, getUnreadCount } = useNotifications(); useEffect(() => { const handleScroll = () => { @@ -182,6 +184,23 @@ export function Navigation(props: NavigationProps) { /> ))} + openNotifications()} + rel="noreferrer" + className="text-xl text-white tabbable rounded-full backdrop-blur-lg relative" + > + + {(() => { + const count = getUnreadCount(); + const shouldShow = + typeof count === "number" ? count > 0 : count === "99+"; + return shouldShow ? ( + + {count} + + ) : null; + })()} +
diff --git a/src/components/layout/SectionHeading.tsx b/src/components/layout/SectionHeading.tsx index 68f9d33a..526a57f7 100644 --- a/src/components/layout/SectionHeading.tsx +++ b/src/components/layout/SectionHeading.tsx @@ -7,6 +7,7 @@ interface SectionHeadingProps { title: string; children?: ReactNode; className?: string; + customIcon?: ReactNode; } export function SectionHeading(props: SectionHeadingProps) { @@ -14,7 +15,11 @@ export function SectionHeading(props: SectionHeadingProps) {

- {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")} +
+ + +
-
{/* 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 []; + } +}