mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
Merge branch 'production' into production
This commit is contained in:
commit
c151f94c1b
72 changed files with 4151 additions and 1152 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 Webkit’s limitations with webRequest APIs. :(
|
||||
|
||||
Thanks to Duplicake for the incredible help building this!
|
||||
|
||||
Use the link below to install the userscript into Violentmonkey or Tampermonkey!
|
||||
|
||||
P.S. We're still working on getting some new sources online, so expect more updates soon!
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
518
src/components/overlays/KeyboardCommandsEditModal.tsx
Normal file
518
src/components/overlays/KeyboardCommandsEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
40
src/components/overlays/SupportInfoModal.tsx
Normal file
40
src/components/overlays/SupportInfoModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
197
src/components/player/atoms/settings/LanguageSubtitlesView.tsx
Normal file
197
src/components/player/atoms/settings/LanguageSubtitlesView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export function useEmbedScraping(
|
|||
run,
|
||||
loading: request.loading,
|
||||
errored: !!request.error,
|
||||
notFound: request.error instanceof NotFoundError,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,6 @@ export function AdminPage() {
|
|||
setEmbedOrder={embedOrderState.setEmbedOrder}
|
||||
enableEmbedOrder={embedOrderState.enableEmbedOrder}
|
||||
setEnableEmbedOrder={embedOrderState.setEnableEmbedOrder}
|
||||
disabledEmbeds={embedOrderState.disabledEmbeds}
|
||||
setDisabledEmbeds={embedOrderState.setDisabledEmbeds}
|
||||
/>
|
||||
{/* <ProgressCleanupPart /> */}
|
||||
</ThinContainer>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
131
src/pages/parts/home/SupportBar.tsx
Normal file
131
src/pages/parts/home/SupportBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") ?? "",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export async function scrapeOpenSubtitlesCaptions(
|
|||
openSubtitlesCaptions.push({
|
||||
id: downloadUrl,
|
||||
language,
|
||||
display: caption.LanguageName,
|
||||
url: downloadUrl,
|
||||
type: caption.SubFormat || "srt",
|
||||
needsProxy: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
298
src/utils/keyboardShortcuts.ts
Normal file
298
src/utils/keyboardShortcuts.ts
Normal 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
114
src/utils/mediaSorting.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
332
themes/list/cobalt.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue