Merge branch 'production' into production

This commit is contained in:
Adrian Pontasch 2025-12-25 19:24:28 +01:00 committed by GitHub
commit c151f94c1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 4151 additions and 1152 deletions

View file

@ -38,7 +38,7 @@
<meta name="msapplication-TileColor" content="#120f1d" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"

View file

@ -1,7 +1,7 @@
{
"packageManager": "pnpm@9.14.4",
"name": "P-Stream",
"version": "5.3.2",
"version": "5.3.3",
"private": true,
"homepage": "https://github.com/p-stream/p-stream",
"scripts": {
@ -75,7 +75,7 @@
"semver": "^7.7.3",
"slugify": "^1.6.6",
"subsrt-ts": "^2.1.2",
"wyzie-lib": "github:FifthWit/wyzie-lib",
"wyzie-lib": "^2.2.6",
"zustand": "^4.5.7"
},
"devDependencies": {

View file

@ -43,8 +43,8 @@ importers:
specifier: ^1.8.0
version: 1.8.0
'@p-stream/providers':
specifier: https://codeload.github.com/p-stream/providers/tar.gz/9e7654ee21220d5ea91d056995584168666138a1
version: https://codeload.github.com/p-stream/providers/tar.gz/9e7654ee21220d5ea91d056995584168666138a1
specifier: github:p-stream/providers#production
version: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0
'@plasmohq/messaging':
specifier: ^0.6.2
version: 0.6.2(react@18.3.1)
@ -154,8 +154,8 @@ importers:
specifier: ^2.1.2
version: 2.1.2
wyzie-lib:
specifier: github:FifthWit/wyzie-lib
version: https://codeload.github.com/FifthWit/wyzie-lib/tar.gz/2df6de4ed84f3253e4fccaa070312574ebf7d052
specifier: ^2.2.6
version: 2.2.6
zustand:
specifier: ^4.5.7
version: 4.5.7(@types/react@18.3.26)(immer@10.1.3)(react@18.3.1)
@ -1207,8 +1207,8 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=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: {}

View file

@ -8,6 +8,103 @@
<lastBuildDate>Mon, 29 Sep 2025 18:00:00 MST</lastBuildDate>
<atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" />
<item>
<guid>notification-054</guid>
<title>P-Stream v5.3.3 released!</title>
<description>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!
</description>
<pubDate>Wed, 24 Dec 2025 11:10:00 MST</pubDate>
<category>update</category>
</item>
<item>
<guid>notification-053</guid>
<title>New Userscript alternative to the extension!</title>
<description>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 Webkits 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!
</description>
<link>https://raw.githubusercontent.com/p-stream/Userscript/main/p-stream.user.js</link>
<pubDate>Sun, 14 Dec 2025 15:00:00 MST</pubDate>
<category>feature</category>
</item>
<item>
<guid>notification-052</guid>
<title>Thanks to everyone who has supported!</title>
<description>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!
</description>
<link>https://rentry.co/nnqtas3e</link>
<pubDate>Tue, 09 Dec 2025 17:30:00 MST</pubDate>
<category>announcement</category>
</item>
<item>
<guid>notification-051</guid>
<title>Xprime has been disabled. More info below!</title>
<description>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!
</description>
<pubDate>Sat, 06 Dec 2025 16:00:00 MST</pubDate>
<category>announcement</category>
</item>
<item>
<guid>notification-050</guid>
<title>New alerts for source failures!</title>
<description>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!
</description>
<pubDate>Tue, 2 Dec 2025 13:11:00 MST</pubDate>
<category>Feature</category>
</item>
<item>
<guid>notification-049</guid>
<title>P-Stream v5.3.2 released!</title>
@ -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!
</description>
<link>https://rentry.co/h5mypdfs</link>
<link>https://rentry.co/nnqtas3e</link>
<pubDate>Sat, 06 Sep 2025 14:42:00 MST</pubDate>
<category>announcement</category>
</item>

View file

@ -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, <bold>open the extension through your browsers extension menu</bold> 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</0> 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</0> 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</0> 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</0> 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</0> 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 <bold>extension</bold> is required for that source. <br><br> <strong>(The default order is best for most users)</strong>",
"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",

View file

@ -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(

View file

@ -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;

View file

@ -315,7 +315,7 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
/>
<CircleDropdownLink href="/support" icon={Icons.SUPPORT} />
<CircleDropdownLink
href="https://rentry.co/h5mypdfs"
href="https://rentry.co/nnqtas3e"
icon={Icons.TIP_JAR}
/>
</div>

File diff suppressed because one or more lines are too long

View file

@ -83,7 +83,7 @@ export function Footer() {
<FooterLink icon={Icons.DISCORD} href={conf().DISCORD_LINK}>
{t("footer.links.discord")}
</FooterLink>
<FooterLink href="https://rentry.co/h5mypdfs" icon={Icons.TIP_JAR}>
<FooterLink href="https://rentry.co/nnqtas3e" icon={Icons.TIP_JAR}>
{t("footer.links.funding")}
</FooterLink>
<div className="inline md:hidden">

View file

@ -50,6 +50,8 @@ function useIntersectionObserver(options: IntersectionObserverInit = {}) {
// Skeleton Component
export function MediaCardSkeleton() {
const enableMinimalCards = usePreferencesStore((s) => s.enableMinimalCards);
return (
<Flare.Base className="group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300">
<Flare.Light
@ -61,21 +63,30 @@ export function MediaCardSkeleton() {
<Flare.Child className="pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300 opacity-60">
<div className="animate-pulse">
{/* Poster skeleton - matches MediaCard poster dimensions exactly */}
<div className="relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground" />
<div
className={classNames(
"relative pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground",
enableMinimalCards ? "" : "mb-4",
)}
/>
{/* Title skeleton - matches MediaCard title dimensions */}
<div className="mb-1">
<div className="h-4 bg-mediaCard-hoverBackground rounded w-full mb-1" />
<div className="h-4 bg-mediaCard-hoverBackground rounded w-3/4 mb-1" />
<div className="h-4 bg-mediaCard-hoverBackground rounded w-1/2" />
</div>
{!enableMinimalCards && (
<>
{/* Title skeleton - matches MediaCard title dimensions */}
<div className="mb-1">
<div className="h-4 bg-mediaCard-hoverBackground rounded w-full mb-1" />
<div className="h-4 bg-mediaCard-hoverBackground rounded w-3/4 mb-1" />
<div className="h-4 bg-mediaCard-hoverBackground rounded w-1/2" />
</div>
{/* Dot list skeleton - matches MediaCard dot list */}
<div className="flex items-center gap-1">
<div className="h-3 bg-mediaCard-hoverBackground rounded w-12" />
<div className="h-1 w-1 bg-mediaCard-hoverBackground rounded-full" />
<div className="h-3 bg-mediaCard-hoverBackground rounded w-8" />
</div>
{/* Dot list skeleton - matches MediaCard dot list */}
<div className="flex items-center gap-1">
<div className="h-3 bg-mediaCard-hoverBackground rounded w-12" />
<div className="h-1 w-1 bg-mediaCard-hoverBackground rounded-full" />
<div className="h-3 bg-mediaCard-hoverBackground rounded w-8" />
</div>
</>
)}
</div>
</Flare.Child>
</Flare.Base>
@ -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({
>
<div
className={classNames(
"relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300",
"relative pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300",
{
"group-hover:rounded-lg": canLink,
},
enableMinimalCards ? "" : "mb-4",
)}
style={{
backgroundImage: isIntersecting
@ -272,48 +285,52 @@ function MediaCardContent({
</div>
</div>
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
<span>{media.title}</span>
</h1>
<div className="media-info-container justify-content-center flex flex-wrap">
<DotList className="text-xs" content={dotListContent} />
</div>
{!enableMinimalCards && (
<>
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
<span>{media.title}</span>
</h1>
<div className="media-info-container justify-content-center flex flex-wrap">
<DotList className="text-xs" content={dotListContent} />
</div>
{!closable && (
<div className="absolute bottom-0 translate-y-1 right-1">
<button
className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onShowDetails?.(media);
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.ELLIPSIS}
/>
</button>
</div>
)}
{editable && closable && (
<div className="absolute bottom-0 translate-y-1 right-1">
<button
className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit?.();
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.EDIT}
/>
</button>
</div>
{!closable && (
<div className="absolute bottom-0 translate-y-1 right-1">
<button
className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onShowDetails?.(media);
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.ELLIPSIS}
/>
</button>
</div>
)}
{editable && closable && (
<div className="absolute bottom-0 translate-y-1 right-1">
<button
className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit?.();
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.EDIT}
/>
</button>
</div>
)}
</>
)}
</Flare.Child>
</Flare.Base>

View file

@ -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 (
<kbd
className={`
relative inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border shadow-sm
${onClick ? "cursor-pointer hover:bg-gray-700" : ""}
${editing ? "ring-2 ring-blue-500" : ""}
${hasConflict ? "border-red-500 bg-red-900/20" : "border-gray-600"}
`}
onClick={onClick}
>
{children}
{modifier && (
<span className="absolute -top-1 -right-1 text-xs bg-blue-600 text-white rounded-full w-4 h-4 flex items-center justify-center">
{getModifierSymbol(modifier)}
</span>
)}
</kbd>
);
}
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>(keyboardShortcuts);
const [editingId, setEditingId] = useState<ShortcutId | null>(null);
const [editingModifier, setEditingModifier] = useState<KeyboardModifier | "">(
"",
);
const [editingKey, setEditingKey] = useState<string>("");
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<string>();
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 (
<Modal id={id}>
<ModalCard className="!max-w-2xl">
<div className="space-y-6">
<div className="text-center">
<Heading2 className="!mt-0 !mb-2">
{t("global.keyboardShortcuts.title")}
</Heading2>
<p className="text-type-secondary text-sm">
{t("global.keyboardShortcuts.clickToEdit")}
</p>
</div>
<div className="flex flex-grow justify-between items-center gap-2">
{conflicts.length > 0 ? (
<p className="text-red-400 text-sm">
{conflicts.length}{" "}
{conflicts.length > 1
? t("global.keyboardShortcuts.conflicts")
: t("global.keyboardShortcuts.conflict")}{" "}
{t("global.keyboardShortcuts.detected")}
</p>
) : (
<div /> // Empty div to take up space
)}
<Button theme="secondary" onClick={handleResetAll}>
<Icon icon={Icons.RELOAD} className="mr-2" />
{t("global.keyboardShortcuts.resetAllToDefault")}
</Button>
</div>
<div className="space-y-6 max-h-[60vh] overflow-y-auto">
{shortcutGroups.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
{group.title}
</h3>
<div className="space-y-2">
{group.shortcuts.map((shortcut) => {
const isEditing = editingId === shortcut.id;
const hasConflict = conflictIds.has(shortcut.id);
const config = editingShortcuts[shortcut.id];
return (
<div
key={shortcut.id}
className="flex items-center justify-between py-1"
>
<div className="flex items-center gap-3 flex-1">
{isEditing ? (
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<Dropdown
selectedItem={
modifierOptions.find(
(opt) => opt.id === editingModifier,
) || modifierOptions[0]
}
setSelectedItem={(item) =>
setEditingModifier(
item.id as KeyboardModifier | "",
)
}
options={modifierOptions}
className="w-32 !my-1"
/>
<KeyBadge
config={
editingKey
? {
modifier:
editingModifier || undefined,
key: editingKey,
}
: undefined
}
editing
>
{isCapturingKey
? t("global.keyboardShortcuts.pressKey")
: editingKey
? getKeyDisplayName(editingKey)
: t("global.keyboardShortcuts.none")}
</KeyBadge>
</div>
<div className="flex items-center gap-2">
<Button
theme="secondary"
onClick={handleSaveEdit}
className="px-2 py-1 text-xs"
>
{t("global.keyboardShortcuts.save")}
</Button>
<Button
theme="secondary"
onClick={handleCancelEdit}
className="px-2 py-1 text-xs"
>
{t("global.keyboardShortcuts.cancel")}
</Button>
</div>
</div>
) : (
<>
<KeyBadge
config={config}
onClick={() => handleStartEdit(shortcut.id)}
hasConflict={hasConflict}
>
{config?.key
? getKeyDisplayName(config.key)
: t("global.keyboardShortcuts.none")}
</KeyBadge>
<span className="text-type-secondary">
{shortcut.description}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{shortcut.condition && !isEditing && (
<span className="text-xs text-gray-400 italic">
{shortcut.condition}
</span>
)}
{!isEditing && (
<button
type="button"
onClick={() => handleResetShortcut(shortcut.id)}
className="text-type-secondary hover:text-white transition-colors"
title={t(
"global.keyboardShortcuts.resetToDefault",
)}
>
<Icon icon={Icons.RELOAD} />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
<Button theme="secondary" onClick={handleCancel}>
{t("global.keyboardShortcuts.cancel")}
</Button>
<Button theme="purple" onClick={handleSave}>
{t("global.keyboardShortcuts.saveChanges")}
</Button>
</div>
</div>
</ModalCard>
</Modal>
);
}

View file

@ -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 (
<kbd className="inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border border-gray-600 shadow-sm">
<kbd className="relative inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border border-gray-600 shadow-sm">
{children}
{modifier && (
<span className="absolute -top-1 -right-1 text-xs bg-blue-600 text-white rounded-full w-4 h-4 flex items-center justify-center">
{getModifierSymbol(modifier)}
</span>
)}
</kbd>
);
}
const getShortcutGroups = (
t: (key: string) => string,
shortcuts: Record<string, KeyboardShortcutConfig>,
): 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 (
<Modal id={id}>
@ -164,12 +233,23 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) {
return (
<>
{before}
<KeyBadge>`</KeyBadge>
<KeyBadge config={undefined}>`</KeyBadge>
{after}
</>
);
})()}
</p>
<p className="text-type-secondary text-sm mt-2">
<button
type="button"
onClick={() => {
navigate("/settings?category=settings-preferences");
}}
className="text-type-link hover:text-type-linkHover"
>
{t("global.keyboardShortcuts.editInSettings")}
</button>
</p>
</div>
<div className="space-y-6 max-h-[60vh] overflow-y-auto">
@ -179,24 +259,28 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) {
{group.title}
</h3>
<div className="space-y-2">
{group.shortcuts.map((shortcut) => (
<div
key={shortcut.key}
className="flex items-center justify-between py-1"
>
<div className="flex items-center gap-3">
<KeyBadge>{shortcut.key}</KeyBadge>
<span className="text-type-secondary">
{shortcut.description}
</span>
{group.shortcuts
.filter((shortcut) => shortcut.key) // Only show shortcuts that have a key configured
.map((shortcut) => (
<div
key={shortcut.key}
className="flex items-center justify-between py-1"
>
<div className="flex items-center gap-3">
<KeyBadge config={shortcut.config}>
{shortcut.key}
</KeyBadge>
<span className="text-type-secondary">
{shortcut.description}
</span>
</div>
{shortcut.condition && (
<span className="text-xs text-gray-400 italic">
{shortcut.condition}
</span>
)}
</div>
{shortcut.condition && (
<span className="text-xs text-gray-400 italic">
{shortcut.condition}
</span>
)}
</div>
))}
))}
</div>
</div>
))}

View file

@ -21,9 +21,12 @@ export function useModal(id: string) {
};
}
export function ModalCard(props: { children?: ReactNode }) {
export function ModalCard(props: {
children?: ReactNode;
className?: ReactNode;
}) {
return (
<div className="w-full max-w-[30rem] m-4">
<div className={classNames("w-full max-w-[30rem] m-4", props.className)}>
<div className="w-full bg-modal-background rounded-xl p-8 pointer-events-auto">
{props.children}
</div>

View file

@ -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 (
<FancyModal id={id} title={t("home.support.title")} size="md">
<div className="space-y-4">
<p className="text-type-secondary">{t("home.support.explanation")}</p>
<p className="text-type-secondary">
{t("home.support.explanation2")}{" "}
<MwLink url="https://discord.gg/7z6znYgrTG">
{t("home.support.discord")}
</MwLink>
</p>
<div className="space-y-3">
<span className="text-center flex justify-center whitespace-nowrap items-center">
<Button
theme="purple"
onClick={() =>
window.open("https://rentry.co/nnqtas3e", "_blank")
}
>
{t("home.support.donate")}
</Button>
</span>
</div>
<div className="text-xs text-type-dimmed text-center">
{t("home.support.thankYou")}
</div>
</div>
</FancyModal>
);
}

View file

@ -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<number>(0);
const logoRef = useRef<HTMLDivElement>(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 (
<div className="relative h-full flex flex-col">
{/* Share notification popup */}
@ -290,20 +327,40 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
{/* Genres */}
{data.genres && data.genres.length > 0 && (
<div className="flex flex-wrap gap-2 items-center">
{data.genres.map((genre, index) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80 transition-all duration-300 hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: `${((data.genres?.length ?? 0) - 1 - index) * 60}ms`,
transform: "scale(0)",
opacity: 0,
}}
<div className="flex justify-between items-center">
<div className="flex flex-wrap gap-2 items-center">
{data.genres.map((genre, index) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80 transition-all duration-300 hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: `${((data.genres?.length ?? 0) - 1 - index) * 60}ms`,
transform: "scale(0)",
opacity: 0,
}}
>
{genre.name}
</span>
))}
</div>
{/* Movie Watch Toggle Button - Only show for movies and not in minimal modal */}
{data.type === "movie" && !minimal && (
<button
type="button"
onClick={toggleMovieWatchStatus}
className="p-1.5 bg-dropdown-background hover:bg-dropdown-hoverBackground transition-colors rounded-full ml-2"
title={
isMovieWatched
? t("player.menus.episodes.markAsUnwatched")
: t("player.menus.episodes.markAsWatched")
}
>
{genre.name}
</span>
))}
<Icon
icon={isMovieWatched ? Icons.EYE_SLASH : Icons.EYE}
className="h-5 w-5 text-white"
/>
</button>
)}
</div>
)}

View file

@ -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<any>(null);

View file

@ -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<string | null>(null);
const [chosenLanguage, setChosenLanguage] = useState<string | null>(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 }) {
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={452}>
<Menu.CardWithScrollable>
<CaptionsView id={id} backLink />
<CaptionsView
id={id}
backLink
onChooseLanguage={setChosenLanguage}
/>
</Menu.CardWithScrollable>
</OverlayPage>
{/* This is used by the captions shortcut in bottomControls of player */}
<OverlayPage id={id} path="/captionsOverlay" width={343} height={452}>
<Menu.CardWithScrollable>
<CaptionsView id={id} />
<CaptionsView id={id} onChooseLanguage={setChosenLanguage} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage
id={id}
path="/captionsOverlay/languagesOverlay"
width={343}
height={452}
>
<Menu.CardWithScrollable>
{chosenLanguage && (
<LanguageSubtitlesView
id={id}
language={chosenLanguage}
overlayBackLink
/>
)}
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/captions/settings" width={343} height={452}>
@ -106,6 +130,18 @@ function SettingsOverlay({ id }: { id: string }) {
<TranscriptView id={id} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage
id={id}
path="/captions/languages"
width={343}
height={452}
>
<Menu.CardWithScrollable>
{chosenLanguage && (
<LanguageSubtitlesView id={id} language={chosenLanguage} />
)}
</Menu.CardWithScrollable>
</OverlayPage>
<DownloadRoutes id={id} />
<OverlayPage id={id} path="/watchparty" width={343} height={455}>
<Menu.CardWithScrollable>

View file

@ -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: {
<FlagIcon langCode={props.countryCode} />
</span>
) : null}
<span>{props.children}</span>
<span
className={
props.flag || props.subtitleUrl || props.subtitleSource
? "truncate max-w-[100px]"
: ""
}
>
{props.children}
</span>
{props.subtitleType && (
<span className="ml-2 px-2 py-0.5 rounded bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-xs font-semibold">
{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<string, typeof allCaptions> = {};
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<HTMLDivElement>) {
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 (
<CaptionOption
key={v.id}
countryCode={v.language}
selected={v.id === selectedCaptionId}
loading={v.id === currentlyDownloading && downloadReq.loading}
error={
v.id === currentlyDownloading && downloadReq.error
? downloadReq.error.toString()
: undefined
}
onClick={() => startDownload(v.id)}
onDoubleClick={handleDoubleClick}
flag
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}
subtitleEncoding={v.encoding}
isHearingImpaired={v.isHearingImpaired}
>
{v.languageName}
</CaptionOption>
);
};
return (
<>
<div>
@ -534,6 +533,24 @@ export function CaptionsView({
{t("player.menus.subtitles.offChoice")}
</CaptionOption>
{/* Automatically select subtitles option */}
{captions.length > 0 && (
<CaptionOption
onClick={() => handleRandomSelect()}
selected={!!selectedCaptionId}
loading={isRandomSelecting}
>
<div className="flex flex-col">
{t("player.menus.subtitles.autoSelectChoice")}
{selectedCaptionId && (
<span className="text-video-context-type-secondary text-xs">
{t("player.menus.subtitles.autoSelectDifferentChoice")}
</span>
)}
</div>
</CaptionOption>
)}
{/* Custom upload option */}
<CustomCaptionOption />
@ -552,16 +569,11 @@ export function CaptionsView({
<div className="h-1" />
{/* Search input */}
{(sourceCaptions.length || externalCaptions.length) > 0 && (
<Input value={searchQuery} onInput={setSearchQuery} />
)}
{/* No subtitles available message */}
{!isLoadingExternalSubtitles &&
sourceCaptions.length === 0 &&
externalCaptions.length === 0 && (
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 text-center">
<div className="p-4 pb-4 rounded-xl bg-video-context-light bg-opacity-10 text-center">
<div className="text-video-context-type-secondary">
{t("player.menus.subtitles.empty")}
</div>
@ -569,7 +581,7 @@ export function CaptionsView({
)}
{/* Loading external subtitles */}
{isLoadingExternalSubtitles && externalCaptions.length === 0 && (
{isLoadingExternalSubtitles && (
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 text-center">
<div className="text-video-context-type-secondary">
{t("player.menus.subtitles.loadingExternal")}
@ -577,45 +589,30 @@ export function CaptionsView({
</div>
)}
{/* Source Subtitles Section */}
{sourceCaptions.length > 0 && (
<>
<div className="text-sm font-semibold text-video-context-type-secondary pt-2 mb-2">
{t("player.menus.subtitles.SourceChoice")}
</div>
{sourceList.length > 0 ? (
sourceList.map(renderSubtitleOption)
) : (
<div className="text-center text-video-context-type-secondary py-2">
{t("player.menus.subtitles.notFound")}
</div>
)}
</>
)}
{/* External Subtitles Section */}
{externalCaptions.length > 0 && (
<>
<div className="text-sm font-semibold text-video-context-type-secondary pt-2 mb-2">
{t("player.menus.subtitles.OpenSubtitlesChoice")}
</div>
{externalList.length > 0 ? (
externalList.map(renderSubtitleOption)
) : (
<div className="text-center text-video-context-type-secondary py-2">
{t("player.menus.subtitles.notFound")}
</div>
)}
</>
)}
{/* Loading indicator for external subtitles while source exists */}
{isLoadingExternalSubtitles && sourceCaptions.length > 0 && (
<div className="text-center text-video-context-type-secondary py-4 mt-2">
{t("player.menus.subtitles.loadingExternal") ||
"Loading external subtitles..."}
</div>
)}
{/* Language selection */}
{groupedCaptions.length > 0 &&
groupedCaptions.map(
({ language, languageName, captions: captionsForLang }) => (
<Menu.ChevronLink
key={language}
selected={selectedLanguage === language}
rightText={captionsForLang.length.toString()}
onClick={() => {
onChooseLanguage?.(language);
router.navigate(
backLink
? "/captions/languages"
: "/captionsOverlay/languagesOverlay",
);
}}
>
<span className="flex items-center">
<FlagIcon langCode={language} />
<span className="ml-3">{languageName}</span>
</span>
</Menu.ChevronLink>
),
)}
</Menu.ScrollToActiveSection>
</FileDropHandler>
</>

View file

@ -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 (
<CaptionOption
key={v.id}
countryCode={v.language}
selected={v.id === selectedCaptionId}
loading={v.id === currentlyDownloading && downloadReq.loading}
error={
v.id === currentlyDownloading && downloadReq.error
? downloadReq.error.toString()
: undefined
}
onClick={() => 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}
</CaptionOption>
);
};
const languageName =
getPrettyLanguageNameFromLocale(language) ||
t("player.menus.subtitles.unknownLanguage");
return (
<>
<Menu.BackLink
onClick={() =>
router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions")
}
rightSide={
languageCaptions.length > 0 && (
<button
type="button"
onClick={handleRandomSelect}
className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10"
title="Pick random subtitle"
>
<Icon icon={Icons.REPEAT} className="text-lg" />
</button>
)
}
>
<span className="flex items-center">
<FlagIcon langCode={language} />
<span className="ml-3">{languageName}</span>
</span>
</Menu.BackLink>
<Menu.ScrollToActiveSection
className="!pt-1 mt-2 pb-3"
loaded={scrollTrigger > 0}
>
{languageCaptions.length > 0 ? (
languageCaptions.map(renderSubtitleOption)
) : (
<div className="text-center text-video-context-type-secondary py-2">
{t("player.menus.subtitles.notFound")}
</div>
)}
{/* Loading indicator */}
{isLoadingExternalSubtitles && languageCaptions.length === 0 && (
<div className="text-center text-video-context-type-secondary py-4 mt-2">
{t("player.menus.subtitles.loadingExternal") ||
"Loading external subtitles..."}
</div>
)}
</Menu.ScrollToActiveSection>
</>
);
}

View file

@ -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 = (
<div className="flex items-center text-video-scraping-noresult">
<div className="w-4 h-4 rounded-full border-2 border-current bg-current flex items-center justify-center">
<div className="w-2 h-0.5 bg-background-main rounded-full" />
</div>
</div>
);
}
return (
<SelectableLink
loading={loading}
error={errored}
error={errored && !notFound}
onClick={run}
selected={props.embedId === currentEmbedId}
rightSide={rightSide}
>
<span className="flex flex-col">
<span>{embedName}</span>
@ -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,
]);

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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]);
}

View file

@ -124,6 +124,7 @@ export function useEmbedScraping(
run,
loading: request.loading,
errored: !!request.error,
notFound: request.error instanceof NotFoundError,
};
}

View file

@ -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 = <Chevron>{props.rightText}</Chevron>;
const rightContent = (
<span className="text-white flex items-center font-medium">
{props.selected ? (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
) : (
props.rightText
)}
<Icon className="text-xl ml-1 -mr-1.5" icon={Icons.CHEVRON_RIGHT} />
</span>
);
return (
<Link
onClick={props.onClick}
@ -169,23 +183,26 @@ export function SelectableLink(props: {
disabled?: boolean;
error?: ReactNode;
box?: boolean;
rightSide?: ReactNode;
}) {
let rightContent;
if (props.selected) {
rightContent = (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
);
let rightContent = props.rightSide; // Use custom rightSide if provided
if (!rightContent) {
if (props.selected) {
rightContent = (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
);
}
if (props.error)
rightContent = (
<span className="flex items-center text-video-context-error">
<Icon className="ml-2" icon={Icons.WARNING} />
</span>
);
if (props.loading) rightContent = <Spinner className="text-lg" />; // should override selected and error
}
if (props.error)
rightContent = (
<span className="flex items-center text-video-context-error">
<Icon className="ml-2" icon={Icons.WARNING} />
</span>
);
if (props.loading) rightContent = <Spinner className="text-lg" />; // should override selected and error
return (
<Link

View file

@ -52,10 +52,11 @@ export function ScrollToActiveSection(props: {
const activeYPos = activeLinkRect.top - boxRect.top;
scrollingContainer.current?.scrollTo(
0,
activeYPos - boxRect.height / 2 + activeLinkRect.height / 2,
);
scrollingContainer.current?.scrollTo({
top: activeYPos - boxRect.height / 2 + activeLinkRect.height / 2,
left: 0,
behavior: "smooth",
});
}, [props.loaded]);
return (

View file

@ -1,14 +1,23 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getMetaFromId } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { useCaptions } from "@/components/player/hooks/useCaptions";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { useVolume } from "@/components/player/hooks/useVolume";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress";
import { useSubtitleStore } from "@/stores/subtitles";
import { useEmpheralVolumeStore } from "@/stores/volume";
import { useWatchPartyStore } from "@/stores/watchParty";
import {
LOCKED_SHORTCUTS,
ShortcutId,
matchesShortcut,
} from "@/utils/keyboardShortcuts";
export function KeyboardEvents() {
const router = useOverlayRouter("");
@ -20,8 +29,19 @@ export function KeyboardEvents() {
const duration = usePlayerStore((s) => 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<ReturnType<typeof setTimeout> | 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");

View file

@ -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,
],
);

View file

@ -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,

View file

@ -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,

View file

@ -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);
}

View file

@ -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,
],
);

View file

@ -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,

View file

@ -155,8 +155,21 @@ function MigrationRunner() {
function TheRouter(props: { children: ReactNode }) {
const normalRouter = conf().NORMAL_ROUTER;
if (normalRouter) return <BrowserRouter>{props.children}</BrowserRouter>;
return <HashRouter>{props.children}</HashRouter>;
if (normalRouter)
return (
<BrowserRouter
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
{props.children}
</BrowserRouter>
);
return (
<HashRouter
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
{props.children}
</HashRouter>
);
}
// Checks if the extension is installed

View file

@ -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<boolean>(false);
@ -171,6 +172,8 @@ export function HomePage() {
/>
)}
{conf().SHOW_SUPPORT_BAR ? <SupportBar /> : null}
{conf().SHOW_AD ? <AdsPart /> : null}
</div>

View file

@ -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 (
<PlayerPart backUrl={backUrl} onMetaChange={metaChange}>
{status !== playerStatus.PLAYING ? <BlurEllipsis /> : null}
{status === playerStatus.IDLE ? (
<MetaPart onGetMeta={handleMetaReceived} />
) : null}

View file

@ -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<HTMLInputElement>(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 (
<WideContainer ultraWide classNames="overflow-visible">
@ -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}

View file

@ -47,8 +47,6 @@ export function AdminPage() {
setEmbedOrder={embedOrderState.setEmbedOrder}
enableEmbedOrder={embedOrderState.enableEmbedOrder}
setEnableEmbedOrder={embedOrderState.setEnableEmbedOrder}
disabledEmbeds={embedOrderState.disabledEmbeds}
setDisabledEmbeds={embedOrderState.setDisabledEmbeds}
/>
{/* <ProgressCleanupPart /> */}
</ThinContainer>

View file

@ -305,9 +305,9 @@ export function MediaCarousel({
return (
<div>
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
<div className="flex flex-col">
<div className="flex flex-col pl-2 lg:pl-[68px]">
<div className="flex items-center gap-4">
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-0 text-balance">
{sectionTitle}
</h2>
{showRecommendations &&
@ -398,7 +398,7 @@ export function MediaCarousel({
<Link
to={generatedMoreLink}
onClick={handleMoreClick}
className="flex px-5 items-center hover:text-type-link transition-colors"
className="flex items-center hover:text-type-link transition-colors"
>
<span className="text-sm">{t("discover.carousel.more")}</span>
<Icon className="text-sm ml-1" icon={Icons.ARROW_RIGHT} />
@ -477,7 +477,7 @@ export function MediaCarousel({
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
<div className="lg:w-12" />
{media.length > 0
? media.map((item) => (
@ -533,7 +533,7 @@ export function MediaCarousel({
<MoreCard link={generatedMoreLink} />
)}
<div className="md:w-12" />
<div className="lg:w-12" />
</div>
{!isMobile && (

View file

@ -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);

View file

@ -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);
}

View file

@ -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() {
<div className="hidden md:flex w-full flex-col md:flex-row gap-3 pb-6">
<Card
onClick={() => navigate("/onboarding/extension")}
className="md:w-1/3"
className={classNames(
conf().HIDE_PROXY_ONBOARDING ? "md:w-1/2" : "md:w-1/3",
)}
>
<CardContent
colorClass="!text-onboarding-best"
@ -192,26 +195,30 @@ export function OnboardingPage() {
</Link>
</CardContent>
</Card>
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
<VerticalLine className="items-end" />
<span className="text-xs uppercase font-bold">
{t("onboarding.start.options.or")}
</span>
<VerticalLine />
</div>
<Card
onClick={() => navigate("/onboarding/proxy")}
className="md:w-1/3"
>
<CardContent
colorClass="!text-onboarding-good"
title={t("onboarding.start.options.proxy.title")}
subtitle={t("onboarding.start.options.proxy.quality")}
description={t("onboarding.start.options.proxy.description")}
>
<Link>{t("onboarding.start.options.proxy.action")}</Link>
</CardContent>
</Card>
{conf().HIDE_PROXY_ONBOARDING ? null : (
<>
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
<VerticalLine className="items-end" />
<span className="text-xs uppercase font-bold">
{t("onboarding.start.options.or")}
</span>
<VerticalLine />
</div>
<Card
onClick={() => navigate("/onboarding/proxy")}
className="md:w-1/3"
>
<CardContent
colorClass="!text-onboarding-good"
title={t("onboarding.start.options.proxy.title")}
subtitle={t("onboarding.start.options.proxy.quality")}
description={t("onboarding.start.options.proxy.description")}
>
<Link>{t("onboarding.start.options.proxy.action")}</Link>
</CardContent>
</Card>
</>
)}
{noProxies ? null : (
<>
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
@ -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",
)}
>
<CardContent
colorClass="!text-onboarding-bad"
@ -255,17 +264,19 @@ export function OnboardingPage() {
description={t("onboarding.start.options.extension.description")}
/>
</Card>
<Card
onClick={() => navigate("/onboarding/proxy")}
className="md:w-1/3"
>
<MiniCardContent
colorClass="!text-onboarding-good"
title={t("onboarding.start.options.proxy.title")}
subtitle={t("onboarding.start.options.proxy.quality")}
description={t("onboarding.start.options.proxy.description")}
/>
</Card>
{conf().HIDE_PROXY_ONBOARDING ? null : (
<Card
onClick={() => navigate("/onboarding/proxy")}
className="md:w-1/3"
>
<MiniCardContent
colorClass="!text-onboarding-good"
title={t("onboarding.start.options.proxy.title")}
subtitle={t("onboarding.start.options.proxy.quality")}
description={t("onboarding.start.options.proxy.description")}
/>
</Card>
)}
{noProxies ? null : (
<Card
onClick={
@ -285,6 +296,11 @@ export function OnboardingPage() {
)}
</div>
{(conf().ALLOW_FEBBOX_KEY || conf().ALLOW_DEBRID_KEY) === true && (
<Heading3 className="text-white font-bold mb-3 mt-6">
{t("onboarding.start.options.addons.title")}
</Heading3>
)}
<div className="mt-6">
<FebboxSetup
febboxKey={usePreferencesStore((s) => s.febboxKey)}

View file

@ -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 (
<>
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
@ -133,40 +139,72 @@ function ChromeExtensionPage(props: ExtensionPageProps) {
<Paragraph className="max-w-[320px] mb-4">
{t("onboarding.extension.explainer")}
</Paragraph>
{installLink ? (
<Link href={installLink} target="_blank" className="mb-12">
{t("onboarding.extension.linkChrome")}
</Link>
) : null}
<ExtensionStatus status={props.status} loading={props.loading} />
<Link
href="https://github.com/p-stream/extension"
target="_blank"
className="pt-4 !text-type-dimmed"
>
See extension source code
</Link>
</>
);
}
{/* Main extension icons */}
<div className="mb-4 flex flex-col md:flex-row md:space-x-8 space-y-4 md:space-y-0 justify-center items-center">
{installChromeLink &&
(browser === "chrome" || browser === "unknown") ? (
<Link
href={installChromeLink}
target="_blank"
className="flex flex-col items-center space-y-2 p-4 rounded-lg hover:bg-type-surface-hover transition-colors"
>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"
width="100px"
height="100px"
fill="currentColor"
>
<path d="M64 320C64 273.4 76.5 229.6 98.3 191.1L208.1 382.3C230 421.5 271.9 448 320 448C334.3 448 347.1 445.7 360.8 441.4L284.5 573.6C159.9 556.3 64 449.3 64 320zM429.1 385.6C441.4 366.4 448 343.1 448 320C448 281.8 431.2 247.5 404.7 224L557.4 224C569.4 253.6 576 286.1 576 320C576 461.4 461.4 575.1 320 576L429.1 385.6zM541.8 192L320 192C257.1 192 206.3 236.1 194.5 294.7L118.2 162.5C165 102.5 238 64 320 64C414.8 64 497.5 115.5 541.8 192zM408 320C408 368.6 368.6 408 320 408C271.4 408 232 368.6 232 320C232 271.4 271.4 232 320 232C368.6 232 408 271.4 408 320z" />
</svg>
</span>
<span className="font-medium text-center">
{t("onboarding.extension.linkChrome")}
</span>
</Link>
) : null}
{installFirefoxLink &&
(browser === "firefox" || browser === "unknown") ? (
<Link
href={installFirefoxLink}
target="_blank"
className="flex flex-col items-center space-y-2 p-4 rounded-lg hover:bg-type-surface-hover transition-colors"
>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"
width="100px"
height="100px"
fill="currentColor"
>
<path d="M567.5 305.5C567.4 303.9 567.3 302.4 567.3 300.8L567.3 300.7L566.9 296L566.9 295.9C565.6 282 563.2 268.2 559.6 254.7C559.6 254.6 559.6 254.6 559.5 254.5L558.4 250.5C558.3 250.3 558.3 250 558.2 249.9C557.8 248.7 557.5 247.4 557.1 246.2C557 246 557 245.6 556.9 245.4C556.5 244.2 556.2 243 555.8 241.9C555.7 241.5 555.6 241.3 555.4 240.9C555 239.7 554.7 238.6 554.2 237.4L553.8 236.3C553.4 235.2 553 234 552.6 232.9C552.5 232.6 552.4 232.2 552.2 231.9C551.7 230.8 551.4 229.6 550.9 228.5C550.8 228.3 550.7 227.9 550.5 227.7C550 226.5 549.5 225.4 549.1 224.2C549.1 224.1 549 224 549 223.8C547.4 220 545.8 216.1 544 212.4L543.6 211.7C543.1 210.7 542.8 209.9 542.3 209.1C542.1 208.6 541.8 208 541.6 207.5C541.2 206.7 540.8 205.9 540.4 205.1C540 204.5 539.8 203.9 539.4 203.3C539 202.7 538.6 201.9 538.2 201C537.8 200.4 537.5 199.7 537.1 199.1C536.7 198.5 536.3 197.7 535.9 196.9C535.5 196.2 535.1 195.5 534.7 194.9C534.3 194.2 533.9 193.6 533.5 192.9C533.1 192.2 532.7 191.6 532.3 190.9C531.9 190.2 531.5 189.6 531.1 189C530.7 188.4 530.3 187.6 529.8 186.8C529.4 186.2 529 185.6 528.6 185L527.2 182.9C526.8 182.3 526.4 181.7 526 181.1C525.5 180.4 524.9 179.5 524.4 178.8C524 178.3 523.7 177.7 523.3 177.2L521.5 174.7C521.1 174.2 520.9 173.9 520.5 173.4C519.5 172.1 518.7 170.9 517.7 169.7C510.5 160.3 502.7 151.4 494.2 143.1C488.5 137.1 482.4 131.6 475.9 126.4C471.9 122.9 467.7 119.7 463.4 116.6C455.7 110.8 447.4 105.8 438.8 101.5C436.4 100.2 434 99 431.6 97.8C413.9 89.2 395.3 82.6 376.2 78.2C374.3 77.8 372.4 77.4 370.6 77L370.5 77C369.5 76.9 368.7 76.6 367.7 76.5C355.2 74.1 342.5 72.8 329.7 72.5L319.1 72.5C303.8 72.7 288.6 74.4 273.6 77.5C240 84.6 210.4 98.7 190.7 116.5C189.6 117.5 188.8 118.2 188.3 118.7L187.8 119.2L187.9 119.2C187.9 119.2 188 119.2 188 119.2C188 119.2 188 119.1 188 119.1L187.9 119.2C188 119.1 188 119.1 188.1 119.1C202.7 110.3 223 103.1 237.5 99.5L243.4 98.1C243.8 98 244.2 98 244.6 97.9C246.3 97.5 248 97.2 249.8 96.8C250 96.8 250.4 96.7 250.6 96.7C314.8 85 383.2 104.2 430.8 149.7C441.1 159.5 450.1 170.5 457.7 182.5C488.1 231.7 485.2 293.6 461.5 330.1C427.1 383.1 350.1 401.4 302.5 354.9C286.5 339.4 277.3 318.2 276.9 295.9C276.7 285.2 278.9 274.7 283.1 264.9C284.8 261.1 296.2 239.2 301.3 240.3C288.2 237.5 263.8 242.9 246.6 268.5C231.2 291.4 232.1 326.7 241.6 351.8C235.6 339.4 231.5 326.2 229.5 312.6C217.3 230 272.8 159.6 323.8 142.1C296.3 118.1 227.3 119.8 176.1 157.5C146.2 179.5 124.9 210.7 113.6 247.9C115.3 227 123.2 195.8 139.4 164C122.2 172.9 100.4 201 89.6 226.9C74 264.3 68.6 309.1 73.5 351.7C73.9 354.9 74.2 358.1 74.6 361.3C94.5 478.4 196.6 567.7 319.4 567.7C456.5 567.7 567.7 456.5 567.7 319.3C567.6 314.8 567.5 310.2 567.2 305.8z" />
</svg>
</span>
<span className="font-medium text-center">
{t("onboarding.extension.linkFirefox")}
</span>
</Link>
) : null}
</div>
function FirefoxExtensionPage(props: ExtensionPageProps) {
const { t } = useTranslation();
const installLink = conf().ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK;
return (
<>
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("onboarding.extension.title")}
</Heading2>
<Paragraph className="max-w-[320px] mb-4">
{t("onboarding.extension.explainer")}
</Paragraph>
{installLink ? (
<Link href={installLink} target="_blank" className="mb-12">
{t("onboarding.extension.linkFirefox")}
</Link>
) : null}
{/* Secondary userscript option */}
<div className="mb-6 text-left">
<div className="flex flex-col items-center space-y-1">
<Link
href="https://raw.githubusercontent.com/p-stream/Userscript/main/p-stream.user.js"
target="_blank"
className="text-sm"
>
{t("onboarding.extension.linkUserscript")}
</Link>
<span className="text-type-dimmed text-xs">
{t("onboarding.extension.userscriptNote")}
</span>
</div>
</div>
<ExtensionStatus status={props.status} loading={props.loading} showHelp />
<Link
@ -197,45 +235,6 @@ function IosExtensionPage(_props: ExtensionPageProps) {
);
}
function UnknownExtensionPage(props: ExtensionPageProps) {
const { t } = useTranslation();
const installChromeLink = conf().ONBOARDING_CHROME_EXTENSION_INSTALL_LINK;
const installFirefoxLink = conf().ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK;
return (
<>
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("onboarding.extension.title")}
</Heading2>
<Paragraph className="max-w-[320px] mb-4">
{t("onboarding.extension.explainer")}
</Paragraph>
<div className="mb-4">
{installChromeLink ? (
<Link href={installChromeLink} target="_blank">
{t("onboarding.extension.linkChrome")}
</Link>
) : null}
</div>
<div className="mb-12">
{installFirefoxLink ? (
<Link href={installFirefoxLink} target="_blank">
{t("onboarding.extension.linkFirefox")}
</Link>
) : null}
</div>
<ExtensionStatus status={props.status} loading={props.loading} showHelp />
<Link
href="https://github.com/p-stream/extension"
target="_blank"
className="pt-4 !text-type-dimmed"
>
See extension source code
</Link>
</>
);
}
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];

View file

@ -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");
});
}, []);

View file

@ -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 (
<div className="space-y-6">
@ -91,10 +78,9 @@ export function EmbedOrderPart({
{enableEmbedOrder && (
<div className="w-full flex flex-col gap-4">
<SortableListWithToggles
<SortableList
items={embedItems}
setItems={(items) => setEmbedOrder(items.map((item) => item.id))}
onToggle={handleEmbedToggle}
/>
<Button
className="max-w-[25rem]"

View file

@ -53,10 +53,8 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
forceCompactEpisodeView: store.forceCompactEpisodeView,
sourceOrder: store.sourceOrder,
enableSourceOrder: store.enableSourceOrder,
disabledSources: store.disabledSources,
embedOrder: store.embedOrder,
enableEmbedOrder: store.enableEmbedOrder,
disabledEmbeds: store.disabledEmbeds,
proxyTmdb: store.proxyTmdb,
febboxKey: store.febboxKey,
debridToken: store.debridToken,

View file

@ -1,6 +1,7 @@
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { isExtensionActiveCached } from "@/backend/extension/messaging";
import { Button } from "@/components/buttons/Button";
import { Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill";
@ -8,9 +9,19 @@ import { Navigation } from "@/components/layout/Navigation";
import { Title } from "@/components/text/Title";
import { Paragraph } from "@/components/utils/Text";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { conf } from "@/setup/config";
import { useOnboardingStore } from "@/stores/onboarding";
import { usePreferencesStore } from "@/stores/preferences";
export function NotFoundPart() {
const { t } = useTranslation();
const setOnboardingCompleted = useOnboardingStore((s) => s.setCompleted);
const febboxKey = usePreferencesStore((s) => s.febboxKey);
function handleOnboarding() {
setOnboardingCompleted(false);
window.location.reload();
}
return (
<div className="relative flex flex-1 flex-col">
@ -42,6 +53,21 @@ export function NotFoundPart() {
{t("notFound.reloadButton")}
</Button>
</div>
{(!isExtensionActiveCached() || !febboxKey) &&
conf().HAS_ONBOARDING ? (
<div className="flex flex-col max-w-md gap-3 items-center py-3">
<Paragraph>
{t("player.scraping.notFound.onboarding")}
</Paragraph>
<Button
onClick={() => handleOnboarding()}
theme="purple"
className="w-fit"
>
{t("player.scraping.notFound.onboardingButton")}
</Button>
</div>
) : null}
</ErrorContainer>
</ErrorLayout>
</div>

View file

@ -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<SortOption>(() => {
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<string, MediaItem[]> = {};
@ -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({
<UserIcon icon={icon} className="w-full h-full" />
</span>
}
className="ml-4 md:ml-12 mt-2 -mb-5"
className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]"
>
<div className="mr-4 md:mr-8 flex items-center gap-2">
<div className="mr-4 lg:mr-[88px] flex items-center gap-2">
{editing && section.group && (
<EditButtonWithText
editing={editing}
@ -320,6 +334,65 @@ export function BookmarksCarousel({
/>
</div>
</SectionHeading>
{editing && (
<div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::bookmarksSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`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 }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${section.group}`}
@ -329,7 +402,7 @@ export function BookmarksCarousel({
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
<div className="lg:w-12" />
{section.items
.slice(0, MAX_ITEMS_PER_SECTION)
@ -357,7 +430,7 @@ export function BookmarksCarousel({
<MoreBookmarksCard />
)}
<div className="md:w-12" />
<div className="lg:w-12" />
</div>
{!isMobile && (
@ -375,9 +448,9 @@ export function BookmarksCarousel({
<SectionHeading
title={t("home.bookmarks.sectionTitle")}
icon={Icons.BOOKMARK}
className="ml-4 md:ml-12 mt-2 -mb-5"
className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]"
>
<div className="mr-4 md:mr-8 flex items-center gap-2">
<div className="mr-4 lg:mr-[88px] flex items-center gap-2">
<EditButton
editing={editing}
onEdit={setEditing}
@ -385,6 +458,61 @@ export function BookmarksCarousel({
/>
</div>
</SectionHeading>
{editing && (
<div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => setSortBy(item.id as SortOption)}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`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 }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
@ -394,7 +522,7 @@ export function BookmarksCarousel({
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
<div className="lg:w-12" />
{section.items.length > 0
? section.items
@ -428,7 +556,7 @@ export function BookmarksCarousel({
<MoreBookmarksCard />
)}
<div className="md:w-12" />
<div className="lg:w-12" />
</div>
{!isMobile && (

View file

@ -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<SortOption>(() => {
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<string, MediaItem[]> = {};
@ -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({
/>
</div>
</SectionHeading>
{editing && (
<div className="mb-6 -mt-4">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::bookmarksSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`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 }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<MediaGrid>
{section.items.map((v) => (
<div
@ -273,6 +346,61 @@ export function BookmarksPart({
/>
</div>
</SectionHeading>
{editing && (
<div className="mb-6 -mt-4">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => setSortBy(item.id as SortOption)}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`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 }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<MediaGrid ref={gridRef}>
{section.items.map((v) => (
<div

View file

@ -6,7 +6,7 @@ import { SearchBarInput } from "@/components/form/SearchBar";
import { ThinContainer } from "@/components/layout/ThinContainer";
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
import { HeroTitle } from "@/components/text/HeroTitle";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useIsIOS, useIsMobile, useIsPWA } from "@/hooks/useIsMobile";
import { useIsTV } from "@/hooks/useIsTv";
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
import { useSearchQuery } from "@/hooks/useSearchQuery";
@ -55,11 +55,17 @@ export function HeroPart({
[setIsSticky],
);
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)
// 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;
const time = getTimeOfDay(new Date());
const title = randomT(`home.titles.${time}`);
@ -88,6 +94,7 @@ export function HeroPart({
paddingTop: `${topOffset}px`,
}}
onFixedToggle={stickStateChanged}
scrollElement="window"
>
<SearchBarInput
ref={inputRef}

View file

@ -0,0 +1,131 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { SettingsCard } from "@/components/layout/SettingsCard";
import { MwLink } from "@/components/text/Link";
import { Heading3 } from "@/components/utils/Text";
import { conf } from "@/setup/config";
import { useOverlayStack } from "@/stores/interface/overlayStack";
function getCookie(name: string): string | null {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i += 1) {
const cookie = cookies[i].trim();
if (cookie.startsWith(`${name}=`)) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function setCookie(name: string, value: string, expiryDays: number): void {
const date = new Date();
date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/`;
}
export function SupportBar() {
const { t } = useTranslation();
const { showModal } = useOverlayStack();
const [isDescriptionDismissed, setIsDescriptionDismissed] = useState(() => {
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 (
<div className="w-full px-4 py-2">
<div className="flex flex-col items-center space-y-2">
<SettingsCard className="max-w-md relative group">
<button
onClick={toggleDescription}
type="button"
className="absolute z-20 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label={
isDescriptionDismissed ? "Show description" : "Hide description"
}
>
<Icon
className="text-s font-semibold text-type-secondary"
icon={
isDescriptionDismissed ? Icons.CHEVRON_UP : Icons.CHEVRON_DOWN
}
/>
</button>
<div
className={`transition-all duration-300 ${
isDescriptionDismissed
? "max-h-0 opacity-0 pb-0"
: "max-h-36 opacity-100 pb-0"
}`}
>
<Heading3 className="transition-opacity duration-300">
{t("home.support.title")}
</Heading3>
<p className="text-type-secondary max-w-md pb-4 transition-opacity duration-300">
{t("home.support.description")}
</p>
</div>
<div className="flex flex-grow items-center text-sm text-type-dimmed w-full max-w-md pb-4">
<span className="text-left">
{t("home.support.label", {
current: current.toLocaleString(),
goal: goal.toLocaleString(),
})}
</span>
<span className="ml-auto text-right flex-shrink-0 whitespace-nowrap">
{percentage.toFixed(1)}% {t("home.support.complete")}
</span>
</div>
<div className="w-full max-w-md">
<div className="relative w-full h-2 bg-progress-background bg-opacity-25 rounded-full">
{/* Progress bar */}
<div
className="absolute top-0 left-0 h-full rounded-full bg-progress-filled transition-all duration-300"
style={{
width: `${percentage}%`,
}}
/>
</div>
</div>
<div className="flex flex-grow items-center text-sm text-type-dimmed w-full max-w-md pt-4">
<span className="text-left">
<button
type="button"
onClick={openSupportModal}
className="group mt-1 cursor-pointer font-bold text-type-link hover:text-type-linkHover active:scale-95"
>
{t("home.support.moreInfo")}
</button>
</span>
<span className="ml-auto text-right flex-shrink-0 whitespace-nowrap">
<MwLink url="https://rentry.co/nnqtas3e">
{t("home.support.donate")}
</MwLink>
</span>
</div>
</SettingsCard>
</div>
</div>
);
}

View file

@ -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<SortOption>(() => {
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({
<SectionHeading
title={t("home.continueWatching.sectionTitle")}
icon={Icons.CLOCK}
className="ml-2 md:ml-8 mt-2 -mb-5"
className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]"
>
<div className="mr-6">
<div className="mr-4 lg:mr-[88px] flex items-center gap-2">
<EditButton
editing={editing}
onEdit={setEditing}
@ -101,6 +134,65 @@ export function WatchingCarousel({
/>
</div>
</SectionHeading>
{editing && (
<div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::watchingSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`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 }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
@ -110,7 +202,7 @@ export function WatchingCarousel({
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
<div className="lg:w-12" />
{items.length > 0
? items.map((media) => (
@ -136,7 +228,7 @@ export function WatchingCarousel({
/>
))}
<div className="md:w-12" />
<div className="lg:w-12" />
</div>
{!isMobile && (

View file

@ -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<SortOption>(() => {
const saved = localStorage.getItem("__MW::watchingSort");
return (saved as SortOption) || "date";
});
const [gridRef] = useAutoAnimate<HTMLDivElement>();
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"
/>
</SectionHeading>
{editing && (
<div className="mb-6 -mt-4">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::watchingSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`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 }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<MediaGrid ref={gridRef}>
{sortedProgressItems.map((v) => (
<div

View file

@ -9,6 +9,7 @@ import { Paragraph } from "@/components/text/Paragraph";
import { Title } from "@/components/text/Title";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { getMediaKey } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
@ -24,9 +25,10 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) {
const playbackError = usePlayerStore((s) => 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,

View file

@ -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 = (
<div className="flex items-center text-video-scraping-noresult">
<div className="w-4 h-4 rounded-full border-2 border-current bg-current flex items-center justify-center">
<div className="w-2 h-0.5 bg-background-main rounded-full" />
</div>
</div>
);
}
return (
<SelectableLink loading={loading} error={errored} onClick={run}>
<SelectableLink
loading={loading}
error={errored && !notFound}
onClick={run}
rightSide={rightSide}
>
<span className="flex flex-col">
<span>{embedName}</span>
</span>
@ -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,
]);

View file

@ -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: {
</div>
</div>
{/* Minimal Cards */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.minimalCards")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.minimalCardsDescription")}
</p>
<div
onClick={() =>
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",
)}
>
<Toggle enabled={props.enableMinimalCards} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.minimalCardsLabel")}
</p>
</div>
</div>
{/* Force Compact Episode View */}
<div>
<p className="text-white font-bold mb-3">

View file

@ -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<Status>("unset");
const [quota, setQuota] = useState<any>(null);
const statusMap: Record<Status, StatusCircleProps["type"]> = {
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({
<br />
<Trans i18nKey="fedapi.setup.step.5" />
</p>
<p className="text-type-danger mt-2">
<Trans i18nKey="fedapi.setup.step.warning" />
</p>
</div>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
@ -438,6 +439,26 @@ export function FebboxSetup({
{t("fedapi.status.invalid_token")}
</p>
)}
{status === "success" &&
quota &&
(() => {
if (!quota?.data?.flow) return null;
const {
traffic_usage: used,
traffic_limit: limit,
reset_at: reset,
} = quota.data.flow;
return (
<>
<p className="text-sm text-green-500 mt-2">
{t("fedapi.setup.traffic", { used, limit, reset })}
</p>
<p className="max-w-[30rem] text-xs opacity-70 mt-2">
{t("fedapi.setup.trafficExplanation")}
</p>
</>
);
})()}
</>
) : null}
</SettingsCard>

View file

@ -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 (
<div className="space-y-12">
<Heading1 border>{t("settings.preferences.title")}</Heading1>
@ -246,6 +238,22 @@ export function PreferencesPart(props: {
</p>
</div>
</div>
{/* Keyboard Shortcuts Preference */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.preferences.keyboardShortcuts")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.preferences.keyboardShortcutsDescription")}
</p>
</div>
<Button
theme="secondary"
onClick={() => showModal("keyboard-commands-edit")}
>
{t("settings.preferences.keyboardShortcutsLabel")}
</Button>
</div>
{/* Column */}
@ -348,12 +356,11 @@ export function PreferencesPart(props: {
{props.enableSourceOrder && (
<div className="w-full flex flex-col gap-4">
<SortableListWithToggles
<SortableList
items={sourceItems}
setItems={(items) =>
props.setSourceOrder(items.map((item) => item.id))
}
onToggle={handleSourceToggle}
/>
<Button
className="max-w-[25rem]"

View file

@ -58,6 +58,33 @@ function testProxy(url: string) {
});
}
export async function fetchFebboxQuota(febboxKey: string | null): Promise<any> {
if (!febboxKey) {
return null;
}
console.log("SetupPart.tsx: Fetching Febbox quota");
try {
const response = await fetch("https://fed-api.pstream.mov/quota", {
headers: {
"ui-token": febboxKey,
},
});
if (!response.ok) {
console.error("Febbox quota API failed with status:", response.status);
return null;
}
const data = await response.json();
console.log("SetupPart.tsx: Febbox quota fetched successfully");
return data;
} catch (error) {
console.error("SetupPart.tsx: Error fetching Febbox quota:", error);
return null;
}
}
export async function testFebboxKey(febboxKey: string | null): Promise<Status> {
const febboxApiTestUrl = `https://fed-api.pstream.mov/movie/tt0325980`;
@ -107,9 +134,9 @@ export async function testFebboxKey(febboxKey: string | null): Promise<Status> {
continue;
}
const isVIPLink = Object.values(data.streams).some((link: any) => {
if (typeof link === "string") {
return link.toLowerCase().includes("vip");
const isVIPLink = Object.values(data.streams).some((stream: any) => {
if (typeof stream === "object" && stream.download) {
return stream.download.includes("/vip/");
}
return false;
});

View file

@ -12,8 +12,10 @@ import {
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommandsEditModal";
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
import { NotificationModal } from "@/components/overlays/notificationsModal";
import { SupportInfoModal } from "@/components/overlays/SupportInfoModal";
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
import { useOnlineListener } from "@/hooks/usePing";
import { AboutPage } from "@/pages/About";
@ -126,6 +128,8 @@ function App() {
<LanguageProvider />
<NotificationModal id="notifications" />
<KeyboardCommandsModal id="keyboard-commands" />
<KeyboardCommandsEditModal id="keyboard-commands-edit" />
<SupportInfoModal id="support-info" />
<DetailsModal id="details" />
<DetailsModal id="discover-details" />
<DetailsModal id="player-details" />

View file

@ -32,12 +32,19 @@ export function initializeChromecast() {
const context = (
window as any
).cast.framework.CastContext.getInstance();
context.setOptions({
const options: any = {
receiverApplicationId: (window as any).chrome?.cast?.media
?.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: (window as any).cast.framework.AutoJoinPolicy
.ORIGIN_SCOPED,
});
};
// Only set autoJoinPolicy if AutoJoinPolicy exists
if ((window as any).cast.framework.AutoJoinPolicy?.ORIGIN_SCOPED) {
options.autoJoinPolicy = (
window as any
).cast.framework.AutoJoinPolicy.ORIGIN_SCOPED;
}
context.setOptions(options);
}
} catch (e) {
console.warn("Chromecast initialization error:", e);

View file

@ -33,6 +33,9 @@ interface Config {
BANNER_MESSAGE: string;
BANNER_ID: string;
USE_TRAKT: boolean;
HIDE_PROXY_ONBOARDING: boolean;
SHOW_SUPPORT_BAR: boolean;
SUPPORT_BAR_VALUE: string;
}
export interface RuntimeConfig {
@ -62,6 +65,9 @@ export interface RuntimeConfig {
BANNER_MESSAGE: string | null;
BANNER_ID: string | null;
USE_TRAKT: boolean;
HIDE_PROXY_ONBOARDING: boolean;
SHOW_SUPPORT_BAR: boolean;
SUPPORT_BAR_VALUE: string;
}
const env: Record<keyof Config, undefined | string> = {
@ -94,6 +100,9 @@ const env: Record<keyof Config, undefined | string> = {
BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE,
BANNER_ID: import.meta.env.VITE_BANNER_ID,
USE_TRAKT: import.meta.env.VITE_USE_TRAKT,
HIDE_PROXY_ONBOARDING: import.meta.env.VITE_HIDE_PROXY_ONBOARDING,
SHOW_SUPPORT_BAR: import.meta.env.VITE_SHOW_SUPPORT_BAR,
SUPPORT_BAR_VALUE: import.meta.env.VITE_SUPPORT_BAR_VALUE,
};
function coerceUndefined(value: string | null | undefined): string | undefined {
@ -169,5 +178,8 @@ export function conf(): RuntimeConfig {
BANNER_MESSAGE: getKey("BANNER_MESSAGE"),
BANNER_ID: getKey("BANNER_ID"),
USE_TRAKT: getKey("USE_TRAKT", "false") === "true",
HIDE_PROXY_ONBOARDING: getKey("HIDE_PROXY_ONBOARDING", "false") === "true",
SHOW_SUPPORT_BAR: getKey("SHOW_SUPPORT_BAR", "false") === "true",
SUPPORT_BAR_VALUE: getKey("SUPPORT_BAR_VALUE") ?? "",
};
}

View file

@ -89,8 +89,8 @@ export interface SourceSlice {
asTrack: boolean;
};
meta: PlayerMeta | null;
failedSources: string[];
failedEmbeds: Record<string, string[]>; // sourceId -> array of failed embedIds
failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds
failedEmbedsPerMedia: Record<string, Record<string, string[]>>; // mediaKey -> sourceId -> array of failed embedIds
setStatus(status: PlayerStatus): void;
setSource(
stream: SourceSliceSource,
@ -108,11 +108,32 @@ export interface SourceSlice {
addExternalSubtitles(): Promise<void>;
addFailedSource(sourceId: string): void;
addFailedEmbed(sourceId: string, embedId: string): void;
clearFailedSources(): void;
clearFailedEmbeds(): void;
clearFailedSources(mediaKey?: string): void;
clearFailedEmbeds(mediaKey?: string): void;
reset(): void;
}
/**
* Generates a unique media key for tracking failed sources per media.
* For movies: `${type}-${tmdbId}`
* For shows: `${type}-${tmdbId}-${season.tmdbId}-${episode.tmdbId}`
*/
export function getMediaKey(meta: PlayerMeta | null): string | null {
if (!meta) return null;
if (meta.type === "movie") {
return `${meta.type}-${meta.tmdbId}`;
}
// For shows, include season and episode IDs for per-episode tracking
if (meta.type === "show" && meta.season && meta.episode) {
return `${meta.type}-${meta.tmdbId}-${meta.season.tmdbId}-${meta.episode.tmdbId}`;
}
// Fallback if show data is incomplete
return `${meta.type}-${meta.tmdbId}`;
}
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
if (meta.type === "show") {
if (!meta.episode || !meta.season) throw new Error("missing show data");
@ -148,8 +169,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
currentAudioTrack: null,
status: playerStatus.IDLE,
meta: null,
failedSources: [],
failedEmbeds: {},
failedSourcesPerMedia: {},
failedEmbedsPerMedia: {},
caption: {
selected: null,
asTrack: false,
@ -172,12 +193,26 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
});
},
setMeta(meta, newStatus) {
const store = get();
const oldMediaKey = getMediaKey(store.meta);
const newMediaKey = getMediaKey(meta);
set((s) => {
s.meta = meta;
s.embedId = null;
s.sourceId = null;
s.interface.hideNextEpisodeBtn = false;
if (newStatus) s.status = newStatus;
// Clear failed sources/embeds for the new media when media changes
// Since we're doing per-episode tracking, we clear whenever media key changes
// Only clear if we're actually switching to different media (not just setting meta for the first time)
if (newMediaKey && oldMediaKey && oldMediaKey !== newMediaKey) {
// Clear failed sources/embeds for the new media (if any exist from previous session)
// This ensures a fresh start for each media/episode
delete s.failedSourcesPerMedia[newMediaKey];
delete s.failedEmbedsPerMedia[newMediaKey];
}
});
},
setCaption(caption) {
@ -218,12 +253,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
},
redisplaySource(startAt: number) {
const store = get();
const quality = store.currentQuality;
if (!store.source) return;
const qualityPreferences = useQualityStore.getState();
const loadableStream = selectQuality(store.source, {
automaticQuality: qualityPreferences.quality.automaticQuality,
lastChosenQuality: quality,
lastChosenQuality: qualityPreferences.quality.lastChosenQuality,
});
set((s) => {
s.interface.error = undefined;
@ -267,30 +301,62 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
});
},
addFailedSource(sourceId: string) {
const store = get();
const mediaKey = getMediaKey(store.meta);
if (!mediaKey) return; // Skip tracking if no media is set
set((s) => {
if (!s.failedSources.includes(sourceId)) {
s.failedSources = [...s.failedSources, sourceId];
if (!s.failedSourcesPerMedia[mediaKey]) {
s.failedSourcesPerMedia[mediaKey] = [];
}
if (!s.failedSourcesPerMedia[mediaKey].includes(sourceId)) {
s.failedSourcesPerMedia[mediaKey] = [
...s.failedSourcesPerMedia[mediaKey],
sourceId,
];
}
});
},
addFailedEmbed(sourceId: string, embedId: string) {
const store = get();
const mediaKey = getMediaKey(store.meta);
if (!mediaKey) return; // Skip tracking if no media is set
set((s) => {
if (!s.failedEmbeds[sourceId]) {
s.failedEmbeds[sourceId] = [];
if (!s.failedEmbedsPerMedia[mediaKey]) {
s.failedEmbedsPerMedia[mediaKey] = {};
}
if (!s.failedEmbeds[sourceId].includes(embedId)) {
s.failedEmbeds[sourceId] = [...s.failedEmbeds[sourceId], embedId];
if (!s.failedEmbedsPerMedia[mediaKey][sourceId]) {
s.failedEmbedsPerMedia[mediaKey][sourceId] = [];
}
if (!s.failedEmbedsPerMedia[mediaKey][sourceId].includes(embedId)) {
s.failedEmbedsPerMedia[mediaKey][sourceId] = [
...s.failedEmbedsPerMedia[mediaKey][sourceId],
embedId,
];
}
});
},
clearFailedSources() {
clearFailedSources(mediaKey?: string) {
set((s) => {
s.failedSources = [];
if (mediaKey) {
// Clear for specific media
delete s.failedSourcesPerMedia[mediaKey];
} else {
// Clear all
s.failedSourcesPerMedia = {};
}
});
},
clearFailedEmbeds() {
clearFailedEmbeds(mediaKey?: string) {
set((s) => {
s.failedEmbeds = {};
if (mediaKey) {
// Clear for specific media
delete s.failedEmbedsPerMedia[mediaKey];
} else {
// Clear all
s.failedEmbedsPerMedia = {};
}
});
},
reset() {
@ -306,8 +372,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.currentAudioTrack = null;
s.status = playerStatus.IDLE;
s.meta = null;
s.failedSources = [];
s.failedEmbeds = {};
s.failedSourcesPerMedia = {};
s.failedEmbedsPerMedia = {};
s.caption = {
selected: null,
asTrack: false,

View file

@ -2,6 +2,11 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import {
DEFAULT_KEYBOARD_SHORTCUTS,
KeyboardShortcuts,
} from "@/utils/keyboardShortcuts";
export interface PreferencesStore {
enableThumbnails: boolean;
enableAutoplay: boolean;
@ -11,15 +16,14 @@ export interface PreferencesStore {
enableDetailsModal: boolean;
enableImageLogos: boolean;
enableCarouselView: boolean;
enableMinimalCards: boolean;
forceCompactEpisodeView: boolean;
sourceOrder: string[];
enableSourceOrder: boolean;
lastSuccessfulSource: string | null;
enableLastSuccessfulSource: boolean;
disabledSources: string[];
embedOrder: string[];
enableEmbedOrder: boolean;
disabledEmbeds: string[];
proxyTmdb: boolean;
febboxKey: string | null;
debridToken: string | null;
@ -31,6 +35,7 @@ export interface PreferencesStore {
manualSourceSelection: boolean;
enableDoubleClickToSeek: boolean;
enableAutoResumeOnPlaybackError: boolean;
keyboardShortcuts: KeyboardShortcuts;
setEnableThumbnails(v: boolean): void;
setEnableAutoplay(v: boolean): void;
@ -40,15 +45,14 @@ export interface PreferencesStore {
setEnableDetailsModal(v: boolean): void;
setEnableImageLogos(v: boolean): void;
setEnableCarouselView(v: boolean): void;
setEnableMinimalCards(v: boolean): void;
setForceCompactEpisodeView(v: boolean): void;
setSourceOrder(v: string[]): void;
setEnableSourceOrder(v: boolean): void;
setLastSuccessfulSource(v: string | null): void;
setEnableLastSuccessfulSource(v: boolean): void;
setDisabledSources(v: string[]): void;
setEmbedOrder(v: string[]): void;
setEnableEmbedOrder(v: boolean): void;
setDisabledEmbeds(v: string[]): void;
setProxyTmdb(v: boolean): void;
setFebboxKey(v: string | null): void;
setdebridToken(v: string | null): void;
@ -60,6 +64,7 @@ export interface PreferencesStore {
setManualSourceSelection(v: boolean): void;
setEnableDoubleClickToSeek(v: boolean): void;
setEnableAutoResumeOnPlaybackError(v: boolean): void;
setKeyboardShortcuts(v: KeyboardShortcuts): void;
}
export const usePreferencesStore = create(
@ -73,15 +78,14 @@ export const usePreferencesStore = create(
enableDetailsModal: false,
enableImageLogos: true,
enableCarouselView: false,
enableMinimalCards: false,
forceCompactEpisodeView: false,
sourceOrder: [],
enableSourceOrder: false,
lastSuccessfulSource: null,
enableLastSuccessfulSource: false,
disabledSources: [],
embedOrder: [],
enableEmbedOrder: false,
disabledEmbeds: [],
proxyTmdb: false,
febboxKey: null,
debridToken: null,
@ -93,6 +97,7 @@ export const usePreferencesStore = create(
manualSourceSelection: false,
enableDoubleClickToSeek: false,
enableAutoResumeOnPlaybackError: true,
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
setEnableThumbnails(v) {
set((s) => {
s.enableThumbnails = v;
@ -133,6 +138,11 @@ export const usePreferencesStore = create(
s.enableCarouselView = v;
});
},
setEnableMinimalCards(v) {
set((s) => {
s.enableMinimalCards = v;
});
},
setForceCompactEpisodeView(v) {
set((s) => {
s.forceCompactEpisodeView = v;
@ -158,11 +168,6 @@ export const usePreferencesStore = create(
s.enableLastSuccessfulSource = v;
});
},
setDisabledSources(v) {
set((s) => {
s.disabledSources = v;
});
},
setEmbedOrder(v) {
set((s) => {
s.embedOrder = v;
@ -173,11 +178,6 @@ export const usePreferencesStore = create(
s.enableEmbedOrder = v;
});
},
setDisabledEmbeds(v) {
set((s) => {
s.disabledEmbeds = v;
});
},
setProxyTmdb(v) {
set((s) => {
s.proxyTmdb = v;
@ -238,6 +238,11 @@ export const usePreferencesStore = create(
s.enableAutoResumeOnPlaybackError = v;
});
},
setKeyboardShortcuts(v) {
set((s) => {
s.keyboardShortcuts = v;
});
},
})),
{
name: "__MW::preferences",

View file

@ -38,6 +38,7 @@ export async function scrapeOpenSubtitlesCaptions(
openSubtitlesCaptions.push({
id: downloadUrl,
language,
display: caption.LanguageName,
url: downloadUrl,
type: caption.SubFormat || "srt",
needsProxy: false,

View file

@ -43,7 +43,7 @@ export async function scrapeWyzieCaptions(
display: subtitle.display,
media: subtitle.media,
isHearingImpaired: subtitle.isHearingImpaired,
source: `wyzie ${subtitle.source.toString() === "opensubtitles" ? "opensubs" : subtitle.source}`,
source: `wyzie ${subtitle.source?.toString() === "opensubtitles" ? "opensubs" : subtitle.source}`,
encoding: subtitle.encoding,
}));

View file

@ -0,0 +1,298 @@
/**
* Keyboard shortcuts configuration and utilities
*/
export type KeyboardModifier = "Shift" | "Alt";
export interface KeyboardShortcutConfig {
modifier?: KeyboardModifier;
key?: string;
}
export type KeyboardShortcuts = Record<string, KeyboardShortcutConfig>;
/**
* Shortcut IDs for customizable shortcuts
*/
export enum ShortcutId {
// Video playback
SKIP_FORWARD_5 = "skipForward5",
SKIP_BACKWARD_5 = "skipBackward5",
SKIP_FORWARD_10 = "skipForward10",
SKIP_BACKWARD_10 = "skipBackward10",
SKIP_FORWARD_1 = "skipForward1",
SKIP_BACKWARD_1 = "skipBackward1",
NEXT_EPISODE = "nextEpisode",
PREVIOUS_EPISODE = "previousEpisode",
// Jump to position
JUMP_TO_0 = "jumpTo0",
JUMP_TO_9 = "jumpTo9",
// Audio/Video
INCREASE_VOLUME = "increaseVolume",
DECREASE_VOLUME = "decreaseVolume",
MUTE = "mute",
TOGGLE_FULLSCREEN = "toggleFullscreen",
// Subtitles/Accessibility
TOGGLE_CAPTIONS = "toggleCaptions",
RANDOM_CAPTION = "randomCaption",
SYNC_SUBTITLES_EARLIER = "syncSubtitlesEarlier",
SYNC_SUBTITLES_LATER = "syncSubtitlesLater",
// Interface
BARREL_ROLL = "barrelRoll",
}
/**
* Default keyboard shortcuts configuration
*/
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
[ShortcutId.SKIP_FORWARD_5]: { key: "ArrowRight" },
[ShortcutId.SKIP_BACKWARD_5]: { key: "ArrowLeft" },
[ShortcutId.SKIP_FORWARD_10]: { key: "L" },
[ShortcutId.SKIP_BACKWARD_10]: { key: "J" },
[ShortcutId.SKIP_FORWARD_1]: { key: "." },
[ShortcutId.SKIP_BACKWARD_1]: { key: "," },
[ShortcutId.NEXT_EPISODE]: { key: "P" },
[ShortcutId.PREVIOUS_EPISODE]: { key: "O" },
[ShortcutId.JUMP_TO_0]: { key: "0" },
[ShortcutId.JUMP_TO_9]: { key: "9" },
[ShortcutId.INCREASE_VOLUME]: { key: "ArrowUp" },
[ShortcutId.DECREASE_VOLUME]: { key: "ArrowDown" },
[ShortcutId.MUTE]: { key: "M" },
[ShortcutId.TOGGLE_FULLSCREEN]: { key: "F" },
[ShortcutId.TOGGLE_CAPTIONS]: { key: "C" },
[ShortcutId.RANDOM_CAPTION]: { modifier: "Shift", key: "C" },
[ShortcutId.SYNC_SUBTITLES_EARLIER]: { key: "[" },
[ShortcutId.SYNC_SUBTITLES_LATER]: { key: "]" },
[ShortcutId.BARREL_ROLL]: { key: "R" },
};
/**
* Locked shortcuts that cannot be customized
*/
export const LOCKED_SHORTCUTS = {
PLAY_PAUSE_SPACE: " ",
PLAY_PAUSE_K: "K",
MODAL_HOTKEY: "`",
ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
ESCAPE: "Escape",
JUMP_TO_0: "0",
JUMP_TO_9: "9",
} as const;
/**
* Locked shortcut IDs that cannot be customized
*/
export const LOCKED_SHORTCUT_IDS: string[] = [
"playPause",
"playPauseAlt",
"skipForward5",
"skipBackward5",
"increaseVolume",
"decreaseVolume",
"modalHotkey",
"closeOverlay",
"jumpTo0",
"jumpTo9",
];
/**
* Check if a key is a number key (0-9)
*/
export function isNumberKey(key: string): boolean {
return /^[0-9]$/.test(key);
}
/**
* Key equivalence map for bidirectional mapping
* Maps keys that should be treated as equivalent (e.g., 1 and !)
*/
export const KEY_EQUIVALENCE_MAP: Record<string, string> = {
// Number keys and their shift equivalents
"1": "!",
"!": "1",
"2": "@",
"@": "2",
"3": "#",
"#": "3",
"4": "$",
$: "4",
"5": "%",
"%": "5",
"6": "^",
"^": "6",
"7": "&",
"&": "7",
"8": "*",
"*": "8",
"9": "(",
"(": "9",
"0": ")",
")": "0",
// Other symbol pairs
"-": "_",
_: "-",
"=": "+",
"+": "=",
"[": "{",
"{": "[",
"]": "}",
"}": "]",
"\\": "|",
"|": "\\",
";": ":",
":": ";",
"'": '"',
'"': "'",
",": "<",
"<": ",",
".": ">",
">": ".",
"/": "?",
"?": "/",
"`": "~",
"~": "`",
};
/**
* Get equivalent keys for a given key
*/
export function getEquivalentKeys(key: string): string[] {
const equivalent = KEY_EQUIVALENCE_MAP[key];
if (equivalent) {
return [key, equivalent];
}
return [key];
}
/**
* Normalize a key for comparison (handles case-insensitive matching)
*/
export function normalizeKey(key: string): string {
// For letter keys, use uppercase for consistency
if (/^[a-z]$/i.test(key)) {
return key.toUpperCase();
}
return key;
}
/**
* Check if two shortcut configs conflict
*/
export function checkShortcutConflict(
config1: KeyboardShortcutConfig | undefined,
config2: KeyboardShortcutConfig | undefined,
): boolean {
if (!config1 || !config2 || !config1.key || !config2.key) {
return false;
}
// Check if modifiers match
if (config1.modifier !== config2.modifier) {
return false;
}
// Check if keys match directly or are equivalent
const key1 = normalizeKey(config1.key);
const key2 = normalizeKey(config2.key);
if (key1 === key2) {
return true;
}
// Check equivalence
const equiv1 = getEquivalentKeys(key1);
const equiv2 = getEquivalentKeys(key2);
return equiv1.some((k1) => equiv2.includes(k1));
}
/**
* Find all conflicts in a shortcuts configuration
*/
export function findConflicts(
shortcuts: KeyboardShortcuts,
): Array<{ id1: string; id2: string }> {
const conflicts: Array<{ id1: string; id2: string }> = [];
const ids = Object.keys(shortcuts);
for (let i = 0; i < ids.length; i += 1) {
for (let j = i + 1; j < ids.length; j += 1) {
const id1 = ids[i];
const id2 = ids[j];
const config1 = shortcuts[id1];
const config2 = shortcuts[id2];
if (checkShortcutConflict(config1, config2)) {
conflicts.push({ id1, id2 });
}
}
}
return conflicts;
}
/**
* Check if a keyboard event matches a shortcut configuration
*/
export function matchesShortcut(
event: KeyboardEvent,
config: KeyboardShortcutConfig | undefined,
): boolean {
if (!config || !config.key) {
return false;
}
const eventKey = normalizeKey(event.key);
const configKey = normalizeKey(config.key);
// Check modifier match
if (config.modifier === "Shift" && !event.shiftKey) {
return false;
}
if (config.modifier === "Alt" && !event.altKey) {
return false;
}
// If no modifier specified, ensure no modifier is pressed (except ctrl/meta which we ignore)
if (!config.modifier && (event.shiftKey || event.altKey)) {
return false;
}
// Check key match (direct or equivalent)
if (eventKey === configKey) {
return true;
}
// Check equivalence
const equivKeys = getEquivalentKeys(configKey);
return equivKeys.includes(eventKey);
}
/**
* Get display name for a key
*/
export function getKeyDisplayName(key: string): string {
const displayNames: Record<string, string> = {
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
" ": "Space",
};
return displayNames[key] || key;
}
/**
* Get display symbol for a modifier
*/
export function getModifierSymbol(modifier: KeyboardModifier): string {
return modifier === "Shift" ? "⇧" : "⌥";
}

114
src/utils/mediaSorting.ts Normal file
View file

@ -0,0 +1,114 @@
import { BookmarkMediaItem } from "@/stores/bookmarks";
import { ProgressMediaItem } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
export type SortOption =
| "date"
| "title-asc"
| "title-desc"
| "year-asc"
| "year-desc";
export function sortMediaItems(
items: MediaItem[],
sortBy: SortOption,
bookmarks?: Record<string, BookmarkMediaItem>,
progressItems?: Record<string, ProgressMediaItem>,
): MediaItem[] {
const sorted = [...items];
switch (sortBy) {
case "date": {
sorted.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 ?? 0,
progressA?.updatedAt ?? 0,
);
const dateB = Math.max(
bookmarkB?.updatedAt ?? 0,
progressB?.updatedAt ?? 0,
);
return dateB - dateA; // Newest first
});
break;
}
case "title-asc": {
sorted.sort((a, b) => {
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleA.localeCompare(titleB);
});
break;
}
case "title-desc": {
sorted.sort((a, b) => {
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleB.localeCompare(titleA);
});
break;
}
case "year-asc": {
sorted.sort((a, b) => {
const yearA = a.year ?? Number.MAX_SAFE_INTEGER;
const yearB = b.year ?? Number.MAX_SAFE_INTEGER;
if (yearA === yearB) {
// Secondary sort by title for same year
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleA.localeCompare(titleB);
}
return yearA - yearB;
});
break;
}
case "year-desc": {
sorted.sort((a, b) => {
const yearA = a.year ?? 0; // Put undefined years at the end
const yearB = b.year ?? 0;
if (yearA === yearB) {
// Secondary sort by title for same year
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleA.localeCompare(titleB);
}
return yearB - yearA;
});
break;
}
default: {
// Fallback to date sorting for unknown sort options
sorted.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 ?? 0,
progressA?.updatedAt ?? 0,
);
const dateB = Math.max(
bookmarkB?.updatedAt ?? 0,
progressB?.updatedAt ?? 0,
);
return dateB - dateA; // Newest first
});
break;
}
}
return sorted;
}

View file

@ -17,7 +17,7 @@ import wolverine from "./list/wolverine";
import popsicle from "./list/popsicle";
import hulk from "./list/hulk";
import autumn from "./list/autumn";
import skyRealm from "./list/skyrealm";
import cobalt from "./list/cobalt";
import frost from "./list/frost";
import christmas from "./list/christmas";
@ -43,5 +43,5 @@ export const allThemes = [
wolverine,
popsicle,
christmas,
skyRealm,
cobalt,
];

332
themes/list/cobalt.ts Normal file
View file

@ -0,0 +1,332 @@
import { createTheme } from "../types";
const tokens = {
black: {
c50: "#000000",
c75: "#030303",
c80: "#080808",
c100: "#0d0d0d",
c125: "#141414",
c150: "#1a1a1a",
c200: "#262626",
c250: "#333333",
},
white: "#FFFFFF",
semantic: {
red: {
c100: "#F46E6E",
c200: "#E44F4F",
c300: "#D74747",
c400: "#B43434",
},
green: {
c100: "#60D26A",
c200: "#40B44B",
c300: "#31A33C",
c400: "#237A2B",
},
silver: {
c100: "#DEDEDE",
c200: "#B6CAD7",
c300: "#8EA3B0",
c400: "#617A8A",
},
yellow: {
c100: "#80EAFF",
c200: "#43DFFF",
c300: "#00D4FF",
c400: "#00B6E6",
},
rose: {
c100: "#80EAFF",
c200: "#00B6E6",
c300: "#00D4FF",
c400: "#43DFFF",
},
},
blue: {
c50: "#ccccd6",
c100: "#a2a2a2",
c200: "#868686",
c300: "#646464",
c400: "#4e4e4e",
c500: "#383838",
c600: "#2e2e2e",
c700: "#272727",
c800: "#181818",
c900: "#0f0f0f",
},
purple: {
c50: "#80EAFF",
c100: "#43DFFF",
c200: "#00D4FF",
c300: "#00B6E6",
c400: "#0099CC",
c500: "#007CAD",
c600: "#00668F",
c700: "#004F6E",
c800: "#00384D",
c900: "#002A3A",
},
ash: {
c50: "#8d8d8d",
c100: "#6b6b6b",
c200: "#545454",
c300: "#3c3c3c",
c400: "#313131",
c500: "#2c2c2c",
c600: "#252525",
c700: "#1e1e1e",
c800: "#181818",
c900: "#111111",
},
shade: {
c25: "#939393",
c50: "#7c7c7c",
c100: "#666666",
c200: "#4f4f4f",
c300: "#404040",
c400: "#343434",
c500: "#282828",
c600: "#202020",
c700: "#1a1a1a",
c800: "#151515",
c900: "#0e0e0e",
},
};
export default createTheme({
name: "cobalt",
extend: {
colors: {
themePreview: {
primary: tokens.black.c80,
secondary: tokens.purple.c400,
ghost: tokens.purple.c100,
},
pill: {
background: tokens.black.c100,
backgroundHover: tokens.black.c125,
highlight: tokens.blue.c200,
activeBackground: tokens.shade.c700,
},
global: {
accentA: tokens.purple.c200,
accentB: tokens.purple.c300,
},
lightBar: {
light: tokens.purple.c800,
},
buttons: {
toggle: tokens.purple.c300,
toggleDisabled: tokens.black.c200,
danger: tokens.semantic.rose.c300,
dangerHover: tokens.semantic.rose.c200,
secondary: tokens.black.c100,
secondaryText: tokens.semantic.silver.c300,
secondaryHover: tokens.black.c150,
primary: tokens.white,
primaryText: tokens.black.c50,
primaryHover: tokens.semantic.silver.c100,
purple: tokens.purple.c600,
purpleHover: tokens.purple.c400,
cancel: tokens.black.c100,
cancelHover: tokens.black.c150,
},
background: {
main: tokens.black.c75,
secondary: tokens.black.c75,
secondaryHover: tokens.black.c75,
accentA: tokens.purple.c600,
accentB: tokens.black.c100,
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
emphasis: tokens.white,
text: tokens.shade.c50,
dimmed: tokens.shade.c50,
divider: tokens.ash.c500,
secondary: tokens.ash.c100,
danger: tokens.semantic.red.c100,
success: tokens.semantic.green.c100,
link: tokens.purple.c100,
linkHover: tokens.purple.c50,
},
search: {
background: tokens.black.c100,
hoverBackground: tokens.shade.c900,
focused: tokens.black.c125,
placeholder: tokens.shade.c200,
icon: tokens.shade.c500,
text: tokens.white,
},
mediaCard: {
hoverBackground: tokens.shade.c900,
hoverAccent: tokens.black.c250,
hoverShadow: tokens.black.c50,
shadow: tokens.shade.c800,
barColor: tokens.ash.c200,
barFillColor: tokens.purple.c100,
badge: tokens.shade.c700,
badgeText: tokens.ash.c100,
},
largeCard: {
background: tokens.black.c100,
icon: tokens.purple.c400,
},
dropdown: {
background: tokens.black.c100,
altBackground: tokens.black.c80,
hoverBackground: tokens.black.c150,
highlight: tokens.semantic.yellow.c400,
highlightHover: tokens.semantic.yellow.c200,
text: tokens.shade.c50,
secondary: tokens.shade.c100,
border: tokens.shade.c400,
contentBackground: tokens.black.c50,
},
authentication: {
border: tokens.shade.c300,
inputBg: tokens.black.c100,
inputBgHover: tokens.black.c150,
wordBackground: tokens.shade.c500,
copyText: tokens.shade.c100,
copyTextHover: tokens.ash.c50,
errorText: tokens.semantic.rose.c100,
},
settings: {
sidebar: {
activeLink: tokens.black.c100,
badge: tokens.shade.c900,
type: {
secondary: tokens.shade.c200,
inactive: tokens.shade.c50,
icon: tokens.black.c200,
iconActivated: tokens.purple.c200,
activated: tokens.purple.c100,
},
},
card: {
border: tokens.shade.c700,
background: tokens.black.c100,
altBackground: tokens.black.c100,
},
saveBar: {
background: tokens.black.c50,
},
},
utils: {
divider: tokens.ash.c300,
},
onboarding: {
bar: tokens.shade.c400,
barFilled: tokens.purple.c300,
divider: tokens.shade.c200,
card: tokens.shade.c800,
cardHover: tokens.shade.c700,
border: tokens.shade.c600,
good: tokens.purple.c100,
best: tokens.semantic.yellow.c100,
link: tokens.purple.c100,
},
errors: {
card: tokens.black.c75,
border: tokens.ash.c500,
type: {
secondary: tokens.ash.c100,
},
},
about: {
circle: tokens.black.c100,
circleText: tokens.ash.c50,
},
editBadge: {
bg: tokens.ash.c500,
bgHover: tokens.ash.c400,
text: tokens.ash.c50,
},
progress: {
background: tokens.ash.c50,
preloaded: tokens.ash.c50,
filled: tokens.purple.c200,
},
video: {
buttonBackground: tokens.ash.c600,
autoPlay: {
background: tokens.ash.c800,
hover: tokens.ash.c600,
},
scraping: {
card: tokens.black.c50,
error: tokens.semantic.red.c200,
success: tokens.semantic.green.c200,
loading: tokens.purple.c200,
noresult: tokens.black.c200,
},
audio: {
set: tokens.purple.c200,
},
context: {
background: tokens.black.c50,
light: tokens.shade.c50,
border: tokens.ash.c600,
hoverColor: tokens.ash.c600,
buttonFocus: tokens.ash.c500,
flagBg: tokens.ash.c500,
inputBg: tokens.black.c100,
buttonOverInputHover: tokens.ash.c500,
inputPlaceholder: tokens.ash.c200,
cardBorder: tokens.ash.c700,
slider: tokens.black.c200,
sliderFilled: tokens.purple.c200,
error: tokens.semantic.red.c200,
buttons: {
list: tokens.ash.c700,
active: tokens.ash.c900,
},
closeHover: tokens.ash.c800,
type: {
main: tokens.semantic.silver.c300,
secondary: tokens.ash.c200,
accent: tokens.purple.c200,
},
},
},
},
},
});

View file

@ -1,248 +0,0 @@
import { createTheme } from "../types";
const tokens = {
petal: {
c50: "#fff0f5",
c100: "#ffdde9",
c200: "#ffbbd1",
c300: "#ff99bb",
c400: "#ff77a5",
c500: "#e65f8f",
c600: "#cc4779",
c700: "#b33263",
c800: "#991e4d",
c900: "#800f3a",
},
dawn: {
c25: "#f0e9f5",
c50: "#e6d9f0",
c100: "#d7c2e8",
c200: "#c2a6dd",
c300: "#ad8ad2",
c400: "#9970c7",
c500: "#8558b5",
c600: "#6b4599",
c700: "#52337d",
c800: "#3a2361",
c900: "#261547",
},
silk: {
c50: "#fdfdfd",
c100: "#faf8f9",
c200: "#f5f0f3",
c300: "#ede5ec",
c400: "#e6dae5",
c500: "#dfd0de",
c600: "#d0b8d0",
c700: "#c29fc2",
c800: "#b386b3",
c900: "#a66ea6",
},
nectar: {
c50: "#fff9e6",
c100: "#fff3cc",
c200: "#ffe899",
c300: "#ffdd66",
c400: "#ffd233",
c500: "#e6b800",
c600: "#cca000",
c700: "#b38900",
c800: "#997100",
c900: "#805a00",
},
};
export default createTheme({
name: "skyRealm",
extend: {
colors: {
themePreview: {
primary: tokens.petal.c200,
secondary: tokens.dawn.c50,
},
pill: {
background: tokens.dawn.c300,
backgroundHover: tokens.dawn.c200,
highlight: tokens.petal.c200,
activeBackground: tokens.dawn.c300,
},
global: {
accentA: tokens.petal.c200,
accentB: tokens.petal.c300,
},
lightBar: {
light: tokens.petal.c400,
},
buttons: {
toggle: tokens.nectar.c300,
toggleDisabled: tokens.silk.c500,
secondary: tokens.silk.c700,
secondaryHover: tokens.silk.c600,
purple: tokens.dawn.c500,
purpleHover: tokens.dawn.c400,
cancel: tokens.silk.c500,
cancelHover: tokens.silk.c300,
},
background: {
main: tokens.dawn.c900,
secondary: tokens.dawn.c600,
secondaryHover: tokens.dawn.c400,
accentA: tokens.nectar.c500,
accentB: tokens.petal.c500,
},
modal: {
background: tokens.dawn.c800,
},
type: {
logo: tokens.nectar.c100,
text: tokens.silk.c50,
dimmed: tokens.silk.c50,
divider: tokens.silk.c500,
secondary: tokens.silk.c100,
link: tokens.petal.c100,
linkHover: tokens.petal.c50,
},
search: {
background: tokens.dawn.c500,
hoverBackground: tokens.dawn.c600,
focused: tokens.dawn.c400,
placeholder: tokens.dawn.c100,
icon: tokens.dawn.c100,
},
mediaCard: {
hoverBackground: tokens.dawn.c600,
hoverAccent: tokens.dawn.c25,
hoverShadow: tokens.dawn.c900,
shadow: tokens.dawn.c700,
barColor: tokens.silk.c200,
barFillColor: tokens.petal.c100,
badge: tokens.dawn.c700,
badgeText: tokens.silk.c100,
},
largeCard: {
background: tokens.dawn.c600,
icon: tokens.petal.c400,
},
dropdown: {
background: tokens.dawn.c600,
altBackground: tokens.dawn.c700,
hoverBackground: tokens.dawn.c500,
text: tokens.silk.c50,
secondary: tokens.dawn.c100,
border: tokens.dawn.c400,
contentBackground: tokens.dawn.c500,
},
authentication: {
border: tokens.dawn.c300,
inputBg: tokens.dawn.c600,
inputBgHover: tokens.dawn.c500,
wordBackground: tokens.dawn.c500,
copyText: tokens.dawn.c100,
copyTextHover: tokens.silk.c50,
},
settings: {
sidebar: {
activeLink: tokens.dawn.c600,
badge: tokens.dawn.c900,
type: {
secondary: tokens.dawn.c200,
inactive: tokens.dawn.c50,
icon: tokens.dawn.c50,
iconActivated: tokens.petal.c200,
activated: tokens.petal.c50,
},
},
card: {
border: tokens.dawn.c400,
background: tokens.dawn.c400,
altBackground: tokens.dawn.c400,
},
saveBar: {
background: tokens.dawn.c800,
},
},
utils: {
divider: tokens.silk.c300,
},
errors: {
card: tokens.dawn.c800,
border: tokens.silk.c500,
type: {
secondary: tokens.silk.c100,
},
},
about: {
circle: tokens.silk.c500,
circleText: tokens.silk.c50,
},
editBadge: {
bg: tokens.silk.c500,
bgHover: tokens.silk.c400,
text: tokens.silk.c50,
},
progress: {
background: tokens.silk.c50,
preloaded: tokens.silk.c50,
filled: tokens.petal.c200,
},
video: {
buttonBackground: tokens.silk.c200,
autoPlay: {
background: tokens.silk.c700,
hover: tokens.silk.c500,
},
scraping: {
card: tokens.dawn.c700,
loading: tokens.petal.c200,
noresult: tokens.silk.c100,
},
audio: {
set: tokens.petal.c200,
},
context: {
background: tokens.silk.c900,
light: tokens.dawn.c50,
border: tokens.silk.c600,
hoverColor: tokens.silk.c600,
buttonFocus: tokens.silk.c500,
flagBg: tokens.silk.c500,
inputBg: tokens.silk.c600,
buttonOverInputHover: tokens.silk.c500,
inputPlaceholder: tokens.silk.c200,
cardBorder: tokens.silk.c700,
slider: tokens.silk.c50,
sliderFilled: tokens.petal.c200,
buttons: {
list: tokens.silk.c700,
active: tokens.silk.c900,
},
closeHover: tokens.silk.c800,
type: {
secondary: tokens.silk.c200,
accent: tokens.petal.c200,
},
},
},
},
},
});