diff --git a/index.html b/index.html index 32c22fbe..2717b38a 100644 --- a/index.html +++ b/index.html @@ -38,7 +38,7 @@ - + =12.4.0'} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/9e7654ee21220d5ea91d056995584168666138a1': - resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/9e7654ee21220d5ea91d056995584168666138a1} + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0': + resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0} version: 3.2.0 '@pkgjs/parseargs@0.11.0': @@ -3750,8 +3750,8 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -4460,9 +4460,8 @@ packages: utf-8-validate: optional: true - wyzie-lib@https://codeload.github.com/FifthWit/wyzie-lib/tar.gz/2df6de4ed84f3253e4fccaa070312574ebf7d052: - resolution: {tarball: https://codeload.github.com/FifthWit/wyzie-lib/tar.gz/2df6de4ed84f3253e4fccaa070312574ebf7d052} - version: 2.2.6 + wyzie-lib@2.2.6: + resolution: {integrity: sha512-pD69lOD4h8wYlw9ZeP+6/qY3Eue213atv0sUX3yyF2KCZcxRuM0D7JD/utvd6cDKtyNcf4SZtrbtoQe4Z5wCtA==} xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} @@ -5524,13 +5523,14 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/9e7654ee21220d5ea91d056995584168666138a1': + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0': dependencies: abort-controller: 3.0.0 cheerio: 1.0.0-rc.12 cookie: 0.6.0 crypto-js: 4.2.0 form-data: 4.0.4 + fuse.js: 7.1.0 hls-parser: 0.13.6 iso-639-1: 3.1.5 json5: 2.2.3 @@ -9052,7 +9052,7 @@ snapshots: ws@8.18.3: {} - wyzie-lib@https://codeload.github.com/FifthWit/wyzie-lib/tar.gz/2df6de4ed84f3253e4fccaa070312574ebf7d052: {} + wyzie-lib@2.2.6: {} xml-name-validator@5.0.0: {} diff --git a/public/notifications.xml b/public/notifications.xml index 55b64f78..1a171d7c 100644 --- a/public/notifications.xml +++ b/public/notifications.xml @@ -8,6 +8,103 @@ Mon, 29 Sep 2025 18:00:00 MST + + notification-054 + P-Stream v5.3.3 released! + Merry Christmas everyone! πŸŽ„ + +I hope everyone has a great holiday season this year, and thank you to everyone who has supported P-Stream! I'm very happy to have this community and give you all free movies and tv. This is a pretty big update thats been in the works, but I wanted to get it out before Christmas! Hopefully, we'll have another smaller update before the end of the year! + +Additions: +- Revamped the captions menu to sort by language +- Added a new Auto Select caption button to automatically choose and enable a caption! +- Added a new setting to customize the keyboard shortcuts! +- Added a new Minimal Cards setting to hide the bottom text on all media cards +- Improved visual feedback when a video is not found +- Reworked the extensions onboarding page to reference the new userscript alternative +- Added a new button to mark a movie as watched from the details modal +- Added new Cobalt theme +- Added new Febbox usage indicator under the token input +- Added a new dropdown to change the sorting of your bookmarks or watching section (A-Z, Z-A, release date, etc)! + +Fixes: +- Improved caption selection logic to be more consistent +- Improved auto quality selection logic to be more consistent +- Improved buffering logic to load the video quicker and be more stable +- Fixed "Similar" section showing the wrong content +- Fixed carousel title padding and alignment +- Fixed search bar placement within the PWA on iOS +- Fixed many other small bugs and issues! + +Enjoy and have a lovely holiday! + + Wed, 24 Dec 2025 11:10:00 MST + update + + + + notification-053 + New Userscript alternative to the extension! + If you prefer to use a userscript instead of the extension, you now have the option! + +It should work almost exactly the same as the extension, but without the need to install an extension. Unfortunately, our Firefox extension store page keeps getting taken down, so this is the alternative until we can get it back up or if you install the .xpi manually. + +Some things to note: + +- The extension is still the most reliable method! +- This does not currently work in Safari or Orion due to Webkit’s limitations with webRequest APIs. :( + +Thanks to Duplicake for the incredible help building this! + +Use the link below to install the userscript into Violentmonkey or Tampermonkey! + +P.S. We're still working on getting some new sources online, so expect more updates soon! + + https://raw.githubusercontent.com/p-stream/Userscript/main/p-stream.user.js + Sun, 14 Dec 2025 15:00:00 MST + feature + + + + notification-052 + Thanks to everyone who has supported! + We've reached our goal of $300! Thanks to everyone who has supported and we'll be working hard to bring you new great things! + +We're working on fixing those source issues and hope to have everything back up and running soon! + +I'll be reaching out to everyone who has donated to get custom email addresses for the @pstream.mov domain! You can still donate at the link below! + + https://rentry.co/nnqtas3e + Tue, 09 Dec 2025 17:30:00 MST + announcement + + + + notification-051 + Xprime has been disabled. More info below! + Yes this is unfortunate, Xprime has been disabled due to funding issues on their end. + +Xprime asked if we could introduce ads to P-Stream, and after some consideration and ad testing last week, the P-Stream team and I have decided not to add ads to P-Stream. Ads are not only unattractive, but unsafe and against the team's morals. + +movie-web (what P-Stream was forked from) was created to be easily self-hostable and ad free. And though the media landscape has changed (cors on streams and our scrapers getting blocked) we've still take this into consideration. I still believe P-Stream is better without ads, and our single banner ad reflects this; its unobtrusive and safe. + +Xprime may come back in the future, but we're already working on a new source that should be just as good or better. Plus we will continue to support FED API and all the extension sources that make P-Stream (and self hosted sites) amazing. + +Thanks to everyone who has supported and we'll be working hard to bring you new great things! + + Sat, 06 Dec 2025 16:00:00 MST + announcement + + + + notification-050 + New alerts for source failures! + I just added a new alert that pops up if FED API, FED DB, or XPrime sources fail the turnstile check. Previously it would just show the ⚠️ icon and give no explanation why it failed. Now you know why! Simply go back and select that source again or reload the page! + + Tue, 2 Dec 2025 13:11:00 MST + Feature + + notification-049 P-Stream v5.3.2 released! @@ -231,7 +328,7 @@ It will improve uptime for FED API and faster EU streams, another proxy which we If you are interested in donating, please check the link below! - https://rentry.co/h5mypdfs + https://rentry.co/nnqtas3e Sat, 06 Sep 2025 14:42:00 MST announcement diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 16aebae3..a0f93d16 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -123,12 +123,12 @@ }, "q24": { "title": "My febbox token fails during setup. What should I do?", - "body": "Your ISP might be blocking FED API. Set a custom DNS like 1.1.1.1 or NextDNS. You can also change your region in the admin panel.", + "body": "Your ISP might be blocking FED API. Use a VPN to bypass the block, we recommend Cloudflare WARP because it's free and easy to use. You can also change your region in the admin panel.", "section": "connections" }, "q25": { "title": "How do I change languages?", - "body": "We prioritize English, but some sources offer other languages. Try switching sources. For FED API, only Safari supports audio switching in MP4 files.", + "body": "We prioritize English, but some sources offer other languages. Try switching sources.", "section": "language" }, "q26": { @@ -255,8 +255,8 @@ "skipBackward5": "Skip backward 5 seconds", "skipBackward10": "Skip backward 10 seconds", "skipForward10": "Skip forward 10 seconds", - "skipForward1": "Skip forward 1 second (when paused)", - "skipBackward1": "Skip backward 1 second (when paused)", + "skipForward1": "Skip forward 1 second", + "skipBackward1": "Skip backward 1 second", "jumpTo0": "Jump to 0% (beginning)", "jumpTo9": "Jump to 90%", "increaseVolume": "Increase volume", @@ -265,17 +265,32 @@ "changeSpeed": "Increase/decrease playback speed", "toggleFullscreen": "Toggle fullscreen", "toggleCaptions": "Toggle captions", + "randomCaption": "Select random caption from last used language", "syncSubtitlesEarlier": "Sync subtitles earlier (-0.5s)", "syncSubtitlesLater": "Sync subtitles later (+0.5s)", "barrelRoll": "Do a barrel roll! πŸŒ€", "closeOverlay": "Close overlay/modal", + "nextEpisode": "Next episode", + "previousEpisode": "Previous episode", "widescreenMode": "to toggle the widescreen button visibility", "copyLinkWithTime": "+ click the title to copy the link with time" }, "conditions": { "notInWatchParty": "Not in watch party", - "whenPaused": "When paused" - } + "showsOnly": "Shows only" + }, + "editInSettings": "Edit keyboard commands from settings", + "clickToEdit": "Click on a key badge to edit it", + "conflict": "conflict", + "conflicts": "conflicts", + "detected": "detected", + "resetAllToDefault": "Reset All to Default", + "pressKey": "Press a key...", + "none": "None", + "save": "Save", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "resetToDefault": "Reset to default" } }, "home": { @@ -317,10 +332,30 @@ "titlePlaceholder": "Enter a title for your bookmark", "yearLabel": "Year", "yearPlaceholder": "Enter a year for your bookmark" + }, + "sorting": { + "label": "Sort by", + "options": { + "date": "Default (Date added)", + "titleAsc": "Title A-Z", + "titleDesc": "Title Z-A", + "yearAsc": "Release Date Oldest-Newest", + "yearDesc": "Release Date Newest-Oldest" + } } }, "continueWatching": { - "sectionTitle": "Continue Watching..." + "sectionTitle": "Continue Watching...", + "sorting": { + "label": "Sort by", + "options": { + "date": "Default (Date added)", + "titleAsc": "Title A-Z", + "titleDesc": "Title Z-A", + "yearAsc": "Release Date Oldest-Newest", + "yearDesc": "Release Date Newest-Oldest" + } + } }, "mediaList": { "stopEditing": "Stop editing" @@ -392,6 +427,18 @@ "It's the Great Pumpkin, Charlie Brown!" ] } + }, + "support": { + "title": "P-Stream needs your help!", + "description": "P-Stream is run at a loss, and we need help to keep it ad free! If you enjoy using P-Stream, please consider donating to help us cover our costs.", + "moreInfo": "More info", + "explanation": "If you aren't using the extension or don't have FED API set up, it may be harder to find content! We want to fix this, but it's a lot harder to provide content without expensive servers. So please, if you enjoy using P-Stream, please consider donating to help us cover our growing costs.", + "explanation2": "If you want more info, please join our ", + "discord": "Discord", + "thankYou": "Thank you for your support!", + "donate": "Donate", + "label": "Project Funding: ${{current}} / ${{goal}}", + "complete": "complete" } }, "media": { @@ -567,6 +614,8 @@ "extensionHelp": "If you've installed the extension but it's not detected, open the extension through your browsers extension menu and follow the steps on screen.", "linkChrome": "Install Chrome extension", "linkFirefox": "Install Firefox extension", + "linkUserscript": "Alternative Userscript", + "userscriptNote": "(The extension is more reliable than the userscript!)", "notDetecting": "Installed on Chrome, but the site isn't detecting it? Try reloading the page!", "notDetectingAction": "Reload page", "status": { @@ -610,7 +659,7 @@ "defaultDescription": "Uses P-Stream's built-in proxy. It's the easiest option but might be slower due to shared bandwidth.", "fedapi": { "fedapi": "Additional: Febbox token", - "fedapiDescription": "Bring your own FREE Febbox account to gain access to FED API and CIA API, the best sources with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" + "fedapiDescription": "Bring your own FREE Febbox account to gain access to FED API, the best sources with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" }, "outro": "If you have more questions on how this works, feel free to ask on the <0>P-Stream Discord server!" }, @@ -647,6 +696,9 @@ "description": "Setup a free proxy in just 5 minutes! Improves loading reliability!", "quality": "Good quality", "title": "Custom proxy" + }, + "addons": { + "title": "Additional sources:" } }, "title": "Let's get you setup with P-Stream πŸ₯³" @@ -763,6 +815,8 @@ "previewLabel": "Subtitle preview:", "offChoice": "Off", "onChoice": "On", + "autoSelectChoice": "Auto select", + "autoSelectDifferentChoice": "Tap again to auto select different subtitle", "SourceChoice": "Source Subtitles", "OpenSubtitlesChoice": "External Subtitles", "loadingExternal": "Loading external subtitles...", @@ -905,7 +959,7 @@ "title": "Support", "text": "P-Stream is designed to be as user-friendly as possible. However, people still have questions and issues. This page is here to help resolve these shortcomings", "q1": { - "body": "Well, you can join the official <0>P-Stream discord and ask questions there or you can email the one provided at the bottom of this page.", + "body": "You can join the official <0>P-Stream discord and ask questions there or you can email the one provided at the bottom of this page.", "title": "Where can I get help?" }, "q2": { @@ -917,7 +971,7 @@ "title": "Jip", "text": "P-Stream didn't fall out of a coconut tree, it was made mostly by a single person (a very epic one at that).", "q1": { - "body": "Well, you can join the official <0>P-Stream discord and ask questions there or you can email the one provided at the bottom of this page.", + "body": "You can join the official <0>P-Stream discord and ask questions there or you can email the one provided at the bottom of this page.", "title": "Where can I get help?" }, "q2": { @@ -1028,7 +1082,7 @@ "popsicle": "Popsicle", "hulk": "Hulk", "autumn": "Autumn", - "skyrealm": "SkyRealm", + "cobalt": "Cobalt", "frost": "Frost", "christmas": "Christmas" }, @@ -1050,6 +1104,9 @@ "carouselView": "Carousel view", "carouselViewDescription": "Display your currently watching and bookmark sections as carousels instead of a grid. Disabled by default.", "carouselViewLabel": "Carousel view", + "minimalCards": "Minimal cards", + "minimalCardsDescription": "Hide text content (title, type, year) on media cards, showing only the poster image.", + "minimalCardsLabel": "Minimal cards", "forceCompactEpisodeView": "Force compact episode view", "forceCompactEpisodeViewDescription": "Force the episode carousel in the player to use the \"classic\" compact vertical view. Disabled by default.", "homeSectionOrder": "Home section order", @@ -1132,6 +1189,9 @@ "doubleClickToSeek": "Double tap to seek", "doubleClickToSeekDescription": "Double tap on the left or right side of the player to seek 10 seconds forward or backward.", "doubleClickToSeekLabel": "Enable double tap to seek", + "keyboardShortcuts": "Keyboard Shortcuts", + "keyboardShortcutsDescription": "Customize the keyboard shortcuts for the application. Hold ` to show this help anytime", + "keyboardShortcutsLabel": "Customize Keyboard Shortcuts", "sourceOrder": "Reordering sources", "sourceOrderDescription": "Drag and drop to reorder sources. This will determine the order in which sources are checked for the media you are trying to watch. If a source is greyed out, it means the extension is required for that source.

(The default order is best for most users)", "sourceOrderEnableLabel": "Custom source order", @@ -1248,7 +1308,7 @@ "fedapi": { "onboarding": { "title": "Febbox token", - "description": "Bring your own FREE Febbox account to gain access to FED API and CIA API, the best sources with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" + "description": "Bring your own FREE Febbox account to gain access to FED API, the best sources with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" }, "setup": { "title": "To get your UI token:", @@ -1259,8 +1319,7 @@ "2": "2. Open DevTools or inspect the page", "3": "3. Go to Application tab β†’ Cookies", "4": "4. Copy the 'ui' cookie's value.", - "5": "5. Close the tab, but do NOT logout!", - "warning": "(Do not share this token!)" + "5": "5. Close the tab, but do NOT logout!" }, "tokenLabel": "Token", "tokenExample": { @@ -1269,7 +1328,9 @@ "description": "This is what a Febbox UI token looks like:", "warning": "Don't try to use, it's fake.", "close": "Got it" - } + }, + "traffic": "{{used}} / {{limit}} High-speed Traffic β€’ Resets in {{reset}}", + "trafficExplanation": "Febbox gives you 100GB/month of high-speed traffic, the streams might buffer more after you've used up your quota. Depends on your internet speed and the quality of the stream." }, "status": { "success": "success", diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index c38cf17f..83993b0f 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -2,6 +2,7 @@ import { ofetch } from "ofetch"; import { getAuthHeaders } from "@/backend/accounts/auth"; import { AccountWithToken } from "@/stores/auth"; +import { KeyboardShortcuts } from "@/utils/keyboardShortcuts"; export interface SettingsInput { applicationLanguage?: string; @@ -19,15 +20,14 @@ export interface SettingsInput { enableDetailsModal?: boolean; enableImageLogos?: boolean; enableCarouselView?: boolean; + enableMinimalCards?: boolean; forceCompactEpisodeView?: boolean; sourceOrder?: string[] | null; enableSourceOrder?: boolean; lastSuccessfulSource?: string | null; enableLastSuccessfulSource?: boolean; - disabledSources?: string[] | null; embedOrder?: string[] | null; enableEmbedOrder?: boolean; - disabledEmbeds?: string[] | null; proxyTmdb?: boolean; enableLowPerformanceMode?: boolean; enableNativeSubtitles?: boolean; @@ -36,6 +36,7 @@ export interface SettingsInput { manualSourceSelection?: boolean; enableDoubleClickToSeek?: boolean; enableAutoResumeOnPlaybackError?: boolean; + keyboardShortcuts?: KeyboardShortcuts; } export interface SettingsResponse { @@ -54,15 +55,14 @@ export interface SettingsResponse { enableDetailsModal?: boolean; enableImageLogos?: boolean; enableCarouselView?: boolean; + enableMinimalCards?: boolean; forceCompactEpisodeView?: boolean; sourceOrder?: string[] | null; enableSourceOrder?: boolean; lastSuccessfulSource?: string | null; enableLastSuccessfulSource?: boolean; - disabledSources?: string[] | null; embedOrder?: string[] | null; enableEmbedOrder?: boolean; - disabledEmbeds?: string[] | null; proxyTmdb?: boolean; enableLowPerformanceMode?: boolean; enableNativeSubtitles?: boolean; @@ -71,6 +71,7 @@ export interface SettingsResponse { manualSourceSelection?: boolean; enableDoubleClickToSeek?: boolean; enableAutoResumeOnPlaybackError?: boolean; + keyboardShortcuts?: KeyboardShortcuts; } export function updateSettings( diff --git a/src/backend/metadata/traktFunctions.ts b/src/backend/metadata/traktFunctions.ts index d1ef952b..d87b39c9 100644 --- a/src/backend/metadata/traktFunctions.ts +++ b/src/backend/metadata/traktFunctions.ts @@ -7,15 +7,26 @@ export function paginateResults( pageSize: number = 20, contentType: "movie" | "tv" | "both" = "both", ): PaginatedTraktResponse { + if (!results) { + return { + tmdb_ids: [], + hasMore: false, + totalCount: 0, + }; + } + let tmdbIds: number[]; if (contentType === "movie") { - tmdbIds = results.movie_tmdb_ids; + tmdbIds = results.movie_tmdb_ids || []; } else if (contentType === "tv") { - tmdbIds = results.tv_tmdb_ids; + tmdbIds = results.tv_tmdb_ids || []; } else { // For 'both', combine movies and TV shows - tmdbIds = [...results.movie_tmdb_ids, ...results.tv_tmdb_ids]; + tmdbIds = [ + ...(results.movie_tmdb_ids || []), + ...(results.tv_tmdb_ids || []), + ]; } const startIndex = (page - 1) * pageSize; diff --git a/src/components/LinksDropdown.tsx b/src/components/LinksDropdown.tsx index d4338b4b..50ae38c0 100644 --- a/src/components/LinksDropdown.tsx +++ b/src/components/LinksDropdown.tsx @@ -315,7 +315,7 @@ export function LinksDropdown(props: { children: React.ReactNode }) { /> diff --git a/src/components/UserIcon.tsx b/src/components/UserIcon.tsx index 797ea1e2..11b9afb1 100644 --- a/src/components/UserIcon.tsx +++ b/src/components/UserIcon.tsx @@ -22,6 +22,11 @@ export enum UserIcons { WAND = "wand", CLAPPER_BOARD = "clapper_board", BOOKMARK = "bookmark", + FIREFOX = "firefox", + CHROME = "chrome", + SAFARI = "safari", + ORION = "orion", + EDGE = "edge", } export interface UserIconProps { @@ -49,6 +54,11 @@ const iconList: Record = { wand: ``, clapper_board: ``, bookmark: ``, + firefox: ``, + chrome: ``, + safari: ``, + edge: ``, + orion: ``, }; export const UserIcon = memo((props: UserIconProps) => { diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 458f2294..5eb71c42 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -83,7 +83,7 @@ export function Footer() { {t("footer.links.discord")} - + {t("footer.links.funding")}
diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 4fd28646..78586926 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -50,6 +50,8 @@ function useIntersectionObserver(options: IntersectionObserverInit = {}) { // Skeleton Component export function MediaCardSkeleton() { + const enableMinimalCards = usePreferencesStore((s) => s.enableMinimalCards); + return (
{/* Poster skeleton - matches MediaCard poster dimensions exactly */} -
+
- {/* Title skeleton - matches MediaCard title dimensions */} -
-
-
-
-
+ {!enableMinimalCards && ( + <> + {/* Title skeleton - matches MediaCard title dimensions */} +
+
+
+
+
- {/* Dot list skeleton - matches MediaCard dot list */} -
-
-
-
-
+ {/* Dot list skeleton - matches MediaCard dot list */} +
+
+
+
+
+ + )}
@@ -136,6 +147,7 @@ function MediaCardContent({ const dotListContent = [t(`media.types.${media.type}`)]; const [searchQuery] = useSearchQuery(); + const enableMinimalCards = usePreferencesStore((s) => s.enableMinimalCards); // Simple intersection observer for lazy loading images const { targetRef, isIntersecting } = useIntersectionObserver({ @@ -185,10 +197,11 @@ function MediaCardContent({ >
-

- {media.title} -

-
- -
+ {!enableMinimalCards && ( + <> +

+ {media.title} +

+
+ +
- {!closable && ( -
- -
- )} - {editable && closable && ( -
- -
+ {!closable && ( +
+ +
+ )} + {editable && closable && ( +
+ +
+ )} + )} diff --git a/src/components/overlays/KeyboardCommandsEditModal.tsx b/src/components/overlays/KeyboardCommandsEditModal.tsx new file mode 100644 index 00000000..3d40e4c7 --- /dev/null +++ b/src/components/overlays/KeyboardCommandsEditModal.tsx @@ -0,0 +1,518 @@ +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { updateSettings } from "@/backend/accounts/settings"; +import { Button } from "@/components/buttons/Button"; +import { Dropdown } from "@/components/form/Dropdown"; +import { Icon, Icons } from "@/components/Icon"; +import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; +import { Heading2 } from "@/components/utils/Text"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { useAuthStore } from "@/stores/auth"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; +import { usePreferencesStore } from "@/stores/preferences"; +import { + DEFAULT_KEYBOARD_SHORTCUTS, + KeyboardModifier, + KeyboardShortcutConfig, + KeyboardShortcuts, + LOCKED_SHORTCUT_IDS, + ShortcutId, + findConflicts, + getKeyDisplayName, + getModifierSymbol, + isNumberKey, +} from "@/utils/keyboardShortcuts"; + +interface KeyboardShortcut { + id: ShortcutId; + config: KeyboardShortcutConfig; + description: string; + condition?: string; +} + +interface ShortcutGroup { + title: string; + shortcuts: KeyboardShortcut[]; +} + +function KeyBadge({ + config, + children, + onClick, + editing, + hasConflict, +}: { + config?: KeyboardShortcutConfig; + children: ReactNode; + onClick?: () => void; + editing?: boolean; + hasConflict?: boolean; +}) { + const modifier = config?.modifier; + + return ( + + {children} + {modifier && ( + + {getModifierSymbol(modifier)} + + )} + + ); +} + +const getShortcutGroups = ( + t: (key: string) => string, + shortcuts: KeyboardShortcuts, +): ShortcutGroup[] => { + return [ + { + title: t("global.keyboardShortcuts.groups.videoPlayback"), + shortcuts: [ + { + id: ShortcutId.SKIP_FORWARD_5, + config: shortcuts[ShortcutId.SKIP_FORWARD_5], + description: t("global.keyboardShortcuts.shortcuts.skipForward5"), + }, + { + id: ShortcutId.SKIP_BACKWARD_5, + config: shortcuts[ShortcutId.SKIP_BACKWARD_5], + description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), + }, + { + id: ShortcutId.SKIP_FORWARD_10, + config: shortcuts[ShortcutId.SKIP_FORWARD_10], + description: t("global.keyboardShortcuts.shortcuts.skipForward10"), + }, + { + id: ShortcutId.SKIP_BACKWARD_10, + config: shortcuts[ShortcutId.SKIP_BACKWARD_10], + description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), + }, + { + id: ShortcutId.SKIP_FORWARD_1, + config: shortcuts[ShortcutId.SKIP_FORWARD_1], + description: t("global.keyboardShortcuts.shortcuts.skipForward1"), + }, + { + id: ShortcutId.SKIP_BACKWARD_1, + config: shortcuts[ShortcutId.SKIP_BACKWARD_1], + description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), + }, + { + id: ShortcutId.NEXT_EPISODE, + config: shortcuts[ShortcutId.NEXT_EPISODE], + description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + }, + { + id: ShortcutId.PREVIOUS_EPISODE, + config: shortcuts[ShortcutId.PREVIOUS_EPISODE], + description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.audioVideo"), + shortcuts: [ + { + id: ShortcutId.MUTE, + config: shortcuts[ShortcutId.MUTE], + description: t("global.keyboardShortcuts.shortcuts.mute"), + }, + { + id: ShortcutId.TOGGLE_FULLSCREEN, + config: shortcuts[ShortcutId.TOGGLE_FULLSCREEN], + description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), + shortcuts: [ + { + id: ShortcutId.TOGGLE_CAPTIONS, + config: shortcuts[ShortcutId.TOGGLE_CAPTIONS], + description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), + }, + { + id: ShortcutId.RANDOM_CAPTION, + config: shortcuts[ShortcutId.RANDOM_CAPTION], + description: t("global.keyboardShortcuts.shortcuts.randomCaption"), + }, + { + id: ShortcutId.SYNC_SUBTITLES_EARLIER, + config: shortcuts[ShortcutId.SYNC_SUBTITLES_EARLIER], + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", + ), + }, + { + id: ShortcutId.SYNC_SUBTITLES_LATER, + config: shortcuts[ShortcutId.SYNC_SUBTITLES_LATER], + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesLater", + ), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.interface"), + shortcuts: [ + { + id: ShortcutId.BARREL_ROLL, + config: shortcuts[ShortcutId.BARREL_ROLL], + description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), + }, + ], + }, + ]; +}; + +interface KeyboardCommandsEditModalProps { + id: string; +} + +export function KeyboardCommandsEditModal({ + id, +}: KeyboardCommandsEditModalProps) { + const { t } = useTranslation(); + const account = useAuthStore((s) => s.account); + const backendUrl = useBackendUrl(); + const { hideModal } = useOverlayStack(); + const modal = useModal(id); + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); + const setKeyboardShortcuts = usePreferencesStore( + (s) => s.setKeyboardShortcuts, + ); + + const [editingShortcuts, setEditingShortcuts] = + useState(keyboardShortcuts); + const [editingId, setEditingId] = useState(null); + const [editingModifier, setEditingModifier] = useState( + "", + ); + const [editingKey, setEditingKey] = useState(""); + const [isCapturingKey, setIsCapturingKey] = useState(false); + + // Cancel any active editing when modal closes + useEffect(() => { + if (!modal.isShown) { + setEditingId(null); + setEditingModifier(""); + setEditingKey(""); + setIsCapturingKey(false); + } + }, [modal.isShown]); + + const shortcutGroups = getShortcutGroups(t, editingShortcuts).map( + (group) => ({ + ...group, + shortcuts: group.shortcuts.filter( + (s) => !LOCKED_SHORTCUT_IDS.includes(s.id), + ), + }), + ); + const conflicts = findConflicts(editingShortcuts); + const conflictIds = new Set(); + conflicts.forEach((conflict: { id1: string; id2: string }) => { + conflictIds.add(conflict.id1); + conflictIds.add(conflict.id2); + }); + + const modifierOptions = [ + { id: "", name: "None" }, + { id: "Shift", name: "Shift" }, + { id: "Alt", name: "Alt" }, + ]; + + const handleStartEdit = useCallback( + (shortcutId: ShortcutId) => { + const config = editingShortcuts[shortcutId]; + setEditingId(shortcutId); + setEditingModifier(config?.modifier || ""); + setEditingKey(config?.key || ""); + setIsCapturingKey(true); + }, + [editingShortcuts], + ); + + const handleCancelEdit = useCallback(() => { + setEditingId(null); + setEditingModifier(""); + setEditingKey(""); + setIsCapturingKey(false); + }, []); + + const handleKeyCapture = useCallback( + (event: KeyboardEvent) => { + if (!isCapturingKey || !editingId) return; + + // Don't capture modifier keys alone + if ( + event.key === "Shift" || + event.key === "Alt" || + event.key === "Control" || + event.key === "Meta" || + event.key === "Escape" + ) { + return; + } + + // Block number keys (0-9) - they're reserved for progress skipping + if (isNumberKey(event.key)) { + event.preventDefault(); + event.stopPropagation(); + setIsCapturingKey(false); + return; + } + + event.preventDefault(); + event.stopPropagation(); + + setEditingKey(event.key); + setIsCapturingKey(false); + }, + [isCapturingKey, editingId], + ); + + useEffect(() => { + if (isCapturingKey) { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + handleCancelEdit(); + } + }; + window.addEventListener("keydown", handleKeyCapture); + window.addEventListener("keydown", handleEscape); + return () => { + window.removeEventListener("keydown", handleKeyCapture); + window.removeEventListener("keydown", handleEscape); + }; + } + }, [isCapturingKey, handleKeyCapture, handleCancelEdit]); + + const handleSaveEdit = useCallback(() => { + if (!editingId) return; + + const newConfig: KeyboardShortcutConfig = { + modifier: editingModifier || undefined, + key: editingKey || undefined, + }; + + setEditingShortcuts((prev: KeyboardShortcuts) => ({ + ...prev, + [editingId]: newConfig, + })); + + handleCancelEdit(); + }, [editingId, editingModifier, editingKey, handleCancelEdit]); + + const handleResetShortcut = useCallback((shortcutId: ShortcutId) => { + setEditingShortcuts((prev: KeyboardShortcuts) => ({ + ...prev, + [shortcutId]: DEFAULT_KEYBOARD_SHORTCUTS[shortcutId], + })); + }, []); + + const handleResetAll = useCallback(() => { + setEditingShortcuts(DEFAULT_KEYBOARD_SHORTCUTS); + }, []); + + const handleSave = useCallback(async () => { + setKeyboardShortcuts(editingShortcuts); + + if (account && backendUrl) { + try { + await updateSettings(backendUrl, account, { + keyboardShortcuts: editingShortcuts, + }); + } catch (error) { + console.error("Failed to save keyboard shortcuts:", error); + } + } + + hideModal(id); + }, [ + editingShortcuts, + account, + backendUrl, + setKeyboardShortcuts, + hideModal, + id, + ]); + + const handleCancel = useCallback(() => { + hideModal(id); + }, [hideModal, id]); + + return ( + + +
+
+ + {t("global.keyboardShortcuts.title")} + +

+ {t("global.keyboardShortcuts.clickToEdit")} +

+
+ +
+ {conflicts.length > 0 ? ( +

+ {conflicts.length}{" "} + {conflicts.length > 1 + ? t("global.keyboardShortcuts.conflicts") + : t("global.keyboardShortcuts.conflict")}{" "} + {t("global.keyboardShortcuts.detected")} +

+ ) : ( +
// Empty div to take up space + )} + +
+ +
+ {shortcutGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut) => { + const isEditing = editingId === shortcut.id; + const hasConflict = conflictIds.has(shortcut.id); + const config = editingShortcuts[shortcut.id]; + + return ( +
+
+ {isEditing ? ( +
+
+ opt.id === editingModifier, + ) || modifierOptions[0] + } + setSelectedItem={(item) => + setEditingModifier( + item.id as KeyboardModifier | "", + ) + } + options={modifierOptions} + className="w-32 !my-1" + /> + + {isCapturingKey + ? t("global.keyboardShortcuts.pressKey") + : editingKey + ? getKeyDisplayName(editingKey) + : t("global.keyboardShortcuts.none")} + +
+
+ + +
+
+ ) : ( + <> + handleStartEdit(shortcut.id)} + hasConflict={hasConflict} + > + {config?.key + ? getKeyDisplayName(config.key) + : t("global.keyboardShortcuts.none")} + + + {shortcut.description} + + + )} +
+
+ {shortcut.condition && !isEditing && ( + + {shortcut.condition} + + )} + {!isEditing && ( + + )} +
+
+ ); + })} +
+
+ ))} +
+ +
+ + +
+
+ + + ); +} diff --git a/src/components/overlays/KeyboardCommandsModal.tsx b/src/components/overlays/KeyboardCommandsModal.tsx index e70928cf..8e93438e 100644 --- a/src/components/overlays/KeyboardCommandsModal.tsx +++ b/src/components/overlays/KeyboardCommandsModal.tsx @@ -1,13 +1,23 @@ import { ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import { Modal, ModalCard } from "@/components/overlays/Modal"; import { Heading2 } from "@/components/utils/Text"; +import { usePreferencesStore } from "@/stores/preferences"; +import { + DEFAULT_KEYBOARD_SHORTCUTS, + KeyboardShortcutConfig, + ShortcutId, + getKeyDisplayName, + getModifierSymbol, +} from "@/utils/keyboardShortcuts"; interface KeyboardShortcut { key: string; description: string; condition?: string; + config?: KeyboardShortcutConfig; } interface ShortcutGroup { @@ -15,139 +25,198 @@ interface ShortcutGroup { shortcuts: KeyboardShortcut[]; } -const getShortcutGroups = (t: (key: string) => string): ShortcutGroup[] => [ - { - title: t("global.keyboardShortcuts.groups.videoPlayback"), - shortcuts: [ - { - key: "Space", - description: t("global.keyboardShortcuts.shortcuts.playPause"), - }, - { - key: "K", - description: t("global.keyboardShortcuts.shortcuts.playPauseAlt"), - }, - { - key: "β†’", - description: t("global.keyboardShortcuts.shortcuts.skipForward5"), - }, - { - key: "←", - description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), - }, - { - key: "J", - description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), - }, - { - key: "L", - description: t("global.keyboardShortcuts.shortcuts.skipForward10"), - }, - { - key: ".", - description: t("global.keyboardShortcuts.shortcuts.skipForward1"), - condition: t("global.keyboardShortcuts.conditions.whenPaused"), - }, - { - key: ",", - description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), - condition: t("global.keyboardShortcuts.conditions.whenPaused"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.jumpToPosition"), - shortcuts: [ - { - key: "0", - description: t("global.keyboardShortcuts.shortcuts.jumpTo0"), - }, - { - key: "9", - description: t("global.keyboardShortcuts.shortcuts.jumpTo9"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.audioVideo"), - shortcuts: [ - { - key: "↑", - description: t("global.keyboardShortcuts.shortcuts.increaseVolume"), - }, - { - key: "↓", - description: t("global.keyboardShortcuts.shortcuts.decreaseVolume"), - }, - { key: "M", description: t("global.keyboardShortcuts.shortcuts.mute") }, - { - key: ">/", - description: t("global.keyboardShortcuts.shortcuts.changeSpeed"), - condition: t("global.keyboardShortcuts.conditions.notInWatchParty"), - }, - { - key: "F", - description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), - shortcuts: [ - { - key: "C", - description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), - }, - { - key: "[", - description: t( - "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", - ), - }, - { - key: "]", - description: t("global.keyboardShortcuts.shortcuts.syncSubtitlesLater"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.interface"), - shortcuts: [ - { - key: "R", - description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), - }, - { - key: "Escape", - description: t("global.keyboardShortcuts.shortcuts.closeOverlay"), - }, - { - key: "Shift", - description: t("global.keyboardShortcuts.shortcuts.copyLinkWithTime"), - }, - { - key: "Shift", - description: t("global.keyboardShortcuts.shortcuts.widescreenMode"), - }, - ], - }, -]; +function KeyBadge({ + config, + children, +}: { + config?: KeyboardShortcutConfig; + children: ReactNode; +}) { + const modifier = config?.modifier; -function KeyBadge({ children }: { children: ReactNode }) { return ( - + {children} + {modifier && ( + + {getModifierSymbol(modifier)} + + )} ); } +const getShortcutGroups = ( + t: (key: string) => string, + shortcuts: Record, +): ShortcutGroup[] => { + // Merge user shortcuts with defaults (user shortcuts take precedence) + const mergedShortcuts = { + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...shortcuts, + }; + + const getDisplayKey = (shortcutId: ShortcutId): string => { + const config = mergedShortcuts[shortcutId]; + if (!config?.key) return ""; + return getKeyDisplayName(config.key); + }; + + const getConfig = ( + shortcutId: ShortcutId, + ): KeyboardShortcutConfig | undefined => { + return mergedShortcuts[shortcutId]; + }; + + return [ + { + title: t("global.keyboardShortcuts.groups.videoPlayback"), + shortcuts: [ + { + key: "Space", + description: t("global.keyboardShortcuts.shortcuts.playPause"), + }, + { + key: "K", + description: t("global.keyboardShortcuts.shortcuts.playPauseAlt"), + }, + { + key: getDisplayKey(ShortcutId.SKIP_FORWARD_5) || "β†’", + description: t("global.keyboardShortcuts.shortcuts.skipForward5"), + config: getConfig(ShortcutId.SKIP_FORWARD_5), + }, + { + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_5) || "←", + description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), + config: getConfig(ShortcutId.SKIP_BACKWARD_5), + }, + { + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_10) || "J", + description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), + config: getConfig(ShortcutId.SKIP_BACKWARD_10), + }, + { + key: getDisplayKey(ShortcutId.SKIP_FORWARD_10) || "L", + description: t("global.keyboardShortcuts.shortcuts.skipForward10"), + config: getConfig(ShortcutId.SKIP_FORWARD_10), + }, + { + key: getDisplayKey(ShortcutId.SKIP_FORWARD_1) || ".", + description: t("global.keyboardShortcuts.shortcuts.skipForward1"), + config: getConfig(ShortcutId.SKIP_FORWARD_1), + }, + { + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_1) || ",", + description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), + config: getConfig(ShortcutId.SKIP_BACKWARD_1), + }, + { + key: getDisplayKey(ShortcutId.NEXT_EPISODE) || "P", + description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + config: getConfig(ShortcutId.NEXT_EPISODE), + }, + { + key: getDisplayKey(ShortcutId.PREVIOUS_EPISODE) || "O", + description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + config: getConfig(ShortcutId.PREVIOUS_EPISODE), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.jumpToPosition"), + shortcuts: [ + { + key: getDisplayKey(ShortcutId.JUMP_TO_0) || "0", + description: t("global.keyboardShortcuts.shortcuts.jumpTo0"), + config: getConfig(ShortcutId.JUMP_TO_0), + }, + { + key: getDisplayKey(ShortcutId.JUMP_TO_9) || "9", + description: t("global.keyboardShortcuts.shortcuts.jumpTo9"), + config: getConfig(ShortcutId.JUMP_TO_9), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.audioVideo"), + shortcuts: [ + { + key: "↑", + description: t("global.keyboardShortcuts.shortcuts.increaseVolume"), + }, + { + key: "↓", + description: t("global.keyboardShortcuts.shortcuts.decreaseVolume"), + }, + { + key: getDisplayKey(ShortcutId.MUTE) || "M", + description: t("global.keyboardShortcuts.shortcuts.mute"), + config: getConfig(ShortcutId.MUTE), + }, + { + key: getDisplayKey(ShortcutId.TOGGLE_FULLSCREEN) || "F", + description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), + config: getConfig(ShortcutId.TOGGLE_FULLSCREEN), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), + shortcuts: [ + { + key: getDisplayKey(ShortcutId.TOGGLE_CAPTIONS) || "C", + description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), + config: getConfig(ShortcutId.TOGGLE_CAPTIONS), + }, + { + key: getDisplayKey(ShortcutId.RANDOM_CAPTION) || "Shift+C", + description: t("global.keyboardShortcuts.shortcuts.randomCaption"), + config: getConfig(ShortcutId.RANDOM_CAPTION), + }, + { + key: getDisplayKey(ShortcutId.SYNC_SUBTITLES_EARLIER) || "[", + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", + ), + config: getConfig(ShortcutId.SYNC_SUBTITLES_EARLIER), + }, + { + key: getDisplayKey(ShortcutId.SYNC_SUBTITLES_LATER) || "]", + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesLater", + ), + config: getConfig(ShortcutId.SYNC_SUBTITLES_LATER), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.interface"), + shortcuts: [ + { + key: getDisplayKey(ShortcutId.BARREL_ROLL) || "R", + description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), + config: getConfig(ShortcutId.BARREL_ROLL), + }, + { + key: "Escape", + description: t("global.keyboardShortcuts.shortcuts.closeOverlay"), + }, + ], + }, + ]; +}; + interface KeyboardCommandsModalProps { id: string; } export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) { const { t } = useTranslation(); - const shortcutGroups = getShortcutGroups(t); + const navigate = useNavigate(); + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); + const shortcutGroups = getShortcutGroups(t, keyboardShortcuts); return ( @@ -164,12 +233,23 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) { return ( <> {before} - ` + ` {after} ); })()}

+

+ +

@@ -179,24 +259,28 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) { {group.title}
- {group.shortcuts.map((shortcut) => ( -
-
- {shortcut.key} - - {shortcut.description} - + {group.shortcuts + .filter((shortcut) => shortcut.key) // Only show shortcuts that have a key configured + .map((shortcut) => ( +
+
+ + {shortcut.key} + + + {shortcut.description} + +
+ {shortcut.condition && ( + + {shortcut.condition} + + )}
- {shortcut.condition && ( - - {shortcut.condition} - - )} -
- ))} + ))}
))} diff --git a/src/components/overlays/Modal.tsx b/src/components/overlays/Modal.tsx index 9a6c2e24..fea9fe41 100644 --- a/src/components/overlays/Modal.tsx +++ b/src/components/overlays/Modal.tsx @@ -21,9 +21,12 @@ export function useModal(id: string) { }; } -export function ModalCard(props: { children?: ReactNode }) { +export function ModalCard(props: { + children?: ReactNode; + className?: ReactNode; +}) { return ( -
+
{props.children}
diff --git a/src/components/overlays/SupportInfoModal.tsx b/src/components/overlays/SupportInfoModal.tsx new file mode 100644 index 00000000..573af03c --- /dev/null +++ b/src/components/overlays/SupportInfoModal.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; + +import { FancyModal } from "./Modal"; +import { Button } from "../buttons/Button"; +import { MwLink } from "../text/Link"; + +export function SupportInfoModal({ id }: { id: string }) { + const { t } = useTranslation(); + + return ( + +
+

{t("home.support.explanation")}

+

+ {t("home.support.explanation2")}{" "} + + {t("home.support.discord")} + +

+ +
+ + + +
+ +
+ {t("home.support.thankYou")} +
+
+
+ ); +} diff --git a/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx b/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx index 709655af..52ff59c2 100644 --- a/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx +++ b/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx @@ -7,7 +7,7 @@ import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; import { Icon, Icons } from "@/components/Icon"; import { useLanguageStore } from "@/stores/language"; import { usePreferencesStore } from "@/stores/preferences"; -import { useProgressStore } from "@/stores/progress"; +import { getProgressPercentage, useProgressStore } from "@/stores/progress"; import { shouldShowProgress } from "@/stores/progress/utils"; import { scrapeIMDb } from "@/utils/imdbScraper"; import { getTmdbLanguageCode } from "@/utils/language"; @@ -38,10 +38,23 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) { const [logoHeight, setLogoHeight] = useState(0); const logoRef = useRef(null); const progress = useProgressStore((s) => s.items); + const updateItem = useProgressStore((s) => s.updateItem); const enableImageLogos = usePreferencesStore( (state) => state.enableImageLogos, ); + // Check if movie is watched (>90% progress) + const isMovieWatched = useMemo(() => { + if (data.type !== "movie" || !data.id) return false; + const movieProgress = progress[data.id.toString()]?.progress; + if (!movieProgress) return false; + const percentage = getProgressPercentage( + movieProgress.watched, + movieProgress.duration, + ); + return percentage > 90; + }, [data.type, data.id, progress]); + const showProgress = useMemo(() => { if (!data.id) return null; const item = progress[data.id.toString()]; @@ -189,6 +202,30 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) { } }; + const toggleMovieWatchStatus = () => { + if (data.type !== "movie" || !data.id) return; + + // Get the poster URL from the data + const posterUrl = data.posterUrl; + + // Update progress - if watched, set to 0%, otherwise set to 100% + updateItem({ + meta: { + tmdbId: data.id.toString(), + title: data.title || "", + type: "movie", + releaseYear: data.releaseDate + ? new Date(data.releaseDate).getFullYear() + : new Date().getFullYear(), + poster: posterUrl, + }, + progress: { + watched: isMovieWatched ? 0 : 60, // 60 seconds for "watched" + duration: 60, + }, + }); + }; + return (
{/* Share notification popup */} @@ -290,20 +327,40 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) { {/* Genres */} {data.genres && data.genres.length > 0 && ( -
- {data.genres.map((genre, index) => ( - +
+ {data.genres.map((genre, index) => ( + + {genre.name} + + ))} +
+ {/* Movie Watch Toggle Button - Only show for movies and not in minimal modal */} + {data.type === "movie" && !minimal && ( + + )}
)} diff --git a/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx b/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx index ac91ea3e..6d090f4e 100644 --- a/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx +++ b/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx @@ -23,7 +23,13 @@ import { DetailsSkeleton } from "./DetailsSkeleton"; import { OverlayPortal } from "../../../OverlayDisplay"; import { DetailsModalProps } from "../../types"; -export function DetailsModal({ id, data: _data, minimal }: DetailsModalProps) { +export function DetailsModal({ + id, + data: _data, + minimal: _minimal, +}: DetailsModalProps) { + // Player details modal should always be minimal (hide episode carousel and movie watch button) + const minimal = _minimal || id === "player-details"; const { hideModal, isModalVisible, modalStack, getModalData } = useOverlayStack(); const [detailsData, setDetailsData] = useState(null); diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index a7bad320..355510c9 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -18,6 +18,7 @@ import { AudioView } from "./settings/AudioView"; import { CaptionSettingsView } from "./settings/CaptionSettingsView"; import { CaptionsView } from "./settings/CaptionsView"; import { DownloadRoutes } from "./settings/Downloads"; +import { LanguageSubtitlesView } from "./settings/LanguageSubtitlesView"; import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; import { QualityView } from "./settings/QualityView"; import { SettingsMenu } from "./settings/SettingsMenu"; @@ -26,15 +27,18 @@ import { WatchPartyView } from "./settings/WatchPartyView"; function SettingsOverlay({ id }: { id: string }) { const [chosenSourceId, setChosenSourceId] = useState(null); + const [chosenLanguage, setChosenLanguage] = useState(null); const router = useOverlayRouter(id); - // reset source id when going to home or closing overlay + // reset source id and language when going to home or closing overlay useEffect(() => { if (!router.isRouterActive) { setChosenSourceId(null); + setChosenLanguage(null); } if (router.route === "/") { setChosenSourceId(null); + setChosenLanguage(null); } }, [router.isRouterActive, router.route]); @@ -56,13 +60,33 @@ function SettingsOverlay({ id }: { id: string }) { - + {/* This is used by the captions shortcut in bottomControls of player */} - + + + + + + {chosenLanguage && ( + + )} @@ -106,6 +130,18 @@ function SettingsOverlay({ id }: { id: string }) { + + + {chosenLanguage && ( + + )} + + diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 7a6f85fe..6681dbe4 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -1,8 +1,8 @@ +import { labelToLanguageCode } from "@p-stream/providers"; import classNames from "classnames"; import Fuse from "fuse.js"; import { type DragEvent, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useAsyncFn } from "react-use"; import { convert } from "subsrt-ts"; import { subtitleTypeList } from "@/backend/helpers/subs"; @@ -11,13 +11,13 @@ import { FlagIcon } from "@/components/FlagIcon"; import { Icon, Icons } from "@/components/Icon"; import { useCaptions } from "@/components/player/hooks/useCaptions"; import { Menu } from "@/components/player/internals/ContextMenu"; -import { Input } from "@/components/player/internals/ContextMenu/Input"; import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; import { captionIsVisible, parseSubtitles, } from "@/components/player/utils/captions"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { useLanguageStore } from "@/stores/language"; import { CaptionListItem } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { useSubtitleStore } from "@/stores/subtitles"; @@ -120,7 +120,15 @@ export function CaptionOption(props: { ) : null} - {props.children} + + {props.children} + {props.subtitleType && ( {props.subtitleType.toUpperCase()} @@ -298,32 +306,44 @@ export function PasteCaptionOption(props: { selected?: boolean }) { ); } +export interface CaptionsViewProps { + id: string; + backLink?: boolean; + onChooseLanguage?: (language: string) => void; +} + export function CaptionsView({ id, backLink, -}: { - id: string; - backLink?: true; -}) { + onChooseLanguage, +}: CaptionsViewProps) { const { t } = useTranslation(); const router = useOverlayRouter(id); const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); - const { disable, selectCaptionById } = useCaptions(); + const { disable, selectRandomCaptionFromLastUsedLanguage } = useCaptions(); + const [isRandomSelecting, setIsRandomSelecting] = useState(false); const [dragging, setDragging] = useState(false); + + const handleRandomSelect = async () => { + if (isRandomSelecting) return; // Prevent multiple simultaneous calls + setIsRandomSelecting(true); + try { + await selectRandomCaptionFromLastUsedLanguage(); + } finally { + setIsRandomSelecting(false); + } + }; const setCaption = usePlayerStore((s) => s.setCaption); - const [searchQuery, setSearchQuery] = useState(""); - const [currentlyDownloading, setCurrentlyDownloading] = useState< - string | null - >(null); const videoTime = usePlayerStore((s) => s.progress.time); const srtData = usePlayerStore((s) => s.caption.selected?.srtData); - const language = usePlayerStore((s) => s.caption.selected?.language); + const selectedLanguage = usePlayerStore((s) => s.caption.selected?.language); const captionList = usePlayerStore((s) => s.captionList); const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); const isLoadingExternalSubtitles = usePlayerStore( (s) => s.isLoadingExternalSubtitles, ); const delay = useSubtitleStore((s) => s.delay); + const appLanguage = useLanguageStore((s) => s.language); // Get combined caption list const captions = useMemo( @@ -342,28 +362,60 @@ export function CaptionsView({ [captions], ); - // Filter lists based on search query - const sourceList = useSubtitleList(sourceCaptions, searchQuery); - const externalList = useSubtitleList(externalCaptions, searchQuery); + // Group captions by language + const groupedCaptions = useMemo(() => { + const allCaptions = [...sourceCaptions, ...externalCaptions]; + const groups: Record = {}; + + allCaptions.forEach((caption) => { + // Use display name if available, otherwise fall back to language code + const lang = + labelToLanguageCode(caption.display || "") || + caption.language || + "unknown"; + if (!groups[lang]) { + groups[lang] = []; + } + groups[lang].push(caption); + }); + + // Sort languages + const sortedGroups: Array<{ + language: string; + captions: typeof allCaptions; + languageName: string; + }> = []; + Object.entries(groups).forEach(([lang, captionsForLang]) => { + const languageName = + getPrettyLanguageNameFromLocale(lang) || + t("player.menus.subtitles.unknownLanguage"); + sortedGroups.push({ + language: lang, + captions: captionsForLang, + languageName, + }); + }); + + // Sort with app language first, then alphabetically + return sortedGroups.sort((a, b) => { + // App language always comes first + if (a.language === appLanguage) return -1; + if (b.language === appLanguage) return 1; + + // Then sort alphabetically + return a.languageName.localeCompare(b.languageName); + }); + }, [sourceCaptions, externalCaptions, t, appLanguage]); // Get current subtitle text preview const currentSubtitleText = useMemo(() => { if (!srtData || !selectedCaptionId) return null; - const parsedCaptions = parseSubtitles(srtData, language); + const parsedCaptions = parseSubtitles(srtData, selectedLanguage); const visibleCaption = parsedCaptions.find(({ start, end }) => captionIsVisible(start, end, delay, videoTime), ); return visibleCaption?.content; - }, [srtData, language, delay, videoTime, selectedCaptionId]); - - // Download handler - const [downloadReq, startDownload] = useAsyncFn( - async (captionId: string) => { - setCurrentlyDownloading(captionId); - return selectCaptionById(captionId); - }, - [selectCaptionById, setCurrentlyDownloading], - ); + }, [srtData, selectedLanguage, delay, videoTime, selectedCaptionId]); function onDrop(event: DragEvent) { const files = event.dataTransfer.files; @@ -391,59 +443,6 @@ export function CaptionsView({ reader.readAsText(firstFile); } - // Render subtitle option - const renderSubtitleOption = ( - v: CaptionListItem & { languageName: string }, - ) => { - const handleDoubleClick = async () => { - const copyData = { - id: v.id, - url: v.url, - language: v.language, - type: v.type, - hasCorsRestrictions: v.needsProxy, - opensubtitles: v.opensubtitles, - display: v.display, - media: v.media, - isHearingImpaired: v.isHearingImpaired, - source: v.source, - encoding: v.encoding, - delay, - }; - - try { - await navigator.clipboard.writeText(JSON.stringify(copyData)); - // Could add a toast notification here if needed - } catch (err) { - console.error("Failed to copy subtitle data:", err); - } - }; - - return ( - startDownload(v.id)} - onDoubleClick={handleDoubleClick} - flag - subtitleUrl={v.url} - subtitleType={v.type} - subtitleSource={v.source} - subtitleEncoding={v.encoding} - isHearingImpaired={v.isHearingImpaired} - > - {v.languageName} - - ); - }; - return ( <>
@@ -534,6 +533,24 @@ export function CaptionsView({ {t("player.menus.subtitles.offChoice")} + {/* Automatically select subtitles option */} + {captions.length > 0 && ( + handleRandomSelect()} + selected={!!selectedCaptionId} + loading={isRandomSelecting} + > +
+ {t("player.menus.subtitles.autoSelectChoice")} + {selectedCaptionId && ( + + {t("player.menus.subtitles.autoSelectDifferentChoice")} + + )} +
+
+ )} + {/* Custom upload option */} @@ -552,16 +569,11 @@ export function CaptionsView({
- {/* Search input */} - {(sourceCaptions.length || externalCaptions.length) > 0 && ( - - )} - {/* No subtitles available message */} {!isLoadingExternalSubtitles && sourceCaptions.length === 0 && externalCaptions.length === 0 && ( -
+
{t("player.menus.subtitles.empty")}
@@ -569,7 +581,7 @@ export function CaptionsView({ )} {/* Loading external subtitles */} - {isLoadingExternalSubtitles && externalCaptions.length === 0 && ( + {isLoadingExternalSubtitles && (
{t("player.menus.subtitles.loadingExternal")} @@ -577,45 +589,30 @@ export function CaptionsView({
)} - {/* Source Subtitles Section */} - {sourceCaptions.length > 0 && ( - <> -
- {t("player.menus.subtitles.SourceChoice")} -
- {sourceList.length > 0 ? ( - sourceList.map(renderSubtitleOption) - ) : ( -
- {t("player.menus.subtitles.notFound")} -
- )} - - )} - - {/* External Subtitles Section */} - {externalCaptions.length > 0 && ( - <> -
- {t("player.menus.subtitles.OpenSubtitlesChoice")} -
- {externalList.length > 0 ? ( - externalList.map(renderSubtitleOption) - ) : ( -
- {t("player.menus.subtitles.notFound")} -
- )} - - )} - - {/* Loading indicator for external subtitles while source exists */} - {isLoadingExternalSubtitles && sourceCaptions.length > 0 && ( -
- {t("player.menus.subtitles.loadingExternal") || - "Loading external subtitles..."} -
- )} + {/* Language selection */} + {groupedCaptions.length > 0 && + groupedCaptions.map( + ({ language, languageName, captions: captionsForLang }) => ( + { + onChooseLanguage?.(language); + router.navigate( + backLink + ? "/captions/languages" + : "/captionsOverlay/languagesOverlay", + ); + }} + > + + + {languageName} + + + ), + )} diff --git a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx new file mode 100644 index 00000000..ccdbab45 --- /dev/null +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAsyncFn } from "react-use"; + +import { FlagIcon } from "@/components/FlagIcon"; +import { Icon, Icons } from "@/components/Icon"; +import { useCaptions } from "@/components/player/hooks/useCaptions"; +import { Menu } from "@/components/player/internals/ContextMenu"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { CaptionListItem } from "@/stores/player/slices/source"; +import { usePlayerStore } from "@/stores/player/store"; +import { getPrettyLanguageNameFromLocale } from "@/utils/language"; + +import { CaptionOption } from "./CaptionsView"; + +export interface LanguageSubtitlesViewProps { + id: string; + language: string; + overlayBackLink?: boolean; +} + +export function LanguageSubtitlesView({ + id, + language, + overlayBackLink, +}: LanguageSubtitlesViewProps) { + const { t } = useTranslation(); + const router = useOverlayRouter(id); + const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); + const { selectCaptionById } = useCaptions(); + const [currentlyDownloading, setCurrentlyDownloading] = useState< + string | null + >(null); + const [scrollTrigger, setScrollTrigger] = useState(0); + const captionList = usePlayerStore((s) => s.captionList); + + // Trigger scroll when selected caption changes + useEffect(() => { + if (selectedCaptionId) { + setScrollTrigger((prev) => prev + 1); + } + }, [selectedCaptionId]); + + // Manual scroll function with smooth behavior + const scrollToActiveCaption = () => { + const active = document.querySelector("[data-active-link]"); + if (!active) return; + + active.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }; + + const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); + const isLoadingExternalSubtitles = usePlayerStore( + (s) => s.isLoadingExternalSubtitles, + ); + + // Get combined caption list + const captions = useMemo( + () => + captionList.length !== 0 ? captionList : (getHlsCaptionList?.() ?? []), + [captionList, getHlsCaptionList], + ); + + // Filter captions for this specific language + const languageCaptions = useMemo( + () => captions.filter((caption) => caption.language === language), + [captions, language], + ); + + // Download handler + const [downloadReq, startDownload] = useAsyncFn( + async (captionId: string) => { + setCurrentlyDownloading(captionId); + return selectCaptionById(captionId); + }, + [selectCaptionById, setCurrentlyDownloading], + ); + + // Random subtitle selection + const handleRandomSelect = async () => { + if (languageCaptions.length === 0) return; + + const randomIndex = Math.floor(Math.random() * languageCaptions.length); + const randomCaption = languageCaptions[randomIndex]; + + await startDownload(randomCaption.id); + + // Scroll to the newly selected caption after a brief delay to ensure DOM updates + setTimeout(() => scrollToActiveCaption(), 100); + }; + + // Render subtitle option + const renderSubtitleOption = (v: CaptionListItem) => { + const handleDoubleClick = async () => { + const copyData = { + id: v.id, + url: v.url, + language: v.language, + type: v.type, + hasCorsRestrictions: v.needsProxy, + opensubtitles: v.opensubtitles, + display: v.display, + media: v.media, + isHearingImpaired: v.isHearingImpaired, + source: v.source, + encoding: v.encoding, + delay: 0, + }; + + try { + await navigator.clipboard.writeText(JSON.stringify(copyData)); + // Could add a toast notification here if needed + } catch (err) { + console.error("Failed to copy subtitle data:", err); + } + }; + + return ( + startDownload(v.id)} + onDoubleClick={handleDoubleClick} + flag + subtitleUrl={v.url} + subtitleType={v.type} + subtitleSource={v.source} + subtitleEncoding={v.encoding} + isHearingImpaired={v.isHearingImpaired} + > + {v.display || v.id} + + ); + }; + + const languageName = + getPrettyLanguageNameFromLocale(language) || + t("player.menus.subtitles.unknownLanguage"); + + return ( + <> + + router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions") + } + rightSide={ + languageCaptions.length > 0 && ( + + ) + } + > + + + {languageName} + + + + 0} + > + {languageCaptions.length > 0 ? ( + languageCaptions.map(renderSubtitleOption) + ) : ( +
+ {t("player.menus.subtitles.notFound")} +
+ )} + + {/* Loading indicator */} + {isLoadingExternalSubtitles && languageCaptions.length === 0 && ( +
+ {t("player.menus.subtitles.loadingExternal") || + "Loading external subtitles..."} +
+ )} +
+ + ); +} diff --git a/src/components/player/atoms/settings/SourceSelectingView.tsx b/src/components/player/atoms/settings/SourceSelectingView.tsx index e20bc6b2..5e9938fb 100644 --- a/src/components/player/atoms/settings/SourceSelectingView.tsx +++ b/src/components/player/atoms/settings/SourceSelectingView.tsx @@ -39,19 +39,33 @@ export function EmbedOption(props: { return sourceMeta?.name ?? unknownEmbedName; }, [props.embedId, unknownEmbedName]); - const { run, errored, loading } = useEmbedScraping( + const { run, errored, loading, notFound } = useEmbedScraping( props.routerId, props.sourceId, props.url, props.embedId, ); + let rightSide; + if (loading) { + rightSide = undefined; // Let SelectableLink handle loading + } else if (notFound) { + rightSide = ( +
+
+
+
+
+ ); + } + return ( {embedName} @@ -150,14 +164,12 @@ export function SourceSelectionView({ const enableLastSuccessfulSource = usePreferencesStore( (s) => s.enableLastSuccessfulSource, ); - const disabledSources = usePreferencesStore((s) => s.disabledSources); const sources = useMemo(() => { if (!metaType) return []; const allSources = getCachedMetadata() .filter((v) => v.type === "source") - .filter((v) => v.mediaTypes?.includes(metaType)) - .filter((v) => !disabledSources.includes(v.id)); + .filter((v) => v.mediaTypes?.includes(metaType)); if (!enableSourceOrder || preferredSourceOrder.length === 0) { // Even without custom source order, prioritize last successful source if enabled @@ -205,7 +217,6 @@ export function SourceSelectionView({ metaType, preferredSourceOrder, enableSourceOrder, - disabledSources, lastSuccessfulSource, enableLastSuccessfulSource, ]); diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 3ccd5756..79e3dd66 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -169,8 +169,12 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { hls.currentLevel = -1; hls.loadLevel = -1; } - const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); - emit("changedquality", quality); + // Only emit quality when we have a valid level index (>= 0) + // When automaticQuality is true, currentLevel is -1, so we wait for LEVEL_SWITCHED event + if (hls.currentLevel >= 0) { + const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); + emit("changedquality", quality); + } } function setupSource(vid: HTMLVideoElement, src: LoadableSource) { @@ -189,6 +193,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { autoStartLoad: true, maxBufferLength: 120, // 120 seconds maxMaxBufferLength: 240, + abrEwmaDefaultEstimate: 5 * 1000 * 1000, // 5 Mbps default bandwidth estimate for better ABR decisions fragLoadPolicy: { default: { maxLoadTimeMs: 30 * 1000, // allow it load extra long, fragments are slow if requested for the first time on an origin @@ -367,18 +372,46 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { videoElement.addEventListener("playing", () => emit("play", undefined)); videoElement.addEventListener("pause", () => emit("pause", undefined)); videoElement.addEventListener("canplay", () => { - emit("loading", false); + // Check if video has enough buffered data to play smoothly (at least 5 seconds ahead) + const hasEnoughBuffer = (() => { + if (!videoElement) return false; + const currentTime = videoElement.currentTime ?? 0; + const buffered = videoElement.buffered; + if (buffered.length === 0) return false; + + // Find the buffered range that contains current time + for (let i = 0; i < buffered.length; i += 1) { + if ( + currentTime >= buffered.start(i) && + currentTime <= buffered.end(i) + ) { + const bufferedAhead = buffered.end(i) - currentTime; + return bufferedAhead >= 5; // At least 5 seconds buffered ahead + } + } + return false; + })(); + + // Only set loading to false if we have enough buffer or if we're not at the start + if (hasEnoughBuffer || (videoElement?.currentTime ?? 0) > 0) { + emit("loading", false); + } + // Attempt autoplay if this was an autoplay transition (startAt = 0) if (shouldAutoplayAfterLoad && startAt === 0 && videoElement) { shouldAutoplayAfterLoad = false; // Reset the flag // Try to play - this will work on most platforms, but iOS may block it const playPromise = videoElement.play(); if (playPromise !== undefined) { - playPromise.catch(() => { - // Play was blocked (likely iOS), emit that we're not playing - // The AutoPlayStart component will show a play button - emit("pause", undefined); - }); + playPromise + .then(() => { + // Autoplay succeeded + }) + .catch((_error) => { + // Play was blocked (likely iOS), emit that we're not playing + // The AutoPlayStart component will show a play button + emit("pause", undefined); + }); } } }); @@ -423,11 +456,38 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { } }); videoElement.addEventListener("progress", () => { - if (videoElement) - emit( - "buffered", - handleBuffered(videoElement.currentTime, videoElement.buffered), + if (videoElement) { + const bufferedTime = handleBuffered( + videoElement.currentTime, + videoElement.buffered, ); + emit("buffered", bufferedTime); + + // Check if we now have enough buffer to stop loading + const hasEnoughBuffer = (() => { + const buffered = videoElement.buffered; + if (buffered.length === 0) return false; + + const currentTime = videoElement.currentTime ?? 0; + // Find the buffered range that contains current time + for (let i = 0; i < buffered.length; i += 1) { + if ( + currentTime >= buffered.start(i) && + currentTime <= buffered.end(i) + ) { + const bufferedAhead = buffered.end(i) - currentTime; + return bufferedAhead >= 5; // At least 5 seconds buffered ahead + } + } + return false; + })(); + + // If we're still loading but now have enough buffer, stop loading + // This handles cases where canplay fired with insufficient buffer + if (hasEnoughBuffer && videoElement.readyState >= 3) { + emit("loading", false); + } + } }); videoElement.addEventListener("webkitendfullscreen", () => { isFullscreen = false; diff --git a/src/components/player/hooks/useCaptions.ts b/src/components/player/hooks/useCaptions.ts index e262d77d..1df573f9 100644 --- a/src/components/player/hooks/useCaptions.ts +++ b/src/components/player/hooks/useCaptions.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import subsrt from "subsrt-ts"; import { downloadCaption, downloadWebVTT } from "@/backend/helpers/subs"; @@ -148,6 +148,58 @@ export function useCaptions() { if (enabled) await selectLastUsedLanguage(); }, [selectLastUsedLanguage, enabled]); + const selectRandomCaptionFromLastUsedLanguage = useCallback(async () => { + const language = lastSelectedLanguage ?? "en"; + + // Filter captions by language + const languageCaptions = captions.filter( + (caption) => caption.language === language, + ); + + // If no captions exist for that language, return early + if (languageCaptions.length === 0) return; + + // Filter out the currently selected caption if possible + const availableCaptions = languageCaptions.filter( + (caption) => caption.id !== selectedCaption?.id, + ); + + // If we filtered out all captions (only one caption available), use all captions + const captionsToChooseFrom = + availableCaptions.length > 0 ? availableCaptions : languageCaptions; + + // Pick a random caption + const randomIndex = Math.floor(Math.random() * captionsToChooseFrom.length); + const randomCaption = captionsToChooseFrom[randomIndex]; + + // Select the random caption + await selectCaptionById(randomCaption.id); + }, [lastSelectedLanguage, captions, selectedCaption, selectCaptionById]); + + // Validate selected caption when caption list changes + useEffect(() => { + if (!selectedCaption) return; + + const isSelectedCaptionStillAvailable = captions.some( + (caption) => caption.id === selectedCaption.id, + ); + + if (!isSelectedCaptionStillAvailable) { + // Try to find a caption with the same language + const sameLanguageCaption = captions.find( + (caption) => caption.language === selectedCaption.language, + ); + + if (sameLanguageCaption) { + // Automatically select the first caption with the same language + selectCaptionById(sameLanguageCaption.id); + } else { + // No caption with the same language found, clear the selection + setCaption(null); + } + } + }, [captions, selectedCaption, setCaption, selectCaptionById]); + return { selectLanguage, disable, @@ -155,5 +207,6 @@ export function useCaptions() { toggleLastUsed, selectLastUsedLanguageIfEnabled, selectCaptionById, + selectRandomCaptionFromLastUsedLanguage, }; } diff --git a/src/components/player/hooks/useInitializePlayer.ts b/src/components/player/hooks/useInitializePlayer.ts index f8fe96a7..f867c803 100644 --- a/src/components/player/hooks/useInitializePlayer.ts +++ b/src/components/player/hooks/useInitializePlayer.ts @@ -26,12 +26,13 @@ export function useInitializeSource() { ); const { selectLastUsedLanguageIfEnabled } = useCaptions(); - const funRef = useRef(selectLastUsedLanguageIfEnabled); - useEffect(() => { - funRef.current = selectLastUsedLanguageIfEnabled; - }, [selectLastUsedLanguageIfEnabled]); + // Only select subtitles on initial load, not when source changes + const hasInitializedRef = useRef(false); useEffect(() => { - if (sourceIdentifier) funRef.current(); - }, [sourceIdentifier]); + if (sourceIdentifier && !hasInitializedRef.current) { + hasInitializedRef.current = true; + selectLastUsedLanguageIfEnabled(); + } + }, [sourceIdentifier, selectLastUsedLanguageIfEnabled]); } diff --git a/src/components/player/hooks/useSourceSelection.ts b/src/components/player/hooks/useSourceSelection.ts index 4fca0775..dde82db0 100644 --- a/src/components/player/hooks/useSourceSelection.ts +++ b/src/components/player/hooks/useSourceSelection.ts @@ -124,6 +124,7 @@ export function useEmbedScraping( run, loading: request.loading, errored: !!request.error, + notFound: request.error instanceof NotFoundError, }; } diff --git a/src/components/player/internals/ContextMenu/Links.tsx b/src/components/player/internals/ContextMenu/Links.tsx index 7d53e264..40d45ccc 100644 --- a/src/components/player/internals/ContextMenu/Links.tsx +++ b/src/components/player/internals/ContextMenu/Links.tsx @@ -138,13 +138,27 @@ export function Link(props: { export function ChevronLink(props: { rightText?: string; + selected?: boolean; onClick?: () => void; children?: ReactNode; active?: boolean; box?: boolean; disabled?: boolean; }) { - const rightContent = {props.rightText}; + const rightContent = ( + + {props.selected ? ( + + ) : ( + props.rightText + )} + + + ); + return ( - ); + let rightContent = props.rightSide; // Use custom rightSide if provided + if (!rightContent) { + if (props.selected) { + rightContent = ( + + ); + } + if (props.error) + rightContent = ( + + + + ); + if (props.loading) rightContent = ; // should override selected and error } - if (props.error) - rightContent = ( - - - - ); - if (props.loading) rightContent = ; // should override selected and error return ( s.progress.duration); const { setVolume, toggleMute } = useVolume(); const isInWatchParty = useWatchPartyStore((s) => s.enabled); + const meta = usePlayerStore((s) => s.meta); + const { setDirectMeta } = usePlayerMeta(); + const setShouldStartFromBeginning = usePlayerStore( + (s) => s.setShouldStartFromBeginning, + ); + const updateItem = useProgressStore((s) => s.updateItem); + const sourceId = usePlayerStore((s) => s.sourceId); + const setLastSuccessfulSource = usePreferencesStore( + (s) => s.setLastSuccessfulSource, + ); - const { toggleLastUsed } = useCaptions(); + const { toggleLastUsed, selectRandomCaptionFromLastUsedLanguage } = + useCaptions(); const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume); const setDelay = useSubtitleStore((s) => s.setDelay); const delay = useSubtitleStore((s) => s.delay); @@ -29,6 +49,7 @@ export function KeyboardEvents() { (s) => s.setShowDelayIndicator, ); const enableHoldToBoost = usePreferencesStore((s) => s.enableHoldToBoost); + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); const [isRolling, setIsRolling] = useState(false); const volumeDebounce = useRef | undefined>(); @@ -47,12 +68,209 @@ export function KeyboardEvents() { const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); + // Episode navigation functions + const navigateToNextEpisode = useCallback(async () => { + if (!meta || meta.type !== "show" || !meta.episode) return; + + // Check if we're at the last episode of the current season + const isLastEpisode = + meta.episode.number === meta.episodes?.[meta.episodes.length - 1]?.number; + + if (!isLastEpisode) { + // Navigate to next episode in current season + const nextEp = meta.episodes?.find( + (v) => v.number === meta.episode!.number + 1, + ); + if (nextEp) { + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + const metaCopy = { ...meta }; + metaCopy.episode = nextEp; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } else { + // Navigate to first episode of next season + if (!meta.tmdbId) return; + + try { + const data = await getMetaFromId(MWMediaType.SERIES, meta.tmdbId); + if (data?.meta.type !== MWMediaType.SERIES) return; + + const nextSeason = data.meta.seasons?.find( + (season) => season.number === (meta.season?.number ?? 0) + 1, + ); + + if (nextSeason) { + const seasonData = await getMetaFromId( + MWMediaType.SERIES, + meta.tmdbId, + nextSeason.id, + ); + + if (seasonData?.meta.type === MWMediaType.SERIES) { + const nextSeasonEpisodes = seasonData.meta.seasonData.episodes + .filter((episode) => { + // Simple aired check - episodes without air_date are considered aired + return ( + !episode.air_date || new Date(episode.air_date) <= new Date() + ); + }) + .map((episode) => ({ + number: episode.number, + title: episode.title, + tmdbId: episode.id, + air_date: episode.air_date, + })); + + if (nextSeasonEpisodes.length > 0) { + const nextEp = nextSeasonEpisodes[0]; + + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + + const metaCopy = { ...meta }; + metaCopy.episode = nextEp; + metaCopy.season = { + number: nextSeason.number, + title: nextSeason.title, + tmdbId: nextSeason.id, + }; + metaCopy.episodes = nextSeasonEpisodes; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } + } + } catch (error) { + console.error("Failed to load next season:", error); + } + } + }, [ + meta, + setDirectMeta, + setShouldStartFromBeginning, + updateItem, + sourceId, + setLastSuccessfulSource, + ]); + + const navigateToPreviousEpisode = useCallback(async () => { + if (!meta || meta.type !== "show" || !meta.episode) return; + + // Check if we're at the first episode of the current season + const isFirstEpisode = meta.episode.number === meta.episodes?.[0]?.number; + + if (!isFirstEpisode) { + // Navigate to previous episode in current season + const prevEp = meta.episodes?.find( + (v) => v.number === meta.episode!.number - 1, + ); + if (prevEp) { + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + const metaCopy = { ...meta }; + metaCopy.episode = prevEp; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } else { + // Navigate to last episode of previous season + if (!meta.tmdbId) return; + + try { + const data = await getMetaFromId(MWMediaType.SERIES, meta.tmdbId); + if (data?.meta.type !== MWMediaType.SERIES) return; + + const prevSeason = data.meta.seasons?.find( + (season) => season.number === (meta.season?.number ?? 0) - 1, + ); + + if (prevSeason) { + const seasonData = await getMetaFromId( + MWMediaType.SERIES, + meta.tmdbId, + prevSeason.id, + ); + + if (seasonData?.meta.type === MWMediaType.SERIES) { + const prevSeasonEpisodes = seasonData.meta.seasonData.episodes + .filter((episode) => { + // Simple aired check - episodes without air_date are considered aired + return ( + !episode.air_date || new Date(episode.air_date) <= new Date() + ); + }) + .map((episode) => ({ + number: episode.number, + title: episode.title, + tmdbId: episode.id, + air_date: episode.air_date, + })); + + if (prevSeasonEpisodes.length > 0) { + const prevEp = prevSeasonEpisodes[prevSeasonEpisodes.length - 1]; + + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + + const metaCopy = { ...meta }; + metaCopy.episode = prevEp; + metaCopy.season = { + number: prevSeason.number, + title: prevSeason.title, + tmdbId: prevSeason.id, + }; + metaCopy.episodes = prevSeasonEpisodes; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } + } + } catch (error) { + console.error("Failed to load previous season:", error); + } + } + }, [ + meta, + setDirectMeta, + setShouldStartFromBeginning, + updateItem, + sourceId, + setLastSuccessfulSource, + ]); + const dataRef = useRef({ setShowVolume, setVolume, toggleMute, setIsRolling, toggleLastUsed, + selectRandomCaptionFromLastUsedLanguage, display, mediaPlaying, mediaProgress, @@ -74,6 +292,9 @@ export function KeyboardEvents() { boostTimeoutRef, isPendingBoostRef, enableHoldToBoost, + navigateToNextEpisode, + navigateToPreviousEpisode, + keyboardShortcuts, }); useEffect(() => { @@ -83,6 +304,7 @@ export function KeyboardEvents() { toggleMute, setIsRolling, toggleLastUsed, + selectRandomCaptionFromLastUsedLanguage, display, mediaPlaying, mediaProgress, @@ -104,6 +326,9 @@ export function KeyboardEvents() { boostTimeoutRef, isPendingBoostRef, enableHoldToBoost, + navigateToNextEpisode, + navigateToPreviousEpisode, + keyboardShortcuts, }; }, [ setShowVolume, @@ -111,6 +336,7 @@ export function KeyboardEvents() { toggleMute, setIsRolling, toggleLastUsed, + selectRandomCaptionFromLastUsedLanguage, display, mediaPlaying, mediaProgress, @@ -127,6 +353,9 @@ export function KeyboardEvents() { setSpeedBoosted, setShowSpeedIndicator, enableHoldToBoost, + navigateToNextEpisode, + navigateToPreviousEpisode, + keyboardShortcuts, ]); useEffect(() => { @@ -137,7 +366,7 @@ export function KeyboardEvents() { const k = evt.key; const keyL = evt.key.toLowerCase(); - // Volume + // Volume (locked shortcuts - ArrowUp/ArrowDown always work) if (["ArrowUp", "ArrowDown", "m", "M"].includes(k)) { dataRef.current.setShowVolume(true); dataRef.current.setCurrentOverlay("volume"); @@ -148,17 +377,22 @@ export function KeyboardEvents() { dataRef.current.setCurrentOverlay(null); }, 3e3); } - if (k === "ArrowUp") + if (k === LOCKED_SHORTCUTS.ARROW_UP) dataRef.current.setVolume( (dataRef.current.mediaPlaying?.volume || 0) + 0.15, ); - if (k === "ArrowDown") + if (k === LOCKED_SHORTCUTS.ARROW_DOWN) dataRef.current.setVolume( (dataRef.current.mediaPlaying?.volume || 0) - 0.15, ); - if (keyL === "m") dataRef.current.toggleMute(); + // Mute - check customizable shortcut + if ( + matchesShortcut(evt, dataRef.current.keyboardShortcuts[ShortcutId.MUTE]) + ) { + dataRef.current.toggleMute(); + } - // Video playback speed - disabled in watch party + // Video playback speed - disabled in watch party (hardcoded, not customizable) if ((k === ">" || k === "<") && !dataRef.current.isInWatchParty) { const options = [0.25, 0.5, 1, 1.5, 2]; let idx = options.indexOf(dataRef.current.mediaPlaying?.playbackRate); @@ -169,8 +403,9 @@ export function KeyboardEvents() { } // Handle spacebar press for play/pause and hold for 2x speed - disabled in watch party or when hold to boost is disabled + // Space is locked, always check it if ( - k === " " && + k === LOCKED_SHORTCUTS.PLAY_PAUSE_SPACE && !dataRef.current.isInWatchParty && dataRef.current.enableHoldToBoost ) { @@ -235,8 +470,9 @@ export function KeyboardEvents() { } // Handle spacebar press for simple play/pause when hold to boost is disabled or in watch party mode + // Space is locked, always check it if ( - k === " " && + k === LOCKED_SHORTCUTS.PLAY_PAUSE_SPACE && (!dataRef.current.enableHoldToBoost || dataRef.current.isInWatchParty) ) { // Skip if it's a repeated event @@ -260,38 +496,130 @@ export function KeyboardEvents() { dataRef.current.display?.[action](); } - // Video progress - if (k === "ArrowRight") - dataRef.current.display?.setTime(dataRef.current.time + 5); - if (k === "ArrowLeft") - dataRef.current.display?.setTime(dataRef.current.time - 5); - if (keyL === "j") - dataRef.current.display?.setTime(dataRef.current.time - 10); - if (keyL === "l") - dataRef.current.display?.setTime(dataRef.current.time + 10); - if (k === "." && dataRef.current.mediaPlaying?.isPaused) - dataRef.current.display?.setTime(dataRef.current.time + 1); - if (k === "," && dataRef.current.mediaPlaying?.isPaused) - dataRef.current.display?.setTime(dataRef.current.time - 1); + // Video progress - handle skip shortcuts + // Skip repeated key events to prevent multiple skips + if (evt.repeat) return; - // Skip to percentage with number keys (0-9) + // Arrow keys are locked (always 5 seconds) - handle first and return + if (k === LOCKED_SHORTCUTS.ARROW_RIGHT) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 5); + return; + } + if (k === LOCKED_SHORTCUTS.ARROW_LEFT) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 5); + return; + } + + // Skip forward/backward 5 seconds - customizable (skip if set to arrow keys) + const skipForward5 = + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_5]; + if ( + skipForward5?.key && + skipForward5.key !== LOCKED_SHORTCUTS.ARROW_RIGHT && + matchesShortcut(evt, skipForward5) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 5); + return; + } + const skipBackward5 = + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_5]; + if ( + skipBackward5?.key && + skipBackward5.key !== LOCKED_SHORTCUTS.ARROW_LEFT && + matchesShortcut(evt, skipBackward5) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 5); + return; + } + + // Skip forward/backward 10 seconds - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_10], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 10); + return; + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_10], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 10); + return; + } + + // Skip forward/backward 1 second - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_1], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 1); + return; + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_1], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 1); + return; + } + + // Skip to percentage with number keys (0-9) - locked, always use number keys + // Number keys are reserved for progress skipping, so handle them before customizable shortcuts if ( /^[0-9]$/.test(k) && dataRef.current.duration > 0 && !evt.ctrlKey && - !evt.metaKey + !evt.metaKey && + !evt.shiftKey && + !evt.altKey ) { - const percentage = parseInt(k, 10) * 10; // 0 = 0%, 1 = 10%, 2 = 20%, ..., 9 = 90% - const targetTime = (dataRef.current.duration * percentage) / 100; - dataRef.current.display?.setTime(targetTime); + evt.preventDefault(); + if (k === "0") { + dataRef.current.display?.setTime(0); + } else if (k === "9") { + const targetTime = (dataRef.current.duration * 90) / 100; + dataRef.current.display?.setTime(targetTime); + } else { + // 1-8 for 10%-80% + const percentage = parseInt(k, 10) * 10; + const targetTime = (dataRef.current.duration * percentage) / 100; + dataRef.current.display?.setTime(targetTime); + } + return; } - // Utils - if (keyL === "f") dataRef.current.display?.toggleFullscreen(); + // Utils - Fullscreen is customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.TOGGLE_FULLSCREEN], + ) + ) { + dataRef.current.display?.toggleFullscreen(); + } - // Remove duplicate spacebar handler that was conflicting - // with our improved implementation - if (keyL === "k" && !dataRef.current.isSpaceHeldRef.current) { + // K key for play/pause - locked shortcut + if ( + keyL === LOCKED_SHORTCUTS.PLAY_PAUSE_K.toLowerCase() && + !dataRef.current.isSpaceHeldRef.current + ) { if ( evt.target && (evt.target as HTMLInputElement).nodeName === "BUTTON" @@ -302,13 +630,55 @@ export function KeyboardEvents() { const action = dataRef.current.mediaPlaying.isPaused ? "play" : "pause"; dataRef.current.display?.[action](); } - if (k === "Escape") dataRef.current.router.close(); + // Escape is locked + if (k === LOCKED_SHORTCUTS.ESCAPE) dataRef.current.router.close(); - // captions - if (keyL === "c") dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors + // Episode navigation (shows only) - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.NEXT_EPISODE], + ) + ) { + dataRef.current.navigateToNextEpisode(); + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.PREVIOUS_EPISODE], + ) + ) { + dataRef.current.navigateToPreviousEpisode(); + } - // Do a barrell roll! - if (keyL === "r") { + // Captions - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.TOGGLE_CAPTIONS], + ) + ) { + dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors + } + // Random caption selection - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.RANDOM_CAPTION], + ) + ) { + dataRef.current + .selectRandomCaptionFromLastUsedLanguage() + .catch(() => {}); // ignore errors + } + + // Barrel roll - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.BARREL_ROLL], + ) + ) { if (dataRef.current.isRolling || evt.ctrlKey || evt.metaKey) return; dataRef.current.setIsRolling(true); @@ -322,10 +692,30 @@ export function KeyboardEvents() { }, 1e3); } - // Subtitle sync - if (k === "[" || k === "]") { - const change = k === "[" ? -0.5 : 0.5; - dataRef.current.setDelay(dataRef.current.delay + change); + // Subtitle sync - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SYNC_SUBTITLES_EARLIER], + ) + ) { + dataRef.current.setDelay(dataRef.current.delay - 0.5); + dataRef.current.setShowDelayIndicator(true); + dataRef.current.setCurrentOverlay("subtitle"); + + if (subtitleDebounce.current) clearTimeout(subtitleDebounce.current); + subtitleDebounce.current = setTimeout(() => { + dataRef.current.setShowDelayIndicator(false); + dataRef.current.setCurrentOverlay(null); + }, 3000); + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SYNC_SUBTITLES_LATER], + ) + ) { + dataRef.current.setDelay(dataRef.current.delay + 0.5); dataRef.current.setShowDelayIndicator(true); dataRef.current.setCurrentOverlay("subtitle"); diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index 3582baf2..8482ead1 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -65,10 +65,8 @@ export function useAuthData() { const setEnableLastSuccessfulSource = usePreferencesStore( (s) => s.setEnableLastSuccessfulSource, ); - const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources); const setEmbedOrder = usePreferencesStore((s) => s.setEmbedOrder); const setEnableEmbedOrder = usePreferencesStore((s) => s.setEnableEmbedOrder); - const setDisabledEmbeds = usePreferencesStore((s) => s.setDisabledEmbeds); const setProxyTmdb = usePreferencesStore((s) => s.setProxyTmdb); @@ -91,6 +89,12 @@ export function useAuthData() { const setEnableAutoResumeOnPlaybackError = usePreferencesStore( (s) => s.setEnableAutoResumeOnPlaybackError, ); + const setKeyboardShortcuts = usePreferencesStore( + (s) => s.setKeyboardShortcuts, + ); + const setEnableMinimalCards = usePreferencesStore( + (s) => s.setEnableMinimalCards, + ); const login = useCallback( async ( @@ -212,10 +216,6 @@ export function useAuthData() { setEnableLastSuccessfulSource(settings.enableLastSuccessfulSource); } - if (settings.disabledSources !== undefined) { - setDisabledSources(settings.disabledSources ?? []); - } - if (settings.embedOrder !== undefined) { setEmbedOrder(settings.embedOrder ?? []); } @@ -224,10 +224,6 @@ export function useAuthData() { setEnableEmbedOrder(settings.enableEmbedOrder); } - if (settings.disabledEmbeds !== undefined) { - setDisabledEmbeds(settings.disabledEmbeds ?? []); - } - if (settings.proxyTmdb !== undefined) { setProxyTmdb(settings.proxyTmdb); } @@ -275,6 +271,14 @@ export function useAuthData() { settings.enableAutoResumeOnPlaybackError, ); } + + if (settings.keyboardShortcuts !== undefined) { + setKeyboardShortcuts(settings.keyboardShortcuts); + } + + if (settings.enableMinimalCards !== undefined) { + setEnableMinimalCards(settings.enableMinimalCards); + } }, [ replaceBookmarks, @@ -296,10 +300,8 @@ export function useAuthData() { setEnableSourceOrder, setLastSuccessfulSource, setEnableLastSuccessfulSource, - setDisabledSources, setEmbedOrder, setEnableEmbedOrder, - setDisabledEmbeds, setProxyTmdb, setFebboxKey, setdebridToken, @@ -311,6 +313,8 @@ export function useAuthData() { setManualSourceSelection, setEnableDoubleClickToSeek, setEnableAutoResumeOnPlaybackError, + setKeyboardShortcuts, + setEnableMinimalCards, ], ); diff --git a/src/hooks/auth/useMigration.ts b/src/hooks/auth/useMigration.ts index ca9efbb4..fb829ec9 100644 --- a/src/hooks/auth/useMigration.ts +++ b/src/hooks/auth/useMigration.ts @@ -131,19 +131,11 @@ export function useMigration() { enableSourceOrder: preferences.enableSourceOrder, lastSuccessfulSource: preferences.lastSuccessfulSource, enableLastSuccessfulSource: preferences.enableLastSuccessfulSource, - disabledSources: - preferences.disabledSources.length > 0 - ? preferences.disabledSources - : undefined, embedOrder: preferences.embedOrder.length > 0 ? preferences.embedOrder : undefined, enableEmbedOrder: preferences.enableEmbedOrder, - disabledEmbeds: - preferences.disabledEmbeds.length > 0 - ? preferences.disabledEmbeds - : undefined, proxyTmdb: preferences.proxyTmdb, enableLowPerformanceMode: preferences.enableLowPerformanceMode, enableNativeSubtitles: preferences.enableNativeSubtitles, diff --git a/src/hooks/useEmbedOrderState.ts b/src/hooks/useEmbedOrderState.ts index 11897f03..ffe78d33 100644 --- a/src/hooks/useEmbedOrderState.ts +++ b/src/hooks/useEmbedOrderState.ts @@ -12,42 +12,29 @@ export function useEmbedOrderState() { // Get current values from store const embedOrder = usePreferencesStore((s) => s.embedOrder); const enableEmbedOrder = usePreferencesStore((s) => s.enableEmbedOrder); - const disabledEmbeds = usePreferencesStore((s) => s.disabledEmbeds); // Local state for tracking changes const [localEmbedOrder, setLocalEmbedOrder] = useState(embedOrder); const [localEnableEmbedOrder, setLocalEnableEmbedOrder] = useState(enableEmbedOrder); - const [localDisabledEmbeds, setLocalDisabledEmbeds] = - useState(disabledEmbeds); // Store setters const setEmbedOrder = usePreferencesStore((s) => s.setEmbedOrder); const setEnableEmbedOrder = usePreferencesStore((s) => s.setEnableEmbedOrder); - const setDisabledEmbeds = usePreferencesStore((s) => s.setDisabledEmbeds); // Check if any changes have been made const hasChanges = useMemo(() => { return ( JSON.stringify(localEmbedOrder) !== JSON.stringify(embedOrder) || - localEnableEmbedOrder !== enableEmbedOrder || - JSON.stringify(localDisabledEmbeds) !== JSON.stringify(disabledEmbeds) + localEnableEmbedOrder !== enableEmbedOrder ); - }, [ - localEmbedOrder, - embedOrder, - localEnableEmbedOrder, - enableEmbedOrder, - localDisabledEmbeds, - disabledEmbeds, - ]); + }, [localEmbedOrder, embedOrder, localEnableEmbedOrder, enableEmbedOrder]); // Reset local state to match store const reset = useCallback(() => { setLocalEmbedOrder(embedOrder); setLocalEnableEmbedOrder(enableEmbedOrder); - setLocalDisabledEmbeds(disabledEmbeds); - }, [embedOrder, enableEmbedOrder, disabledEmbeds]); + }, [embedOrder, enableEmbedOrder]); // Save changes to backend and update store const saveChanges = useCallback(async () => { @@ -57,13 +44,11 @@ export function useEmbedOrderState() { await updateSettings(backendUrl, account, { embedOrder: localEmbedOrder, enableEmbedOrder: localEnableEmbedOrder, - disabledEmbeds: localDisabledEmbeds, }); // Update the store with the new values setEmbedOrder(localEmbedOrder); setEnableEmbedOrder(localEnableEmbedOrder); - setDisabledEmbeds(localDisabledEmbeds); } catch (error) { console.error("Failed to save embed order settings:", error); throw error; @@ -73,29 +58,24 @@ export function useEmbedOrderState() { backendUrl, localEmbedOrder, localEnableEmbedOrder, - localDisabledEmbeds, setEmbedOrder, setEnableEmbedOrder, - setDisabledEmbeds, ]); // Update local state when store changes useEffect(() => { setLocalEmbedOrder(embedOrder); setLocalEnableEmbedOrder(enableEmbedOrder); - setLocalDisabledEmbeds(disabledEmbeds); - }, [embedOrder, enableEmbedOrder, disabledEmbeds]); + }, [embedOrder, enableEmbedOrder]); return { // Current values embedOrder: localEmbedOrder, enableEmbedOrder: localEnableEmbedOrder, - disabledEmbeds: localDisabledEmbeds, // Setters setEmbedOrder: setLocalEmbedOrder, setEnableEmbedOrder: setLocalEnableEmbedOrder, - setDisabledEmbeds: setLocalDisabledEmbeds, // State management hasChanges, diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts index a2df937f..db619f28 100644 --- a/src/hooks/useIsMobile.ts +++ b/src/hooks/useIsMobile.ts @@ -28,3 +28,11 @@ export function useIsMobile(horizontal?: boolean) { isMobile, }; } + +export function useIsPWA() { + return window.matchMedia("(display-mode: standalone)").matches; +} + +export function useIsIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent); +} diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx index 2492f6b9..0ef0ecea 100644 --- a/src/hooks/useProviderScrape.tsx +++ b/src/hooks/useProviderScrape.tsx @@ -10,6 +10,7 @@ import { } from "@/backend/helpers/providerApi"; import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; import { getProviders } from "@/backend/providers/providers"; +import { getMediaKey } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; @@ -162,30 +163,42 @@ export function useScrape() { const enableLastSuccessfulSource = usePreferencesStore( (s) => s.enableLastSuccessfulSource, ); - const disabledSources = usePreferencesStore((s) => s.disabledSources); const preferredEmbedOrder = usePreferencesStore((s) => s.embedOrder); const enableEmbedOrder = usePreferencesStore((s) => s.enableEmbedOrder); - const disabledEmbeds = usePreferencesStore((s) => s.disabledEmbeds); const startScraping = useCallback( async (media: ScrapeMedia, startFromSourceId?: string) => { const providerInstance = getProviders(); const allSources = providerInstance.listSources(); const playerState = usePlayerStore.getState(); - const failedSources = playerState.failedSources; - const failedEmbeds = playerState.failedEmbeds; - // Start with all available sources (filtered by disabled and failed ones) + // Get media-specific failed sources/embeds + // Try to get media key from player state first, fallback to deriving from ScrapeMedia + let mediaKey = getMediaKey(playerState.meta); + if (!mediaKey) { + // Derive media key from ScrapeMedia if meta is not set yet + if (media.type === "movie") { + mediaKey = `movie-${media.tmdbId}`; + } else if (media.type === "show" && media.season && media.episode) { + mediaKey = `show-${media.tmdbId}-${media.season.tmdbId}-${media.episode.tmdbId}`; + } else if (media.type === "show") { + mediaKey = `show-${media.tmdbId}`; + } + } + const failedSources = mediaKey + ? playerState.failedSourcesPerMedia[mediaKey] || [] + : []; + const failedEmbeds = mediaKey + ? playerState.failedEmbedsPerMedia[mediaKey] || {} + : {}; + + // Start with all available sources (filtered by failed ones only) let baseSourceOrder = allSources - .filter( - (source) => - !disabledSources.includes(source.id) && - !failedSources.includes(source.id), - ) + .filter((source) => !failedSources.includes(source.id)) .map((source) => source.id); // Apply custom source ordering if enabled - if (enableSourceOrder && preferredSourceOrder.length > 0) { + if (enableSourceOrder && (preferredSourceOrder || []).length > 0) { const orderedSources: string[] = []; const remainingSources = [...baseSourceOrder]; @@ -222,14 +235,13 @@ export function useScrape() { } } - // Collect all failed embed IDs across all sources + // Collect all failed embed IDs across all sources for current media const allFailedEmbedIds = Object.values(failedEmbeds).flat(); - // Filter out disabled and failed embeds from the embed order + // Filter out failed embeds from the embed order const filteredEmbedOrder = enableEmbedOrder - ? preferredEmbedOrder.filter( - (id) => - !disabledEmbeds.includes(id) && !allFailedEmbedIds.includes(id), + ? (preferredEmbedOrder || []).filter( + (id) => !allFailedEmbedIds.includes(id), ) : undefined; @@ -284,10 +296,8 @@ export function useScrape() { enableSourceOrder, lastSuccessfulSource, enableLastSuccessfulSource, - disabledSources, preferredEmbedOrder, enableEmbedOrder, - disabledEmbeds, ], ); diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index b9e7dd37..b704be17 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -64,14 +64,13 @@ export function useSettingsState( enableSourceOrder: boolean, lastSuccessfulSource: string | null, enableLastSuccessfulSource: boolean, - disabledSources: string[], embedOrder: string[], enableEmbedOrder: boolean, - disabledEmbeds: string[], proxyTmdb: boolean, enableSkipCredits: boolean, enableImageLogos: boolean, enableCarouselView: boolean, + enableMinimalCards: boolean, forceCompactEpisodeView: boolean, enableLowPerformanceMode: boolean, enableNativeSubtitles: boolean, @@ -189,12 +188,6 @@ export function useSettingsState( resetEnableLastSuccessfulSource, enableLastSuccessfulSourceChanged, ] = useDerived(enableLastSuccessfulSource); - const [ - disabledSourcesState, - setDisabledSourcesState, - resetDisabledSources, - disabledSourcesChanged, - ] = useDerived(disabledSources); const [ embedOrderState, setEmbedOrderState, @@ -207,12 +200,6 @@ export function useSettingsState( resetEnableEmbedOrder, enableEmbedOrderChanged, ] = useDerived(enableEmbedOrder); - const [ - disabledEmbedsState, - setDisabledEmbedsState, - resetDisabledEmbeds, - disabledEmbedsChanged, - ] = useDerived(disabledEmbeds); const [proxyTmdbState, setProxyTmdbState, resetProxyTmdb, proxyTmdbChanged] = useDerived(proxyTmdb); const [ @@ -221,6 +208,12 @@ export function useSettingsState( resetEnableCarouselView, enableCarouselViewChanged, ] = useDerived(enableCarouselView); + const [ + enableMinimalCardsState, + setEnableMinimalCardsState, + resetEnableMinimalCards, + enableMinimalCardsChanged, + ] = useDerived(enableMinimalCards); const [ forceCompactEpisodeViewState, setForceCompactEpisodeViewState, @@ -293,12 +286,11 @@ export function useSettingsState( resetEnableSourceOrder(); resetLastSuccessfulSource(); resetEnableLastSuccessfulSource(); - resetDisabledSources(); resetEmbedOrder(); resetEnableEmbedOrder(); - resetDisabledEmbeds(); resetProxyTmdb(); resetEnableCarouselView(); + resetEnableMinimalCards(); resetForceCompactEpisodeView(); resetEnableLowPerformanceMode(); resetEnableNativeSubtitles(); @@ -332,12 +324,11 @@ export function useSettingsState( enableSourceOrderChanged || lastSuccessfulSourceChanged || enableLastSuccessfulSourceChanged || - disabledSourcesChanged || embedOrderChanged || enableEmbedOrderChanged || - disabledEmbedsChanged || proxyTmdbChanged || enableCarouselViewChanged || + enableMinimalCardsChanged || forceCompactEpisodeViewChanged || enableLowPerformanceModeChanged || enableNativeSubtitlesChanged || @@ -465,11 +456,6 @@ export function useSettingsState( set: setProxyTmdbState, changed: proxyTmdbChanged, }, - disabledSources: { - state: disabledSourcesState, - set: setDisabledSourcesState, - changed: disabledSourcesChanged, - }, embedOrder: { state: embedOrderState, set: setEmbedOrderState, @@ -480,16 +466,16 @@ export function useSettingsState( set: setEnableEmbedOrderState, changed: enableEmbedOrderChanged, }, - disabledEmbeds: { - state: disabledEmbedsState, - set: setDisabledEmbedsState, - changed: disabledEmbedsChanged, - }, enableCarouselView: { state: enableCarouselViewState, set: setEnableCarouselViewState, changed: enableCarouselViewChanged, }, + enableMinimalCards: { + state: enableMinimalCardsState, + set: setEnableMinimalCardsState, + changed: enableMinimalCardsChanged, + }, forceCompactEpisodeView: { state: forceCompactEpisodeViewState, set: setForceCompactEpisodeViewState, diff --git a/src/index.tsx b/src/index.tsx index 5659d764..e524488a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -155,8 +155,21 @@ function MigrationRunner() { function TheRouter(props: { children: ReactNode }) { const normalRouter = conf().NORMAL_ROUTER; - if (normalRouter) return {props.children}; - return {props.children}; + if (normalRouter) + return ( + + {props.children} + + ); + return ( + + {props.children} + + ); } // Checks if the extension is installed diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 88a97ee0..ddd0bf92 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -25,6 +25,7 @@ import { MediaItem } from "@/utils/mediaTypes"; import { Button } from "./About"; import { AdsPart } from "./parts/home/AdsPart"; +import { SupportBar } from "./parts/home/SupportBar"; function useSearch(search: string) { const [searching, setSearching] = useState(false); @@ -171,6 +172,8 @@ export function HomePage() { /> )} + {conf().SHOW_SUPPORT_BAR ? : null} + {conf().SHOW_AD ? : null}
diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 9ceabcc9..43558fe3 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -31,6 +31,8 @@ import { getProgressPercentage, useProgressStore } from "@/stores/progress"; import { needsOnboarding } from "@/utils/onboarding"; import { parseTimestamp } from "@/utils/timestamp"; +import { BlurEllipsis } from "./layouts/SubPageLayout"; + export function RealPlayerView() { const navigate = useNavigate(); const params = useParams<{ @@ -205,6 +207,7 @@ export function RealPlayerView() { return ( + {status !== playerStatus.PLAYING ? : null} {status === playerStatus.IDLE ? ( ) : null} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d0aaf9be..c411b894 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -22,7 +22,7 @@ import { Heading1 } from "@/components/utils/Text"; import { Transition } from "@/components/utils/Transition"; import { useAuth } from "@/hooks/auth/useAuth"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; -import { useIsMobile } from "@/hooks/useIsMobile"; +import { useIsIOS, useIsMobile, useIsPWA } from "@/hooks/useIsMobile"; import { useSettingsState } from "@/hooks/useSettingsState"; import { AccountActionsPart } from "@/pages/parts/settings/AccountActionsPart"; import { AccountEditPart } from "@/pages/parts/settings/AccountEditPart"; @@ -60,11 +60,17 @@ function SettingsLayout(props: { const searchRef = useRef(null); const bannerSize = useBannerSize(); + const isPWA = useIsPWA(); + const isIOS = useIsIOS(); + const isIOSPWA = isIOS && isPWA; + // Navbar height is 80px (h-20) const navbarHeight = 80; // On desktop: inline with navbar (same top position + 14px adjustment) // On mobile: below navbar (navbar height + banner) - const topOffset = isMobile ? navbarHeight + bannerSize : bannerSize + 14; + const topOffset = isMobile + ? navbarHeight + bannerSize + (isIOSPWA ? 34 : 0) + : bannerSize + 14; return ( @@ -407,9 +413,6 @@ export function SettingsPage() { (s) => s.setEnableLastSuccessfulSource, ); - const disabledSources = usePreferencesStore((s) => s.disabledSources); - const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources); - // These are commented because the EmbedOrderPart is on the admin page and not on the settings page. const embedOrder = usePreferencesStore((s) => s.embedOrder); // const setEmbedOrder = usePreferencesStore((s) => s.setEmbedOrder); @@ -417,7 +420,6 @@ export function SettingsPage() { const enableEmbedOrder = usePreferencesStore((s) => s.enableEmbedOrder); // const setEnableEmbedOrder = usePreferencesStore((s) => s.setEnableEmbedOrder); - const disabledEmbeds = usePreferencesStore((s) => s.disabledEmbeds); // const setDisabledEmbeds = usePreferencesStore((s) => s.setDisabledEmbeds); const enableDiscover = usePreferencesStore((s) => s.enableDiscover); @@ -442,6 +444,11 @@ export function SettingsPage() { (s) => s.setEnableCarouselView, ); + const enableMinimalCards = usePreferencesStore((s) => s.enableMinimalCards); + const setEnableMinimalCards = usePreferencesStore( + (s) => s.setEnableMinimalCards, + ); + const forceCompactEpisodeView = usePreferencesStore( (s) => s.forceCompactEpisodeView, ); @@ -549,14 +556,13 @@ export function SettingsPage() { enableSourceOrder, lastSuccessfulSource, enableLastSuccessfulSource, - disabledSources, embedOrder, enableEmbedOrder, - disabledEmbeds, proxyTmdb, enableSkipCredits, enableImageLogos, enableCarouselView, + enableMinimalCards, forceCompactEpisodeView, enableLowPerformanceMode, enableNativeSubtitles, @@ -570,7 +576,7 @@ export function SettingsPage() { const availableSources = useMemo(() => { const sources = getAllProviders().listSources(); const sourceIDs = sources.map((s) => s.id); - const stateSources = state.sourceOrder.state; + const stateSources = state.sourceOrder.state || []; // Filter out sources that are not in `stateSources` and are in `sources` const updatedSources = stateSources.filter((ss) => sourceIDs.includes(ss)); @@ -622,9 +628,9 @@ export function SettingsPage() { state.enableSourceOrder.changed || state.lastSuccessfulSource.changed || state.enableLastSuccessfulSource.changed || - state.disabledSources.changed || state.proxyTmdb.changed || state.enableCarouselView.changed || + state.enableMinimalCards.changed || state.forceCompactEpisodeView.changed || state.enableLowPerformanceMode.changed || state.enableHoldToBoost.changed || @@ -651,9 +657,9 @@ export function SettingsPage() { enableSourceOrder: state.enableSourceOrder.state, lastSuccessfulSource: state.lastSuccessfulSource.state, enableLastSuccessfulSource: state.enableLastSuccessfulSource.state, - disabledSources: state.disabledSources.state, proxyTmdb: state.proxyTmdb.state, enableCarouselView: state.enableCarouselView.state, + enableMinimalCards: state.enableMinimalCards.state, forceCompactEpisodeView: state.forceCompactEpisodeView.state, enableLowPerformanceMode: state.enableLowPerformanceMode.state, enableHoldToBoost: state.enableHoldToBoost.state, @@ -699,7 +705,6 @@ export function SettingsPage() { setEnableSourceOrder(state.enableSourceOrder.state); setLastSuccessfulSource(state.lastSuccessfulSource.state); setEnableLastSuccessfulSource(state.enableLastSuccessfulSource.state); - setDisabledSources(state.disabledSources.state); setAppLanguage(state.appLanguage.state); setTheme(state.theme.state); setSubStyling(state.subtitleStyling.state); @@ -710,6 +715,7 @@ export function SettingsPage() { setdebridService(state.debridService.state); setProxyTmdb(state.proxyTmdb.state); setEnableCarouselView(state.enableCarouselView.state); + setEnableMinimalCards(state.enableMinimalCards.state); setForceCompactEpisodeView(state.forceCompactEpisodeView.state); setEnableLowPerformanceMode(state.enableLowPerformanceMode.state); setEnableHoldToBoost(state.enableHoldToBoost.state); @@ -753,7 +759,6 @@ export function SettingsPage() { setEnableSourceOrder, setLastSuccessfulSource, setEnableLastSuccessfulSource, - setDisabledSources, setAppLanguage, setTheme, setSubStyling, @@ -765,6 +770,7 @@ export function SettingsPage() { setBackendUrl, setProxyTmdb, setEnableCarouselView, + setEnableMinimalCards, setForceCompactEpisodeView, setEnableLowPerformanceMode, setEnableHoldToBoost, @@ -843,8 +849,6 @@ export function SettingsPage() { setEnableLastSuccessfulSource={ state.enableLastSuccessfulSource.set } - disabledSources={state.disabledSources.state} - setDisabledSources={state.disabledSources.set} enableLowPerformanceMode={state.enableLowPerformanceMode.state} setEnableLowPerformanceMode={state.enableLowPerformanceMode.set} enableHoldToBoost={state.enableHoldToBoost.state} @@ -880,6 +884,8 @@ export function SettingsPage() { setEnableImageLogos={state.enableImageLogos.set} enableCarouselView={state.enableCarouselView.state} setEnableCarouselView={state.enableCarouselView.set} + enableMinimalCards={state.enableMinimalCards.state} + setEnableMinimalCards={state.enableMinimalCards.set} forceCompactEpisodeView={state.forceCompactEpisodeView.state} setForceCompactEpisodeView={state.forceCompactEpisodeView.set} homeSectionOrder={state.homeSectionOrder.state} diff --git a/src/pages/admin/AdminPage.tsx b/src/pages/admin/AdminPage.tsx index e3d39eca..dada6524 100644 --- a/src/pages/admin/AdminPage.tsx +++ b/src/pages/admin/AdminPage.tsx @@ -47,8 +47,6 @@ export function AdminPage() { setEmbedOrder={embedOrderState.setEmbedOrder} enableEmbedOrder={embedOrderState.enableEmbedOrder} setEnableEmbedOrder={embedOrderState.setEnableEmbedOrder} - disabledEmbeds={embedOrderState.disabledEmbeds} - setDisabledEmbeds={embedOrderState.setDisabledEmbeds} /> {/* */} diff --git a/src/pages/discover/components/MediaCarousel.tsx b/src/pages/discover/components/MediaCarousel.tsx index 281b8b62..5e9d4b77 100644 --- a/src/pages/discover/components/MediaCarousel.tsx +++ b/src/pages/discover/components/MediaCarousel.tsx @@ -305,9 +305,9 @@ export function MediaCarousel({ return (
-
+
-

+

{sectionTitle}

{showRecommendations && @@ -398,7 +398,7 @@ export function MediaCarousel({ {t("discover.carousel.more")} @@ -477,7 +477,7 @@ export function MediaCarousel({ }} onWheel={handleWheel} > -
+
{media.length > 0 ? media.map((item) => ( @@ -533,7 +533,7 @@ export function MediaCarousel({ )} -
+
{!isMobile && ( diff --git a/src/pages/discover/hooks/useDiscoverMedia.ts b/src/pages/discover/hooks/useDiscoverMedia.ts index ea4e719d..1419c3ad 100644 --- a/src/pages/discover/hooks/useDiscoverMedia.ts +++ b/src/pages/discover/hooks/useDiscoverMedia.ts @@ -184,6 +184,11 @@ export function useDiscoverMedia({ // Race between the Trakt request and timeout const response = await Promise.race([traktFunction(), timeoutPromise]); + // Check if response is null + if (!response) { + throw new Error("Trakt API returned null response"); + } + // Paginate the results const pageSize = isCarouselView ? 20 : 100; // Limit to 20 items for carousels, get more for detailed views const { tmdb_ids: tmdbIds, hasMore: hasMoreResults } = paginateResults( @@ -317,6 +322,15 @@ export function useDiscoverMedia({ }, [mediaType, formattedLanguage, isCarouselView]); const fetchMedia = useCallback(async () => { + // Skip fetching recommendations if no ID is provided + if (contentType === "recommendations" && !id) { + setIsLoading(false); + setMedia([]); + setHasMore(false); + setSectionTitle(""); + return; + } + setIsLoading(true); setError(null); diff --git a/src/pages/migration/MigrationUpload.tsx b/src/pages/migration/MigrationUpload.tsx index 86dcc27f..9271b294 100644 --- a/src/pages/migration/MigrationUpload.tsx +++ b/src/pages/migration/MigrationUpload.tsx @@ -375,11 +375,6 @@ export function MigrationUploadPage() { uploadedData.settings.enableLastSuccessfulSource, ); } - if (uploadedData.settings.disabledSources !== undefined) { - preferencesStore.setDisabledSources( - uploadedData.settings.disabledSources, - ); - } if (uploadedData.settings.embedOrder !== undefined) { preferencesStore.setEmbedOrder(uploadedData.settings.embedOrder); } @@ -388,11 +383,6 @@ export function MigrationUploadPage() { uploadedData.settings.enableEmbedOrder, ); } - if (uploadedData.settings.disabledEmbeds !== undefined) { - preferencesStore.setDisabledEmbeds( - uploadedData.settings.disabledEmbeds, - ); - } if (uploadedData.settings.proxyTmdb !== undefined) { preferencesStore.setProxyTmdb(uploadedData.settings.proxyTmdb); } diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 1d47aad9..2c37fc5e 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { Trans, useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; @@ -179,7 +180,9 @@ export function OnboardingPage() {
navigate("/onboarding/extension")} - className="md:w-1/3" + className={classNames( + conf().HIDE_PROXY_ONBOARDING ? "md:w-1/2" : "md:w-1/3", + )} > -
- - - {t("onboarding.start.options.or")} - - -
- navigate("/onboarding/proxy")} - className="md:w-1/3" - > - - {t("onboarding.start.options.proxy.action")} - - + {conf().HIDE_PROXY_ONBOARDING ? null : ( + <> +
+ + + {t("onboarding.start.options.or")} + + +
+ navigate("/onboarding/proxy")} + className="md:w-1/3" + > + + {t("onboarding.start.options.proxy.action")} + + + + )} {noProxies ? null : ( <>
@@ -227,7 +234,9 @@ export function OnboardingPage() { ? () => completeAndRedirect() // Skip modal on Safari : skipModal.show // Show modal on other browsers } - className="md:w-1/3" + className={classNames( + conf().HIDE_PROXY_ONBOARDING ? "md:w-1/2" : "md:w-1/3", + )} > - navigate("/onboarding/proxy")} - className="md:w-1/3" - > - - + {conf().HIDE_PROXY_ONBOARDING ? null : ( + navigate("/onboarding/proxy")} + className="md:w-1/3" + > + + + )} {noProxies ? null : ( + {(conf().ALLOW_FEBBOX_KEY || conf().ALLOW_DEBRID_KEY) === true && ( + + {t("onboarding.start.options.addons.title")} + + )}
s.febboxKey)} diff --git a/src/pages/onboarding/OnboardingExtension.tsx b/src/pages/onboarding/OnboardingExtension.tsx index 11e9f3af..61e00b6e 100644 --- a/src/pages/onboarding/OnboardingExtension.tsx +++ b/src/pages/onboarding/OnboardingExtension.tsx @@ -122,9 +122,15 @@ interface ExtensionPageProps { loading: boolean; } -function ChromeExtensionPage(props: ExtensionPageProps) { +function DefaultExtensionPage(props: ExtensionPageProps) { const { t } = useTranslation(); - const installLink = conf().ONBOARDING_CHROME_EXTENSION_INSTALL_LINK; + const installChromeLink = conf().ONBOARDING_CHROME_EXTENSION_INSTALL_LINK; + const installFirefoxLink = conf().ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK; + + const browser = useMemo(() => { + return detectExtensionInstall(); + }, []); + return ( <> @@ -133,40 +139,72 @@ function ChromeExtensionPage(props: ExtensionPageProps) { {t("onboarding.extension.explainer")} - {installLink ? ( - - {t("onboarding.extension.linkChrome")} - - ) : null} - - - See extension source code - - - ); -} + {/* Main extension icons */} +
+ {installChromeLink && + (browser === "chrome" || browser === "unknown") ? ( + + + + + + + + {t("onboarding.extension.linkChrome")} + + + ) : null} + {installFirefoxLink && + (browser === "firefox" || browser === "unknown") ? ( + + + + + + + + {t("onboarding.extension.linkFirefox")} + + + ) : null} +
-function FirefoxExtensionPage(props: ExtensionPageProps) { - const { t } = useTranslation(); - const installLink = conf().ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK; - return ( - <> - - {t("onboarding.extension.title")} - - - {t("onboarding.extension.explainer")} - - {installLink ? ( - - {t("onboarding.extension.linkFirefox")} - - ) : null} + {/* Secondary userscript option */} +
+
+ + {t("onboarding.extension.linkUserscript")} + + + {t("onboarding.extension.userscriptNote")} + +
+
- - {t("onboarding.extension.title")} - - - {t("onboarding.extension.explainer")} - -
- {installChromeLink ? ( - - {t("onboarding.extension.linkChrome")} - - ) : null} -
-
- {installFirefoxLink ? ( - - {t("onboarding.extension.linkFirefox")} - - ) : null} -
- - - - See extension source code - - - ); -} - export function OnboardingExtensionPage() { const { t } = useTranslation(); const navigate = useNavigateOnboarding(); @@ -254,12 +253,12 @@ export function OnboardingExtensionPage() { const componentMap: Record< ExtensionDetectionResult, - typeof UnknownExtensionPage + typeof DefaultExtensionPage > = { - chrome: ChromeExtensionPage, - firefox: FirefoxExtensionPage, + chrome: DefaultExtensionPage, + firefox: DefaultExtensionPage, ios: IosExtensionPage, - unknown: UnknownExtensionPage, + unknown: DefaultExtensionPage, }; const PageContent = componentMap[extensionSupport]; diff --git a/src/pages/parts/admin/ConfigValuesPart.tsx b/src/pages/parts/admin/ConfigValuesPart.tsx index 74372361..261dec60 100644 --- a/src/pages/parts/admin/ConfigValuesPart.tsx +++ b/src/pages/parts/admin/ConfigValuesPart.tsx @@ -6,37 +6,59 @@ import { conf } from "@/setup/config"; import { BACKEND_URL } from "@/setup/constants"; async function getAccountNumber() { - const response = await fetch(`${BACKEND_URL}/metrics`); - const text = await response.text(); - - // Adjusted regex to match any hostname - const regex = - /mw_provider_hostname_count{hostname="https?:\/\/[^"}]+"} (\d+)/g; - let total = 0; - let match = regex.exec(text); // Initial assignment outside the loop - - while (match !== null) { - total += parseInt(match[1], 10); - match = regex.exec(text); // Update the assignment at the end of the loop body + if (!BACKEND_URL) { + return "N/A"; } - if (total > 0) { - return total.toString(); + try { + const response = await fetch(`${BACKEND_URL}/metrics`); + if (!response.ok) { + return "N/A"; + } + const text = await response.text(); + + // Adjusted regex to match any hostname + const regex = + /mw_provider_hostname_count{hostname="https?:\/\/[^"}]+"} (\d+)/g; + let total = 0; + let match = regex.exec(text); // Initial assignment outside the loop + + while (match !== null) { + total += parseInt(match[1], 10); + match = regex.exec(text); // Update the assignment at the end of the loop body + } + + if (total > 0) { + return total.toString(); + } + return "0"; + } catch (error) { + return "N/A"; } - throw new Error("ACCOUNT_NUMBER not found"); } async function getAllAccounts() { - const response = await fetch(`${BACKEND_URL}/metrics`); - const text = await response.text(); - - const regex = /mw_user_count{namespace="movie-web"} (\d+)/; - const match = text.match(regex); - - if (match) { - return match[1]; + if (!BACKEND_URL) { + return "N/A"; + } + + try { + const response = await fetch(`${BACKEND_URL}/metrics`); + if (!response.ok) { + return "N/A"; + } + const text = await response.text(); + + const regex = /mw_user_count{namespace="movie-web"} (\d+)/; + const match = text.match(regex); + + if (match) { + return match[1]; + } + return "0"; + } catch (error) { + return "N/A"; } - throw new Error("USER_COUNT not found"); } function ConfigValue(props: { name: string; children?: ReactNode }) { @@ -65,6 +87,7 @@ export function ConfigValuesPart() { }) .catch((error) => { console.error("Error fetching account number:", error); + setAccountNumber("N/A"); }); getAllAccounts() @@ -73,6 +96,7 @@ export function ConfigValuesPart() { }) .catch((error) => { console.error("Error fetching all accounts:", error); + setAllAccounts("N/A"); }); }, []); diff --git a/src/pages/parts/admin/EmbedOrderPart.tsx b/src/pages/parts/admin/EmbedOrderPart.tsx index 168d495b..5e591c41 100644 --- a/src/pages/parts/admin/EmbedOrderPart.tsx +++ b/src/pages/parts/admin/EmbedOrderPart.tsx @@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom"; import { getAllProviders, getProviders } from "@/backend/providers/providers"; import { Button } from "@/components/buttons/Button"; import { Toggle } from "@/components/buttons/Toggle"; -import { SortableListWithToggles } from "@/components/form/SortableListWithToggles"; +import { SortableList } from "@/components/form/SortableList"; import { Heading2 } from "@/components/utils/Text"; interface EmbedOrderPartProps { @@ -13,8 +13,6 @@ interface EmbedOrderPartProps { setEmbedOrder: (order: string[]) => void; enableEmbedOrder: boolean; setEnableEmbedOrder: (enabled: boolean) => void; - disabledEmbeds: string[]; - setDisabledEmbeds: (disabled: string[]) => void; } export function EmbedOrderPart({ @@ -22,8 +20,6 @@ export function EmbedOrderPart({ setEmbedOrder, enableEmbedOrder, setEnableEmbedOrder, - disabledEmbeds, - setDisabledEmbeds, }: EmbedOrderPartProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -39,7 +35,6 @@ export function EmbedOrderPart({ id: e.id, name: e.name || e.id, disabled: !currentDeviceEmbeds.find((embed) => embed.id === e.id), - enabled: !disabledEmbeds.includes(e.id), })); } @@ -48,16 +43,8 @@ export function EmbedOrderPart({ id, name: allEmbeds.find((e) => e.id === id)?.name || id, disabled: !currentDeviceEmbeds.find((e) => e.id === id), - enabled: !disabledEmbeds.includes(id), })); - }, [embedOrder, allEmbeds, disabledEmbeds]); - - const handleEmbedToggle = (embedId: string) => { - const newDisabledEmbeds = disabledEmbeds.includes(embedId) - ? disabledEmbeds.filter((id) => id !== embedId) - : [...disabledEmbeds, embedId]; - setDisabledEmbeds(newDisabledEmbeds); - }; + }, [embedOrder, allEmbeds]); return (
@@ -91,10 +78,9 @@ export function EmbedOrderPart({ {enableEmbedOrder && (
- setEmbedOrder(items.map((item) => item.id))} - onToggle={handleEmbedToggle} />
+ {(!isExtensionActiveCached() || !febboxKey) && + conf().HAS_ONBOARDING ? ( +
+ + {t("player.scraping.notFound.onboarding")} + + +
+ ) : null}
diff --git a/src/pages/parts/home/BookmarksCarousel.tsx b/src/pages/parts/home/BookmarksCarousel.tsx index 0d91b069..13183d1b 100644 --- a/src/pages/parts/home/BookmarksCarousel.tsx +++ b/src/pages/parts/home/BookmarksCarousel.tsx @@ -1,9 +1,11 @@ -import React, { useMemo, useState } from "react"; +import { Listbox } from "@headlessui/react"; +import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { EditButton } from "@/components/buttons/EditButton"; import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; +import { Dropdown, OptionItem } from "@/components/form/Dropdown"; import { Icon, Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; @@ -17,6 +19,7 @@ import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButto import { useBookmarkStore } from "@/stores/bookmarks"; import { useGroupOrderStore } from "@/stores/groupOrder"; import { useProgressStore } from "@/stores/progress"; +import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; import { MediaItem } from "@/utils/mediaTypes"; function parseGroupString(group: string): { icon: UserIcons; name: string } { @@ -88,8 +91,16 @@ export function BookmarksCarousel({ const browser = !!window.chrome; let isScrolling = false; const [editing, setEditing] = useState(false); + const [sortBy, setSortBy] = useState(() => { + const saved = localStorage.getItem("__MW::bookmarksSort"); + return (saved as SortOption) || "date"; + }); const removeBookmark = useBookmarkStore((s) => s.removeBookmark); + useEffect(() => { + localStorage.setItem("__MW::bookmarksSort", sortBy); + }, [sortBy]); + // Editing modals const editBookmarkModal = useModal("bookmark-edit-carousel"); const editGroupModal = useModal("bookmark-edit-group-carousel"); @@ -113,26 +124,15 @@ export function BookmarksCarousel({ const groupOrder = useGroupOrderStore((s) => s.groupOrder); const items = useMemo(() => { - let output: MediaItem[] = []; + const 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]); + return sortMediaItems(output, sortBy, bookmarks, progressItems); + }, [bookmarks, progressItems, sortBy]); const { groupedItems, regularItems } = useMemo(() => { const grouped: Record = {}; @@ -152,23 +152,26 @@ export function BookmarksCarousel({ } }); - // Sort items within each group by date + // Sort items within each group 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; - }); + grouped[group] = sortMediaItems( + grouped[group], + sortBy, + bookmarks, + progressItems, + ); }); - return { groupedItems: grouped, regularItems: regular }; - }, [items, bookmarks, progressItems]); + // Sort regular items + const sortedRegular = sortMediaItems( + regular, + sortBy, + bookmarks, + progressItems, + ); + + return { groupedItems: grouped, regularItems: sortedRegular }; + }, [items, bookmarks, progressItems, sortBy]); const sortedSections = useMemo(() => { const sections: Array<{ @@ -279,6 +282,17 @@ export function BookmarksCarousel({ setEditingGroupName(null); }; + const sortOptions: OptionItem[] = [ + { id: "date", name: t("home.bookmarks.sorting.options.date") }, + { id: "title-asc", name: t("home.bookmarks.sorting.options.titleAsc") }, + { id: "title-desc", name: t("home.bookmarks.sorting.options.titleDesc") }, + { id: "year-asc", name: t("home.bookmarks.sorting.options.yearAsc") }, + { id: "year-desc", name: t("home.bookmarks.sorting.options.yearDesc") }, + ]; + + const selectedSortOption = + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; + const categorySlug = "bookmarks"; const SKELETON_COUNT = 10; @@ -299,9 +313,9 @@ export function BookmarksCarousel({ } - className="ml-4 md:ml-12 mt-2 -mb-5" + className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]" > -
+
{editing && section.group && (
+ {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::bookmarksSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ + active + ? "bg-background-secondaryHover text-type-link" + : "text-type-secondary" + }` + } + key={opt.id} + value={opt} + > + {({ selected }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )}
-
+
{section.items .slice(0, MAX_ITEMS_PER_SECTION) @@ -357,7 +430,7 @@ export function BookmarksCarousel({ )} -
+
{!isMobile && ( @@ -375,9 +448,9 @@ export function BookmarksCarousel({ -
+
+ {editing && ( +
+ setSortBy(item.id as SortOption)} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ + active + ? "bg-background-secondaryHover text-type-link" + : "text-type-secondary" + }` + } + key={opt.id} + value={opt} + > + {({ selected }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )}
-
+
{section.items.length > 0 ? section.items @@ -428,7 +556,7 @@ export function BookmarksCarousel({ )} -
+
{!isMobile && ( diff --git a/src/pages/parts/home/BookmarksPart.tsx b/src/pages/parts/home/BookmarksPart.tsx index a5d04454..3f8b5603 100644 --- a/src/pages/parts/home/BookmarksPart.tsx +++ b/src/pages/parts/home/BookmarksPart.tsx @@ -1,10 +1,12 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Listbox } from "@headlessui/react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditButton } from "@/components/buttons/EditButton"; import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; -import { Icons } from "@/components/Icon"; +import { Dropdown, OptionItem } from "@/components/form/Dropdown"; +import { Icon, Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; @@ -15,6 +17,7 @@ import { UserIcon, UserIcons } from "@/components/UserIcon"; import { useBookmarkStore } from "@/stores/bookmarks"; import { useGroupOrderStore } from "@/stores/groupOrder"; import { useProgressStore } from "@/stores/progress"; +import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; import { MediaItem } from "@/utils/mediaTypes"; function parseGroupString(group: string): { icon: UserIcons; name: string } { @@ -52,28 +55,25 @@ export function BookmarksPart({ const modifyBookmarksByGroup = useBookmarkStore( (s) => s.modifyBookmarksByGroup, ); + const [sortBy, setSortBy] = useState(() => { + const saved = localStorage.getItem("__MW::bookmarksSort"); + return (saved as SortOption) || "date"; + }); + + useEffect(() => { + localStorage.setItem("__MW::bookmarksSort", sortBy); + }, [sortBy]); const items = useMemo(() => { - let output: MediaItem[] = []; + const 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]); + return sortMediaItems(output, sortBy, bookmarks, progressItems); + }, [bookmarks, progressItems, sortBy]); const { groupedItems, regularItems } = useMemo(() => { const grouped: Record = {}; @@ -93,23 +93,26 @@ export function BookmarksPart({ } }); - // Sort items within each group by date + // Sort items within each group 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; - }); + grouped[group] = sortMediaItems( + grouped[group], + sortBy, + bookmarks, + progressItems, + ); }); - return { groupedItems: grouped, regularItems: regular }; - }, [items, bookmarks, progressItems]); + // Sort regular items + const sortedRegular = sortMediaItems( + regular, + sortBy, + bookmarks, + progressItems, + ); + + return { groupedItems: grouped, regularItems: sortedRegular }; + }, [items, bookmarks, progressItems, sortBy]); const sortedSections = useMemo(() => { const sections: Array<{ @@ -199,6 +202,17 @@ export function BookmarksPart({ setEditingGroupName(null); }; + const sortOptions: OptionItem[] = [ + { id: "date", name: t("home.bookmarks.sorting.options.date") }, + { id: "title-asc", name: t("home.bookmarks.sorting.options.titleAsc") }, + { id: "title-desc", name: t("home.bookmarks.sorting.options.titleDesc") }, + { id: "year-asc", name: t("home.bookmarks.sorting.options.yearAsc") }, + { id: "year-desc", name: t("home.bookmarks.sorting.options.yearDesc") }, + ]; + + const selectedSortOption = + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; + if (items.length === 0) return null; return ( @@ -236,6 +250,65 @@ export function BookmarksPart({ />
+ {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::bookmarksSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ + active + ? "bg-background-secondaryHover text-type-link" + : "text-type-secondary" + }` + } + key={opt.id} + value={opt} + > + {({ selected }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )} {section.items.map((v) => (
+ {editing && ( +
+ setSortBy(item.id as SortOption)} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ + active + ? "bg-background-secondaryHover text-type-link" + : "text-type-secondary" + }` + } + key={opt.id} + value={opt} + > + {({ selected }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )} {section.items.map((v) => (
{ + return getCookie("supportDescriptionDismissed") === "true"; + }); + + const toggleDescription = useCallback(() => { + const newState = !isDescriptionDismissed; + setIsDescriptionDismissed(newState); + setCookie("supportDescriptionDismissed", newState ? "true" : "false", 14); // Expires after 14 days + }, [isDescriptionDismissed]); + + const openSupportModal = useCallback(() => { + showModal("support-info"); + }, [showModal]); + + const supportValue = conf().SUPPORT_BAR_VALUE; + if (!supportValue) return null; + + // Parse fraction like "100/300" + const [currentStr, goalStr] = supportValue.split("/"); + const current = parseFloat(currentStr) || 0; + const goal = parseFloat(goalStr) || 1; + + const percentage = Math.min((current / goal) * 100, 100); + + return ( +
+
+ + +
+ + {t("home.support.title")} + +

+ {t("home.support.description")} +

+
+
+ + {t("home.support.label", { + current: current.toLocaleString(), + goal: goal.toLocaleString(), + })} + + + {percentage.toFixed(1)}% {t("home.support.complete")} + +
+
+
+ {/* Progress bar */} +
+
+
+
+ + + + + + {t("home.support.donate")} + + +
+ +
+
+ ); +} diff --git a/src/pages/parts/home/WatchingCarousel.tsx b/src/pages/parts/home/WatchingCarousel.tsx index dc8d0942..fd685356 100644 --- a/src/pages/parts/home/WatchingCarousel.tsx +++ b/src/pages/parts/home/WatchingCarousel.tsx @@ -1,14 +1,17 @@ -import React, { useMemo, useState } from "react"; +import { Listbox } from "@headlessui/react"; +import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditButton } from "@/components/buttons/EditButton"; -import { Icons } from "@/components/Icon"; +import { Dropdown, OptionItem } from "@/components/form/Dropdown"; +import { Icon, Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { useIsMobile } from "@/hooks/useIsMobile"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; import { useProgressStore } from "@/stores/progress"; import { shouldShowProgress } from "@/stores/progress/utils"; +import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; import { MediaItem } from "@/utils/mediaTypes"; interface WatchingCarouselProps { @@ -37,8 +40,16 @@ export function WatchingCarousel({ const browser = !!window.chrome; let isScrolling = false; const [editing, setEditing] = useState(false); + const [sortBy, setSortBy] = useState(() => { + const saved = localStorage.getItem("__MW::watchingSort"); + return (saved as SortOption) || "date"; + }); const removeItem = useProgressStore((s) => s.removeItem); + useEffect(() => { + localStorage.setItem("__MW::watchingSort", sortBy); + }, [sortBy]); + const { isMobile } = useIsMobile(); const itemsLength = useProgressStore((state) => { @@ -53,15 +64,14 @@ export function WatchingCarousel({ const output: MediaItem[] = []; Object.entries(progressItems) .filter((entry) => shouldShowProgress(entry[1]).show) - .sort((a, b) => b[1].updatedAt - a[1].updatedAt) .forEach((entry) => { output.push({ id: entry[0], ...entry[1], }); }); - return output; - }, [progressItems]); + return sortMediaItems(output, sortBy, undefined, progressItems); + }, [progressItems, sortBy]); const handleWheel = (e: React.WheelEvent) => { if (isScrolling) return; @@ -81,6 +91,29 @@ export function WatchingCarousel({ } }; + const sortOptions: OptionItem[] = [ + { id: "date", name: t("home.continueWatching.sorting.options.date") }, + { + id: "title-asc", + name: t("home.continueWatching.sorting.options.titleAsc"), + }, + { + id: "title-desc", + name: t("home.continueWatching.sorting.options.titleDesc"), + }, + { + id: "year-asc", + name: t("home.continueWatching.sorting.options.yearAsc"), + }, + { + id: "year-desc", + name: t("home.continueWatching.sorting.options.yearDesc"), + }, + ]; + + const selectedSortOption = + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; + const categorySlug = "continue-watching"; const SKELETON_COUNT = 10; @@ -91,9 +124,9 @@ export function WatchingCarousel({ -
+
+ {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::watchingSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ + active + ? "bg-background-secondaryHover text-type-link" + : "text-type-secondary" + }` + } + key={opt.id} + value={opt} + > + {({ selected }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )}
-
+
{items.length > 0 ? items.map((media) => ( @@ -136,7 +228,7 @@ export function WatchingCarousel({ /> ))} -
+
{!isMobile && ( diff --git a/src/pages/parts/home/WatchingPart.tsx b/src/pages/parts/home/WatchingPart.tsx index de0f6d6e..534dc4b3 100644 --- a/src/pages/parts/home/WatchingPart.tsx +++ b/src/pages/parts/home/WatchingPart.tsx @@ -1,14 +1,17 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Listbox } from "@headlessui/react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditButton } from "@/components/buttons/EditButton"; -import { Icons } from "@/components/Icon"; +import { Dropdown, OptionItem } from "@/components/form/Dropdown"; +import { Icon, Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { useProgressStore } from "@/stores/progress"; import { shouldShowProgress } from "@/stores/progress/utils"; +import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; import { MediaItem } from "@/utils/mediaTypes"; export function WatchingPart({ @@ -22,13 +25,20 @@ export function WatchingPart({ const progressItems = useProgressStore((s) => s.items); const removeItem = useProgressStore((s) => s.removeItem); const [editing, setEditing] = useState(false); + const [sortBy, setSortBy] = useState(() => { + const saved = localStorage.getItem("__MW::watchingSort"); + return (saved as SortOption) || "date"; + }); const [gridRef] = useAutoAnimate(); + useEffect(() => { + localStorage.setItem("__MW::watchingSort", sortBy); + }, [sortBy]); + const sortedProgressItems = useMemo(() => { const output: MediaItem[] = []; Object.entries(progressItems) .filter((entry) => shouldShowProgress(entry[1]).show) - .sort((a, b) => b[1].updatedAt - a[1].updatedAt) .forEach((entry) => { output.push({ id: entry[0], @@ -36,13 +46,36 @@ export function WatchingPart({ }); }); - return output; - }, [progressItems]); + return sortMediaItems(output, sortBy, undefined, progressItems); + }, [progressItems, sortBy]); useEffect(() => { onItemsChange(sortedProgressItems.length > 0); }, [sortedProgressItems, onItemsChange]); + const sortOptions: OptionItem[] = [ + { id: "date", name: t("home.continueWatching.sorting.options.date") }, + { + id: "title-asc", + name: t("home.continueWatching.sorting.options.titleAsc"), + }, + { + id: "title-desc", + name: t("home.continueWatching.sorting.options.titleDesc"), + }, + { + id: "year-asc", + name: t("home.continueWatching.sorting.options.yearAsc"), + }, + { + id: "year-desc", + name: t("home.continueWatching.sorting.options.yearDesc"), + }, + ]; + + const selectedSortOption = + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; + if (sortedProgressItems.length === 0) return null; return ( @@ -57,6 +90,65 @@ export function WatchingPart({ id="edit-button-watching" /> + {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::watchingSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ + active + ? "bg-background-secondaryHover text-type-link" + : "text-type-secondary" + }` + } + key={opt.id} + value={opt} + > + {({ selected }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )} {sortedProgressItems.map((v) => (
s.interface.error); const currentSourceId = usePlayerStore((s) => s.sourceId); const currentEmbedId = usePlayerStore((s) => s.embedId); + const meta = usePlayerStore((s) => s.meta); + const failedEmbedsPerMedia = usePlayerStore((s) => s.failedEmbedsPerMedia); const addFailedSource = usePlayerStore((s) => s.addFailedSource); const addFailedEmbed = usePlayerStore((s) => s.addFailedEmbed); - const failedEmbeds = usePlayerStore((s) => s.failedEmbeds); const modal = useModal("error"); const settingsRouter = useOverlayRouter("settings"); const hasOpenedSettings = useRef(false); @@ -54,6 +56,11 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) { // Check if all embeds for this source have now failed // If so, disable the entire source + const mediaKey = getMediaKey(meta); + const failedEmbeds = + mediaKey && failedEmbedsPerMedia[mediaKey] + ? failedEmbedsPerMedia[mediaKey] + : {}; const failedEmbedsForSource = failedEmbeds[currentSourceId] || []; // For now, we'll assume if we have 2+ failed embeds for a source, disable it // This is a simple heuristic - we could make it more sophisticated @@ -78,7 +85,8 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) { playbackError, currentSourceId, currentEmbedId, - failedEmbeds, + meta, + failedEmbedsPerMedia, addFailedSource, addFailedEmbed, settingsRouter, diff --git a/src/pages/parts/player/SourceSelectPart.tsx b/src/pages/parts/player/SourceSelectPart.tsx index 6c6f4d8e..c3d54a18 100644 --- a/src/pages/parts/player/SourceSelectPart.tsx +++ b/src/pages/parts/player/SourceSelectPart.tsx @@ -28,15 +28,33 @@ function EmbedOption(props: { return sourceMeta?.name ?? unknownEmbedName; }, [props.embedId, unknownEmbedName]); - const { run, errored, loading } = useEmbedScraping( + const { run, errored, loading, notFound } = useEmbedScraping( props.routerId, props.sourceId, props.url, props.embedId, ); + let rightSide; + if (loading) { + rightSide = undefined; // Let SelectableLink handle loading + } else if (notFound) { + rightSide = ( +
+
+
+
+
+ ); + } + return ( - + {embedName} @@ -135,15 +153,13 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) { const enableLastSuccessfulSource = usePreferencesStore( (s) => s.enableLastSuccessfulSource, ); - const disabledSources = usePreferencesStore((s) => s.disabledSources); const sources = useMemo(() => { const metaType = props.media.type; if (!metaType) return []; const allSources = getCachedMetadata() .filter((v) => v.type === "source") - .filter((v) => v.mediaTypes?.includes(metaType)) - .filter((v) => !disabledSources.includes(v.id)); + .filter((v) => v.mediaTypes?.includes(metaType)); if (!enableSourceOrder || preferredSourceOrder.length === 0) { // Even without custom source order, prioritize last successful source if enabled @@ -191,7 +207,6 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) { props.media.type, preferredSourceOrder, enableSourceOrder, - disabledSources, lastSuccessfulSource, enableLastSuccessfulSource, ]); diff --git a/src/pages/parts/settings/AppearancePart.tsx b/src/pages/parts/settings/AppearancePart.tsx index 3bf00e5f..fb72bc28 100644 --- a/src/pages/parts/settings/AppearancePart.tsx +++ b/src/pages/parts/settings/AppearancePart.tsx @@ -95,6 +95,11 @@ const availableThemes = [ selector: "theme-spark", key: "settings.appearance.themes.spark", }, + { + id: "cobalt", + selector: "theme-cobalt", + key: "settings.appearance.themes.cobalt", + }, { id: "grape", selector: "theme-grape", @@ -125,11 +130,6 @@ const availableThemes = [ selector: "theme-christmas", key: "settings.appearance.themes.christmas", }, - { - id: "skyRealm", - selector: "theme-skyrealm", - key: "settings.appearance.themes.skyrealm", - }, ]; function ThemePreview(props: { @@ -245,6 +245,9 @@ export function AppearancePart(props: { enableCarouselView: boolean; setEnableCarouselView: (v: boolean) => void; + enableMinimalCards: boolean; + setEnableMinimalCards: (v: boolean) => void; + forceCompactEpisodeView: boolean; setForceCompactEpisodeView: (v: boolean) => void; @@ -510,6 +513,30 @@ export function AppearancePart(props: {
+ {/* Minimal Cards */} +
+

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

+

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

+
+ props.setEnableMinimalCards(!props.enableMinimalCards) + } + className={classNames( + "bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg", + "cursor-pointer opacity-100 pointer-events-auto", + )} + > + +

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

+
+
+ {/* Force Compact Episode View */}

diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index c0888c3d..443b916a 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -24,6 +24,7 @@ import { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; import { SetupPart, Status, + fetchFebboxQuota, testFebboxKey, testTorboxToken, testdebridToken, @@ -237,9 +238,10 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) { async function getFebboxKeyStatus(febboxKey: string | null) { if (febboxKey) { const status: Status = await testFebboxKey(febboxKey); - return status; + const quota = await fetchFebboxQuota(febboxKey); + return { status, quota }; } - return "unset"; + return { status: "unset" as Status, quota: null }; } interface FebboxSetupProps extends FebboxKeyProps { @@ -282,6 +284,7 @@ export function FebboxSetup({ }, [user.account, febboxKey, preferences.febboxKey, setFebboxKey, mode]); const [status, setStatus] = useState("unset"); + const [quota, setQuota] = useState(null); const statusMap: Record = { error: "error", success: "success", @@ -293,7 +296,8 @@ export function FebboxSetup({ useEffect(() => { const checkTokenStatus = async () => { const result = await getFebboxKeyStatus(febboxKey); - setStatus(result); + setStatus(result.status); + setQuota(result.quota); }; checkTokenStatus(); }, [febboxKey]); @@ -395,9 +399,6 @@ export function FebboxSetup({

-

- -

@@ -438,6 +439,26 @@ export function FebboxSetup({ {t("fedapi.status.invalid_token")}

)} + {status === "success" && + quota && + (() => { + if (!quota?.data?.flow) return null; + const { + traffic_usage: used, + traffic_limit: limit, + reset_at: reset, + } = quota.data.flow; + return ( + <> +

+ {t("fedapi.setup.traffic", { used, limit, reset })} +

+

+ {t("fedapi.setup.trafficExplanation")} +

+ + ); + })()} ) : null} diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 150ee1f9..cb387d9d 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -8,9 +8,10 @@ import { Button } from "@/components/buttons/Button"; import { Toggle } from "@/components/buttons/Toggle"; import { FlagIcon } from "@/components/FlagIcon"; import { Dropdown } from "@/components/form/Dropdown"; -import { SortableListWithToggles } from "@/components/form/SortableListWithToggles"; +import { SortableList } from "@/components/form/SortableList"; import { Heading1 } from "@/components/utils/Text"; import { appLanguageOptions } from "@/setup/i18n"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { isAutoplayAllowed } from "@/utils/autoplay"; import { getLocaleInfo, sortLangCodes } from "@/utils/language"; @@ -29,8 +30,6 @@ export function PreferencesPart(props: { setenableSourceOrder: (v: boolean) => void; enableLastSuccessfulSource: boolean; setEnableLastSuccessfulSource: (v: boolean) => void; - disabledSources: string[]; - setDisabledSources: (v: string[]) => void; enableLowPerformanceMode: boolean; setEnableLowPerformanceMode: (v: boolean) => void; enableHoldToBoost: boolean; @@ -43,6 +42,7 @@ export function PreferencesPart(props: { setEnableAutoResumeOnPlaybackError: (v: boolean) => void; }) { const { t } = useTranslation(); + const { showModal } = useOverlayStack(); const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); const allowAutoplay = isAutoplayAllowed(); @@ -67,9 +67,8 @@ export function PreferencesPart(props: { id, name: allSources.find((s) => s.id === id)?.name || id, disabled: !currentDeviceSources.find((s) => s.id === id), - enabled: !props.disabledSources.includes(id), })); - }, [props.sourceOrder, props.disabledSources, allSources]); + }, [props.sourceOrder, allSources]); const navigate = useNavigate(); @@ -77,13 +76,6 @@ export function PreferencesPart(props: { props.setEnableLowPerformanceMode(!props.enableLowPerformanceMode); }; - const handleSourceToggle = (sourceId: string) => { - const newDisabledSources = props.disabledSources.includes(sourceId) - ? props.disabledSources.filter((id) => id !== sourceId) - : [...props.disabledSources, sourceId]; - props.setDisabledSources(newDisabledSources); - }; - return (
{t("settings.preferences.title")} @@ -246,6 +238,22 @@ export function PreferencesPart(props: {

+ + {/* Keyboard Shortcuts Preference */} +
+

+ {t("settings.preferences.keyboardShortcuts")} +

+

+ {t("settings.preferences.keyboardShortcutsDescription")} +

+
+
{/* Column */} @@ -348,12 +356,11 @@ export function PreferencesPart(props: { {props.enableSourceOrder && (
- props.setSourceOrder(items.map((item) => item.id)) } - onToggle={handleSourceToggle} />