pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

Overhaul Discover Page and Featured

Add Featured Modal
Removed Individual Carousels for each genre
Recommended Carousel
View More page for viewing all
Improve several minor visuals
Update search and navigation

Full Commit Log:

add more carousel skeleton dots

bug fix and languages

remove provider translations

Add change button for recommended more content

add buttons to moreContent page

dropdown for changing recommended

Increase genres and providers

add home/search button to discover

Update FeaturedCarousel.tsx

fix recommended load more pages

increase number of featured items

clean up featured image fetch

maybe fix ff bug?

add dynamic blur to header

Update Dropdown.tsx

fix dropdown

add recommended carousel

animate dropdown

fix some visuals

random button

fix padding

reset timer when manually switching slides

fix editor picks more titles

add store for discover

fix editor picks

Update FeaturedCarousel.tsx

add view more card

move view more link

update carousel buttons and dropdown

finish 5 carousels

use 5 carousels

init carousel nav buttons

update dropdown

update featured sizing

update blurs

add clear blur to navigation

update padding and sizing

Update FeaturedCarousel.tsx

add loading skeleton

update discover navigation again

simplify featured media

Update SearchBar.tsx

tweak some minor visual stuff

fix button sizes

update carousel gradient

fix sticky

fix safari overlay bug

make search transparent

use secondary buttons on featured

fix up negative margins

fix searching classes

fix buttons because of the overlay

make it shorter

add featured section to home page

add toggle for image logos

fix details modal title overlay position

clean up some buttons

improve fed setup status check

update grid

Update FeaturedCarousel.tsx

dont show more content for providers

more stuff

clean and bugfix

update editor picks more content page

Update DetailsModal.tsx

more more more!

shuffle editor picks

discover update part 2

fix more info button

init discover v3

Pas 3ce5053a 12b3002b

+3852 -1578
+45
src/assets/locales/de.json
··· 821 821 "episodes": "Folgen", 822 822 "season": "Staffel", 823 823 "runtime": "Laufzeit:" 824 + }, 825 + "discover": { 826 + "tabs": { 827 + "movies": "Filme", 828 + "tvshows": "Serien", 829 + "editorpicks": "Empfehlungen der Redaktion" 830 + }, 831 + "carousel": { 832 + "title": { 833 + "movies": "{{category}} Filme", 834 + "tvshows": "{{category}} Serien", 835 + "inCinemas": "Jetzt im Kino", 836 + "popularOn": "Beliebte {{type}} auf {{provider}}", 837 + "editorPicksMovies": "Redaktionsempfehlungen Filme", 838 + "editorPicksShows": "Redaktionsempfehlungen Serien", 839 + "moviesOn": "Filme auf {{provider}}", 840 + "tvshowsOn": "Serien auf {{provider}}", 841 + "recommended": "Weil du geschaut hast: {{title}}", 842 + "genreMovies": "{{genre}} Filme", 843 + "genreShows": "{{genre}} Serien", 844 + "categoryMovies": "{{category}} Filme", 845 + "categoryShows": "{{category}} Serien" 846 + }, 847 + "change": "Ändern", 848 + "more": "Mehr anzeigen" 849 + }, 850 + "featured": { 851 + "playNow": "Jetzt abspielen", 852 + "moreInfo": "Mehr Infos" 853 + }, 854 + "randomMovie": { 855 + "button": "Zufälligen Titel abspielen", 856 + "cancel": "Countdown abbrechen", 857 + "countdown": "{{countdown}}s", 858 + "nowPlaying": "Jetzt läuft", 859 + "in": "in" 860 + }, 861 + "page": { 862 + "title": "Filme & Serien entdecken", 863 + "subtitle": "Entdecke aktuelle Highlights und zeitlose Klassiker.", 864 + "loadMore": "Mehr laden", 865 + "loading": "Lade...", 866 + "back": "Zurück" 867 + }, 868 + "scrollToTop": "Nach oben" 824 869 } 825 870 }
+942 -939
src/assets/locales/en.json
··· 1 1 { 2 - "about": { 3 - "description": "P-Stream is a fork of movie-web that is ensured to stay up even after the shutdown of movie-web.app. P-Stream runs on a private, self-hosted VPS. I run this site at a loss; there are no ads due to my beliefs in free media.", 4 - "faqTitle": "Common questions", 5 - "q1": { 6 - "body": "P-Stream does not host any content. When you click on something to watch, the internet is searched for the selected media (On the loading screen and in the 'video sources' tab you can see which source you're using). Media never gets uploaded by P-Stream, everything is through this searching mechanism.", 7 - "title": "Where does the content come from?" 8 - }, 9 - "q2": { 10 - "body": "It's not possible to request a show or movie, P-Stream does not manage any content. All content is viewed through sources on the internet.", 11 - "title": "Where can I request a show or movie?" 12 - }, 13 - "q3": { 14 - "body": "Our search results are powered by The Movie Database (TMDB) and display regardless of whether our sources actually have the content.", 15 - "title": "The search results display the show or movie, why can't I play it?" 16 - }, 17 - "q4": { 18 - "body": "All data is synced to the community backend, anyone is free to use this as well.", 19 - "title": "What about my data and stuff?" 20 - }, 21 - "q5": { 22 - "body": "P-Stream has a Discord server that can be found at the header of this page!", 23 - "title": "How can I find out more?" 24 - }, 25 - "title": "About P-Stream (^▽^)" 2 + "about": { 3 + "description": "P-Stream is a fork of movie-web that is ensured to stay up even after the shutdown of movie-web.app. P-Stream runs on a private, self-hosted VPS. I run this site at a loss; there are no ads due to my beliefs in free media.", 4 + "faqTitle": "Common questions", 5 + "q1": { 6 + "body": "P-Stream does not host any content. When you click on something to watch, the internet is searched for the selected media (On the loading screen and in the 'video sources' tab you can see which source you're using). Media never gets uploaded by P-Stream, everything is through this searching mechanism.", 7 + "title": "Where does the content come from?" 26 8 }, 27 - "actions": { 28 - "copied": "Copied", 29 - "copy": "Copy" 9 + "q2": { 10 + "body": "It's not possible to request a show or movie, P-Stream does not manage any content. All content is viewed through sources on the internet.", 11 + "title": "Where can I request a show or movie?" 30 12 }, 31 - "auth": { 32 - "createAccount": "Don't have an account yet 😬 <0>Create an account.</0>", 33 - "deviceNameLabel": "Device name", 34 - "deviceNamePlaceholder": "Personal phone", 35 - "generate": { 36 - "description": "Your passphrase acts as your username and password. Make sure to keep it safe as you will need to enter it to login to your account. <bold>Do NOT lose your passphrase!</bold>", 37 - "next": "I have saved my passphrase", 38 - "passphraseFrameLabel": "Passphrase", 39 - "title": "Your passphrase" 40 - }, 41 - "hasAccount": "Already have an account? <0>Login here.</0>", 42 - "login": { 43 - "description": "Please enter your passphrase to login to your account", 44 - "deviceLengthError": "Please enter a device name", 45 - "passphraseLabel": "12-Word passphrase", 46 - "passphrasePlaceholder": "Passphrase", 47 - "submit": "Login", 48 - "title": "Login to your account", 49 - "validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\" 50 - }, 51 - "register": { 52 - "information": { 53 - "color1": "Profile color one", 54 - "color2": "Profile color two", 55 - "header": "Enter a name for your device then pick colors and a user icon of your choosing!", 56 - "icon": "User icon", 57 - "next": "Next", 58 - "title": "Account information" 59 - } 60 - }, 61 - "trust": { 62 - "failed": { 63 - "text": "Did you configure it correctly?", 64 - "title": "Failed to reach server" 65 - }, 66 - "noHostTitle": "Server not configured!", 67 - "noHost": "The server has not been configured, therefore you cannot create an account", 68 - "host": "You are connecting to <0>{{hostname}}</0> - please confirm you trust it before making an account...", 69 - "no": "Go back", 70 - "title": "Do you trust this server?", 71 - "yes": "I trust this server 🤞" 72 - }, 73 - "verify": { 74 - "description": "Please enter your passphrase from earlier to confirm you have saved it and to create your account", 75 - "invalidData": "Data is not valid", 76 - "noMatch": "Passphrase doesn't match 😭", 77 - "passphraseLabel": "Your 12-word passphrase", 78 - "recaptchaFailed": "ReCaptcha validation failed", 79 - "register": "Create account", 80 - "title": "Confirm your passphrase" 81 - } 13 + "q3": { 14 + "body": "Our search results are powered by The Movie Database (TMDB) and display regardless of whether our sources actually have the content.", 15 + "title": "The search results display the show or movie, why can't I play it?" 82 16 }, 83 - "errors": { 84 - "badge": "It broke 💀", 85 - "details": "Error details", 86 - "reloadPage": "Reload the page", 87 - "showError": "Show error details", 88 - "title": "We encountered an error!" 17 + "q4": { 18 + "body": "All data is synced to the community backend, anyone is free to use this as well.", 19 + "title": "What about my data and stuff?" 89 20 }, 90 - "footer": { 91 - "legal": { 92 - "disclaimer": "Disclaimer ◝(ᵔᵕᵔ)◜", 93 - "disclaimerText": "Please note: P-Stream does not host any files itself but instead only display's content from 3rd party providers. Legal issues should be taken up with them." 94 - }, 95 - "links": { 96 - "discord": "Discord", 97 - "dmca": "DMCA", 98 - "github": "GitHub", 99 - "twitter": "Twitter", 100 - "funding": "Support us" 101 - }, 102 - "tagline": "Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)" 21 + "q5": { 22 + "body": "P-Stream has a Discord server that can be found at the header of this page!", 23 + "title": "How can I find out more?" 103 24 }, 104 - "global": { 105 - "name": "P-Stream", 106 - "pages": { 107 - "about": "About", 108 - "dmca": "DMCA", 109 - "discover": "Discover", 110 - "support": "Support", 111 - "login": "Login", 112 - "onboarding": "Setup", 113 - "pagetitle": "{{title}} - P-Stream", 114 - "register": "Register", 115 - "settings": "Settings", 116 - "migration": "Migrate Account", 117 - "jip": "Jip" 118 - } 25 + "title": "About P-Stream (^▽^)" 26 + }, 27 + "actions": { 28 + "copied": "Copied", 29 + "copy": "Copy" 30 + }, 31 + "auth": { 32 + "createAccount": "Don't have an account yet 😬 <0>Create an account.</0>", 33 + "deviceNameLabel": "Device name", 34 + "deviceNamePlaceholder": "Personal phone", 35 + "generate": { 36 + "description": "Your passphrase acts as your username and password. Make sure to keep it safe as you will need to enter it to login to your account. <bold>Do NOT lose your passphrase!</bold>", 37 + "next": "I have saved my passphrase", 38 + "passphraseFrameLabel": "Passphrase", 39 + "title": "Your passphrase" 40 + }, 41 + "hasAccount": "Already have an account? <0>Login here.</0>", 42 + "login": { 43 + "description": "Please enter your passphrase to login to your account", 44 + "deviceLengthError": "Please enter a device name", 45 + "passphraseLabel": "12-Word passphrase", 46 + "passphrasePlaceholder": "Passphrase", 47 + "submit": "Login", 48 + "title": "Login to your account", 49 + "validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\" 50 + }, 51 + "register": { 52 + "information": { 53 + "color1": "Profile color one", 54 + "color2": "Profile color two", 55 + "header": "Enter a name for your device then pick colors and a user icon of your choosing!", 56 + "icon": "User icon", 57 + "next": "Next", 58 + "title": "Account information" 59 + } 60 + }, 61 + "trust": { 62 + "failed": { 63 + "text": "Did you configure it correctly?", 64 + "title": "Failed to reach server" 65 + }, 66 + "noHostTitle": "Server not configured!", 67 + "noHost": "The server has not been configured, therefore you cannot create an account", 68 + "host": "You are connecting to <0>{{hostname}}</0> - please confirm you trust it before making an account...", 69 + "no": "Go back", 70 + "title": "Do you trust this server?", 71 + "yes": "I trust this server 🤞" 119 72 }, 120 - "home": { 121 - "bookmarks": { 122 - "sectionTitle": "Bookmarks" 123 - }, 124 - "continueWatching": { 125 - "sectionTitle": "Continue Watching..." 126 - }, 127 - "mediaList": { 128 - "stopEditing": "Stop editing" 129 - }, 130 - "search": { 131 - "allResults": "That's all we have...", 132 - "failed": "Failed to find media, try again!", 133 - "loading": "Loading...", 134 - "noResults": "We couldn't find anything :(", 135 - "placeholder": { 136 - "default": "What do you want to watch?", 137 - "extra": [ 138 - "What are you in the mood for?", 139 - "Should we delete your browser history?", 140 - "What do you want to stream?", 141 - "P-Stream is the best site ever!", 142 - "What's on your watchlist today?", 143 - "How was your day?", 144 - "My bad the site never works...", 145 - "Isn't P-Stream just the best?", 146 - ">ᴗ<" 147 - ] 148 - }, 149 - "empty": { 150 - "default": "Welcome, find media to watch here!", 151 - "extra": [ 152 - "There's nothing here :(", 153 - "So empty...", 154 - "Such emptiness.", 155 - "Hi new user :3" 156 - ] 157 - }, 158 - "sectionTitle": "Search results", 159 - "discoverMore": "Discover more", 160 - "discover": "Discover" 161 - }, 162 - "mediaCard": { 163 - "moreInfo": "More Info", 164 - "copyLink": "Copy Link", 165 - "close": "Close" 166 - }, 167 - "titles": { 168 - "day": { 169 - "default": "What would you like to watch this afternoon?", 170 - "extra": [ 171 - "Viva la P-Stream!" 172 - ] 173 - }, 174 - "morning": { 175 - "default": "What would you like to watch this morning?", 176 - "extra": [ 177 - "Viva la P-Stream!" 178 - ] 179 - }, 180 - "night": { 181 - "default": "What would you like to watch tonight?", 182 - "extra": [ 183 - "Viva la P-Stream!" 184 - ] 185 - }, 186 - "420": { 187 - "default": "What would you like to watch this 4/20?", 188 - "extra": [ 189 - "Happy 4/20 🥳!" 190 - ] 191 - }, 192 - "69": { 193 - "default": "Up for something spicy?", 194 - "extra": [ 195 - "Happy 69 day 😘!" 196 - ] 197 - } 198 - } 73 + "verify": { 74 + "description": "Please enter your passphrase from earlier to confirm you have saved it and to create your account", 75 + "invalidData": "Data is not valid", 76 + "noMatch": "Passphrase doesn't match 😭", 77 + "passphraseLabel": "Your 12-word passphrase", 78 + "recaptchaFailed": "ReCaptcha validation failed", 79 + "register": "Create account", 80 + "title": "Confirm your passphrase" 81 + } 82 + }, 83 + "errors": { 84 + "badge": "It broke 💀", 85 + "details": "Error details", 86 + "reloadPage": "Reload the page", 87 + "showError": "Show error details", 88 + "title": "We encountered an error!" 89 + }, 90 + "footer": { 91 + "legal": { 92 + "disclaimer": "Disclaimer ◝(ᵔᵕᵔ)◜", 93 + "disclaimerText": "Please note: P-Stream does not host any files itself but instead only display's content from 3rd party providers. Legal issues should be taken up with them." 94 + }, 95 + "links": { 96 + "discord": "Discord", 97 + "dmca": "DMCA", 98 + "github": "GitHub", 99 + "twitter": "Twitter", 100 + "funding": "Support us" 101 + }, 102 + "tagline": "Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)" 103 + }, 104 + "global": { 105 + "name": "P-Stream", 106 + "pages": { 107 + "about": "About", 108 + "dmca": "DMCA", 109 + "discover": "Discover", 110 + "support": "Support", 111 + "login": "Login", 112 + "onboarding": "Setup", 113 + "pagetitle": "{{title}} - P-Stream", 114 + "register": "Register", 115 + "settings": "Settings", 116 + "migration": "Migrate Account", 117 + "jip": "Jip" 118 + } 119 + }, 120 + "home": { 121 + "bookmarks": { 122 + "sectionTitle": "Bookmarks" 123 + }, 124 + "continueWatching": { 125 + "sectionTitle": "Continue Watching..." 126 + }, 127 + "mediaList": { 128 + "stopEditing": "Stop editing" 129 + }, 130 + "search": { 131 + "allResults": "That's all we have...", 132 + "failed": "Failed to find media, try again!", 133 + "loading": "Loading...", 134 + "noResults": "We couldn't find anything :(", 135 + "placeholder": { 136 + "default": "What do you want to watch?", 137 + "extra": [ 138 + "What are you in the mood for?", 139 + "Should we delete your browser history?", 140 + "What do you want to stream?", 141 + "P-Stream is the best site ever!", 142 + "What's on your watchlist today?", 143 + "How was your day?", 144 + "My bad the site never works...", 145 + "Isn't P-Stream just the best?", 146 + ">ᴗ<" 147 + ] 148 + }, 149 + "empty": { 150 + "default": "Welcome, find media to watch here!", 151 + "extra": [ 152 + "There's nothing here :(", 153 + "So empty...", 154 + "Such emptiness.", 155 + "Hi new user :3" 156 + ] 157 + }, 158 + "sectionTitle": "Search results", 159 + "discoverMore": "Discover more", 160 + "discover": "Discover" 199 161 }, 200 - "media": { 201 - "episodeDisplay": "S{{season}} - E{{episode}}", 202 - "unreleased": "Unreleased", 203 - "types": { 204 - "movie": "Movie", 205 - "show": "Show" 206 - }, 207 - "episodeShort": "E", 208 - "seasonShort": "S" 162 + "mediaCard": { 163 + "moreInfo": "More Info", 164 + "copyLink": "Copy Link", 165 + "close": "Close" 209 166 }, 210 - "details": { 211 - "resume": "Resume", 212 - "play": "Play", 213 - "director": "Director:", 214 - "cast": "Cast:", 215 - "runtime": "Runtime:", 216 - "language": "Language:", 217 - "releaseDate": "Release Date:", 218 - "rating": "Rating:", 219 - "votes": "votes", 220 - "tmdb": "View on TMDB", 221 - "imdb": "View on IMDb", 222 - "episodes": "Episodes", 223 - "season": "Season", 224 - "episode": "Episode", 225 - "airs": "Airs", 226 - "endsAt": "Ends at {{time}}" 167 + "titles": { 168 + "day": { 169 + "default": "What would you like to watch this afternoon?", 170 + "extra": ["Viva la P-Stream!"] 171 + }, 172 + "morning": { 173 + "default": "What would you like to watch this morning?", 174 + "extra": ["Viva la P-Stream!"] 175 + }, 176 + "night": { 177 + "default": "What would you like to watch tonight?", 178 + "extra": ["Viva la P-Stream!"] 179 + }, 180 + "420": { 181 + "default": "What would you like to watch this 4/20?", 182 + "extra": ["Happy 4/20 🥳!"] 183 + }, 184 + "69": { 185 + "default": "Up for something spicy?", 186 + "extra": ["Happy 69 day 😘!"] 187 + } 188 + } 189 + }, 190 + "media": { 191 + "episodeDisplay": "S{{season}} - E{{episode}}", 192 + "unreleased": "Unreleased", 193 + "types": { 194 + "movie": "Movie", 195 + "show": "Show" 227 196 }, 228 - "migration": { 229 - "loginRequired": "You must be logged in to migrate your data! Please go back and login to continue.", 230 - "start": { 231 - "title": "Migrate your data", 232 - "explainer": "If you wish to migrate or backup your data, you can do so using the options below. This will allow you to keep your data when you switch backend servers.", 233 - "options": { 234 - "or": "or", 235 - "direct": { 236 - "description": "This will directly migrate your data to the new server. This is the fastest option. <br /><br />This option allows you to keep your passphrase the same!", 237 - "title": "Direct migration", 238 - "quality": "Easiest and fastest", 239 - "action": "Transfer data" 240 - }, 241 - "download": { 242 - "description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.", 243 - "title": "Download data", 244 - "quality": "More technical", 245 - "action": "Download data" 246 - }, 247 - "upload": { 248 - "title": "Upload Data", 249 - "description": "Upload your previously exported data file to restore your bookmarks and progress on this account.", 250 - "quality": "Restore from backup", 251 - "action": "Upload Data" 252 - } 253 - } 254 - }, 197 + "episodeShort": "E", 198 + "seasonShort": "S" 199 + }, 200 + "details": { 201 + "resume": "Resume", 202 + "play": "Play", 203 + "director": "Director:", 204 + "cast": "Cast:", 205 + "runtime": "Runtime:", 206 + "language": "Language:", 207 + "releaseDate": "Release Date:", 208 + "rating": "Rating:", 209 + "votes": "votes", 210 + "tmdb": "View on TMDB", 211 + "imdb": "View on IMDb", 212 + "episodes": "Episodes", 213 + "season": "Season", 214 + "episode": "Episode", 215 + "airs": "Airs", 216 + "endsAt": "Ends at {{time}}" 217 + }, 218 + "migration": { 219 + "loginRequired": "You must be logged in to migrate your data! Please go back and login to continue.", 220 + "start": { 221 + "title": "Migrate your data", 222 + "explainer": "If you wish to migrate or backup your data, you can do so using the options below. This will allow you to keep your data when you switch backend servers.", 223 + "options": { 224 + "or": "or", 255 225 "direct": { 256 - "title": "Direct migration", 257 - "description": "Enter the destination backend URL to migrate your current account data to a new backend. This keeps your passphrase the same!", 258 - "backendLabel": "Destination Backend URL", 259 - "recaptchaLabel": "ReCaptcha Key (Optional)", 260 - "toggleLable": "Needs ReCaptcha?", 261 - "status": { 262 - "error": "Failed to migrate your data. 😿", 263 - "success": "Your data has been migrated successfully! 🎉" 264 - }, 265 - "button": { 266 - "migrate": "Migrate", 267 - "processing": "Processing...", 268 - "home": "Go home", 269 - "login": "Continue to login" 270 - } 226 + "description": "This will directly migrate your data to the new server. This is the fastest option. <br /><br />This option allows you to keep your passphrase the same!", 227 + "title": "Direct migration", 228 + "quality": "Easiest and fastest", 229 + "action": "Transfer data" 271 230 }, 272 231 "download": { 273 - "title": "Download data", 274 - "description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.", 275 - "items": { 276 - "description": "Download includes:", 277 - "bookmarks": "Bookmarked media", 278 - "progress": "Watch progress" 279 - }, 280 - "status": { 281 - "error": "Failed to download your data. 😿", 282 - "success": "Your data has been downloaded successfully! 🎉" 283 - }, 284 - "button": { 285 - "download": "Download data", 286 - "home": "Go home", 287 - "login": "Continue to login" 288 - } 232 + "description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.", 233 + "title": "Download data", 234 + "quality": "More technical", 235 + "action": "Download data" 289 236 }, 290 237 "upload": { 291 - "title": "Upload data", 292 - "description": "Upload your previously exported data file to restore your bookmarks and progress on this account.", 293 - "status": { 294 - "processing": "Processing data...", 295 - "error": "Failed to upload your data. 😿", 296 - "success": "Your data has been uploaded successfully! 🎉" 297 - }, 298 - "file": { 299 - "description": "Select the file you want to upload", 300 - "select": "Select file", 301 - "change": "Change file", 302 - "name": "File name" 303 - }, 304 - "dataPreview": "Preview:", 305 - "items": { 306 - "bookmarks": "Bookmarked media", 307 - "progress": "Watch progress" 308 - }, 309 - "exportedOn": "Exported on", 310 - "button": { 311 - "import": "Import data", 312 - "processing": "Processing...", 313 - "success": "Import complete", 314 - "home": "Continue to home" 315 - } 316 - }, 317 - "back": "Go back" 238 + "title": "Upload Data", 239 + "description": "Upload your previously exported data file to restore your bookmarks and progress on this account.", 240 + "quality": "Restore from backup", 241 + "action": "Upload Data" 242 + } 243 + } 244 + }, 245 + "direct": { 246 + "title": "Direct migration", 247 + "description": "Enter the destination backend URL to migrate your current account data to a new backend. This keeps your passphrase the same!", 248 + "backendLabel": "Destination Backend URL", 249 + "recaptchaLabel": "ReCaptcha Key (Optional)", 250 + "toggleLable": "Needs ReCaptcha?", 251 + "status": { 252 + "error": "Failed to migrate your data. 😿", 253 + "success": "Your data has been migrated successfully! 🎉" 254 + }, 255 + "button": { 256 + "migrate": "Migrate", 257 + "processing": "Processing...", 258 + "home": "Go home", 259 + "login": "Continue to login" 260 + } 261 + }, 262 + "download": { 263 + "title": "Download data", 264 + "description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.", 265 + "items": { 266 + "description": "Download includes:", 267 + "bookmarks": "Bookmarked media", 268 + "progress": "Watch progress" 269 + }, 270 + "status": { 271 + "error": "Failed to download your data. 😿", 272 + "success": "Your data has been downloaded successfully! 🎉" 273 + }, 274 + "button": { 275 + "download": "Download data", 276 + "home": "Go home", 277 + "login": "Continue to login" 278 + } 279 + }, 280 + "upload": { 281 + "title": "Upload data", 282 + "description": "Upload your previously exported data file to restore your bookmarks and progress on this account.", 283 + "status": { 284 + "processing": "Processing data...", 285 + "error": "Failed to upload your data. 😿", 286 + "success": "Your data has been uploaded successfully! 🎉" 287 + }, 288 + "file": { 289 + "description": "Select the file you want to upload", 290 + "select": "Select file", 291 + "change": "Change file", 292 + "name": "File name" 293 + }, 294 + "dataPreview": "Preview:", 295 + "items": { 296 + "bookmarks": "Bookmarked media", 297 + "progress": "Watch progress" 298 + }, 299 + "exportedOn": "Exported on", 300 + "button": { 301 + "import": "Import data", 302 + "processing": "Processing...", 303 + "success": "Import complete", 304 + "home": "Continue to home" 305 + } 306 + }, 307 + "back": "Go back" 308 + }, 309 + "navigation": { 310 + "banner": { 311 + "offline": "Check your internet connection, silly goose! 🦢" 318 312 }, 319 - "navigation": { 320 - "banner": { 321 - "offline": "Check your internet connection, silly goose! 🦢" 322 - }, 323 - "menu": { 324 - "about": "About us", 325 - "logout": "Log out", 326 - "register": "Sync to Cloud", 327 - "settings": "Settings", 328 - "support": "Support", 329 - "discover": "Discover", 330 - "development": "Development" 331 - } 313 + "menu": { 314 + "about": "About us", 315 + "logout": "Log out", 316 + "register": "Sync to Cloud", 317 + "settings": "Settings", 318 + "support": "Support", 319 + "discover": "Discover", 320 + "development": "Development" 321 + } 322 + }, 323 + "notFound": { 324 + "badge": "Not found", 325 + "goHome": "Back to home", 326 + "reloadButton": "Try again", 327 + "message": "We looked everywhere: under the bins, in the closet, behind the proxy, but ultimately couldn't find the page you are looking for. (ಥ﹏ಥ)", 328 + "title": "Couldn't find that page" 329 + }, 330 + "downtimeNotice": { 331 + "badge": "Issues", 332 + "goHome": "Go home", 333 + "message": "P-Stream is experiencing issues with some providers again, if you cant find or play a show please change the source. Expect this error to persist throughout the below times.", 334 + "title": "Provider issues" 335 + }, 336 + "onboarding": { 337 + "defaultConfirm": { 338 + "cancel": "Cancel", 339 + "confirm": "Use default setup", 340 + "description": "The default setup does not have the best streams. You'll be missing out on the best sources!", 341 + "title": "Are you sure?" 332 342 }, 333 - "notFound": { 334 - "badge": "Not found", 335 - "goHome": "Back to home", 336 - "reloadButton": "Try again", 337 - "message": "We looked everywhere: under the bins, in the closet, behind the proxy, but ultimately couldn't find the page you are looking for. (ಥ﹏ಥ)", 338 - "title": "Couldn't find that page" 343 + "extension": { 344 + "back": "Go back", 345 + "explainer": "Using the browser extension, you can get the best streams we have to offer. With just a simple install. 👌", 346 + "explainerIos": "Unfortunately, the browser extension is not supported on iOS, Press <bold>Go back</bold> to choose another option.", 347 + "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.", 348 + "linkChrome": "Install Chrome extension", 349 + "linkFirefox": "Install Firefox extension", 350 + "notDetecting": "Installed on Chrome, but the site isn't detecting it? Try reloading the page!", 351 + "notDetectingAction": "Reload page", 352 + "status": { 353 + "disallowed": "Extension is not enabled for this page (,,>﹏<,,)", 354 + "disallowedAction": "Enable extension", 355 + "failed": "Failed to request status", 356 + "loading": "Waiting for you to install the extension", 357 + "outdated": "Extension version too old", 358 + "success": "Extension is working as expected!" 359 + }, 360 + "submit": "Continue", 361 + "title": "Let's start with an extension" 339 362 }, 340 - "downtimeNotice": { 341 - "badge": "Issues", 342 - "goHome": "Go home", 343 - "message": "P-Stream is experiencing issues with some providers again, if you cant find or play a show please change the source. Expect this error to persist throughout the below times.", 344 - "title": "Provider issues" 363 + "proxy": { 364 + "back": "Go back", 365 + "explainer": "With the proxy method, you can get great quality streams by making a self-service proxy.", 366 + "input": { 367 + "errorConnection": "Could not connect to proxy", 368 + "errorInvalidUrl": "Not a valid URL", 369 + "errorNotProxy": "Expected a proxy but got a website", 370 + "label": "Proxy URL", 371 + "placeholder": "https://" 372 + }, 373 + "link": "Learn how to make a proxy", 374 + "submit": "Submit proxy", 375 + "title": "Let's make a new proxy" 345 376 }, 346 - "onboarding": { 347 - "defaultConfirm": { 348 - "cancel": "Cancel", 349 - "confirm": "Use default setup", 350 - "description": "The default setup does not have the best streams. You'll be missing out on the best sources!", 351 - "title": "Are you sure?" 377 + "start": { 378 + "explainer": "To get the best streams possible, you will need to choose which streaming method you want to use.", 379 + "moreInfo": { 380 + "button": "More info", 381 + "title": "Understanding a setup", 382 + "explainer": { 383 + "intro": "P-Stream doesn't host videos. It relies on third-party websites for content, so you need to choose how it connects to those sites.", 384 + "options": "Your Options:", 385 + "extension": "1. Extension", 386 + "extensionDescription": "The extension gives you access to the most sources. It acts as a local proxy and can handle sites that need special cookies or headers to load.", 387 + "proxy": "2. Proxy", 388 + "proxyDescription": "The proxy scrapes media from other websites. It bypasses browser restrictions (like CORS) to allow scraping.", 389 + "default": "3. Default Setup", 390 + "defaultDescription": "Uses P-Stream's built-in proxy. It's the easiest option but might be slower due to shared bandwidth.", 391 + "fedapi": { 392 + "fedapi": "Additional: FED API (Private) token", 393 + "fedapiDescription": "Bring your own FREE Febbox account to gain access to FED API (Private), the best source with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" 394 + }, 395 + "outro": "If you have more questions on how this works, feel free to ask on the <0>P-Stream Discord</0> server!" 396 + }, 397 + "recommended": { 398 + "title": "Not sure what to choose?", 399 + "subtitle": "Recommended Configurations:", 400 + "desktop": { 401 + "title": "Desktop:", 402 + "description": "Extension + FED API (Private)" 403 + }, 404 + "iOS": { 405 + "title": "iOS:", 406 + "description": "Custom proxy + FED API (Private)" 407 + }, 408 + "android": { 409 + "title": "Android:", 410 + "description": "Extension + FED API (Private)" 411 + } 412 + } 413 + }, 414 + "options": { 415 + "or": "or", 416 + "default": { 417 + "text": "I don't want good quality streams, use the default setup." 352 418 }, 353 419 "extension": { 354 - "back": "Go back", 355 - "explainer": "Using the browser extension, you can get the best streams we have to offer. With just a simple install. 👌", 356 - "explainerIos": "Unfortunately, the browser extension is not supported on iOS, Press <bold>Go back</bold> to choose another option.", 357 - "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.", 358 - "linkChrome": "Install Chrome extension", 359 - "linkFirefox": "Install Firefox extension", 360 - "notDetecting": "Installed on Chrome, but the site isn't detecting it? Try reloading the page!", 361 - "notDetectingAction": "Reload page", 362 - "status": { 363 - "disallowed": "Extension is not enabled for this page (,,>﹏<,,)", 364 - "disallowedAction": "Enable extension", 365 - "failed": "Failed to request status", 366 - "loading": "Waiting for you to install the extension", 367 - "outdated": "Extension version too old", 368 - "success": "Extension is working as expected!" 369 - }, 370 - "submit": "Continue", 371 - "title": "Let's start with an extension" 420 + "action": "Install extension", 421 + "description": "Install browser extension and gain access to additional sources! Remember to enable it for this site.", 422 + "quality": "Best quality + More Sources", 423 + "title": "Browser extension" 372 424 }, 373 425 "proxy": { 374 - "back": "Go back", 375 - "explainer": "With the proxy method, you can get great quality streams by making a self-service proxy.", 376 - "input": { 377 - "errorConnection": "Could not connect to proxy", 378 - "errorInvalidUrl": "Not a valid URL", 379 - "errorNotProxy": "Expected a proxy but got a website", 380 - "label": "Proxy URL", 381 - "placeholder": "https://" 382 - }, 383 - "link": "Learn how to make a proxy", 384 - "submit": "Submit proxy", 385 - "title": "Let's make a new proxy" 386 - }, 387 - "start": { 388 - "explainer": "To get the best streams possible, you will need to choose which streaming method you want to use.", 389 - "moreInfo": { 390 - "button": "More info", 391 - "title": "Understanding a setup", 392 - "explainer": { 393 - "intro": "P-Stream doesn't host videos. It relies on third-party websites for content, so you need to choose how it connects to those sites.", 394 - "options": "Your Options:", 395 - "extension": "1. Extension", 396 - "extensionDescription": "The extension gives you access to the most sources. It acts as a local proxy and can handle sites that need special cookies or headers to load.", 397 - "proxy": "2. Proxy", 398 - "proxyDescription": "The proxy scrapes media from other websites. It bypasses browser restrictions (like CORS) to allow scraping.", 399 - "default": "3. Default Setup", 400 - "defaultDescription": "Uses P-Stream's built-in proxy. It's the easiest option but might be slower due to shared bandwidth.", 401 - "fedapi": { 402 - "fedapi": "Additional: FED API (Private) token", 403 - "fedapiDescription": "Bring your own FREE Febbox account to gain access to FED API (Private), the best source with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" 404 - }, 405 - "outro": "If you have more questions on how this works, feel free to ask on the <0>P-Stream Discord</0> server!" 406 - }, 407 - "recommended": { 408 - "title": "Not sure what to choose?", 409 - "subtitle": "Recommended Configurations:", 410 - "desktop": { 411 - "title": "Desktop:", 412 - "description": "Extension + FED API (Private)" 413 - }, 414 - "iOS": { 415 - "title": "iOS:", 416 - "description": "Custom proxy + FED API (Private)" 417 - }, 418 - "android": { 419 - "title": "Android:", 420 - "description": "Extension + FED API (Private)" 421 - } 422 - } 423 - }, 424 - "options": { 425 - "or": "or", 426 - "default": { 427 - "text": "I don't want good quality streams, use the default setup." 428 - }, 429 - "extension": { 430 - "action": "Install extension", 431 - "description": "Install browser extension and gain access to additional sources! Remember to enable it for this site.", 432 - "quality": "Best quality + More Sources", 433 - "title": "Browser extension" 434 - }, 435 - "proxy": { 436 - "action": "Setup proxy", 437 - "description": "Setup a free proxy in just 5 minutes! Improves loading reliability!", 438 - "quality": "Good quality", 439 - "title": "Custom proxy" 440 - } 441 - }, 442 - "title": "Let's get you setup with P-Stream 🥳" 426 + "action": "Setup proxy", 427 + "description": "Setup a free proxy in just 5 minutes! Improves loading reliability!", 428 + "quality": "Good quality", 429 + "title": "Custom proxy" 443 430 } 431 + }, 432 + "title": "Let's get you setup with P-Stream 🥳" 433 + } 434 + }, 435 + "overlays": { 436 + "close": "Close" 437 + }, 438 + "player": { 439 + "back": { 440 + "default": "Back to home", 441 + "short": "Back" 444 442 }, 445 - "overlays": { 446 - "close": "Close" 443 + "casting": { 444 + "enabled": "Casting to device 🎬" 447 445 }, 448 - "player": { 449 - "back": { 450 - "default": "Back to home", 451 - "short": "Back" 446 + "menus": { 447 + "downloads": { 448 + "button": "Atempt download", 449 + "hlsDownloader": "Or, go to the <0>hls downloader website</0> and paste the playlist URL from below.", 450 + "disclaimer": "Downloads are taken directly from the provider. P-Stream does not have control over how the downloads are provided.", 451 + "copyHlsPlaylist": "Copy HLS playlist link", 452 + "downloadSubtitle": "Download current subtitle", 453 + "downloadVideo": "Download video", 454 + "hlsDisclaimer": "Downloads are taken directly from the provider. P-Stream does not have control over how the downloads are provided.<br /><br />Please note you are downloading an HLS playlist, <bold>it is not recommended to download if you are not familiar with advanced streaming formats</bold>. Try different sources for different formats.", 455 + "onAndroid": { 456 + "1": "To download on Android, click the download button then, on the new page, <bold>tap and hold</bold> on the video, then select <bold>save</bold>.", 457 + "shortTitle": "Download / Android", 458 + "title": "Downloading on Android" 452 459 }, 453 - "casting": { 454 - "enabled": "Casting to device 🎬" 460 + "onIos": { 461 + "1": "To download on iOS, click the download button then, on the new page, click <bold><ios_share /></bold>, then <bold>Save to Files <ios_files /></bold>.", 462 + "shortTitle": "Download / iOS", 463 + "title": "Downloading on iOS" 455 464 }, 456 - "menus": { 457 - "downloads": { 458 - "button": "Atempt download", 459 - "hlsDownloader": "Or, go to the <0>hls downloader website</0> and paste the playlist URL from below.", 460 - "disclaimer": "Downloads are taken directly from the provider. P-Stream does not have control over how the downloads are provided.", 461 - "copyHlsPlaylist": "Copy HLS playlist link", 462 - "downloadSubtitle": "Download current subtitle", 463 - "downloadVideo": "Download video", 464 - "hlsDisclaimer": "Downloads are taken directly from the provider. P-Stream does not have control over how the downloads are provided.<br /><br />Please note you are downloading an HLS playlist, <bold>it is not recommended to download if you are not familiar with advanced streaming formats</bold>. Try different sources for different formats.", 465 - "onAndroid": { 466 - "1": "To download on Android, click the download button then, on the new page, <bold>tap and hold</bold> on the video, then select <bold>save</bold>.", 467 - "shortTitle": "Download / Android", 468 - "title": "Downloading on Android" 469 - }, 470 - "onIos": { 471 - "1": "To download on iOS, click the download button then, on the new page, click <bold><ios_share /></bold>, then <bold>Save to Files <ios_files /></bold>.", 472 - "shortTitle": "Download / iOS", 473 - "title": "Downloading on iOS" 474 - }, 475 - "onPc": { 476 - "1": "On PC, click the download button then, on the new page, right click the video and select <bold>Save video as</bold>", 477 - "shortTitle": "Download / PC", 478 - "title": "Downloading on PC" 479 - }, 480 - "title": "Download" 481 - }, 482 - "episodes": { 483 - "button": "Episodes", 484 - "emptyState": "There are no episodes in this season, check back later (sorry :3)...", 485 - "episodeBadge": "E{{episode}}", 486 - "loadingError": "Error loading season", 487 - "loadingList": "Loading...", 488 - "loadingTitle": "Loading...", 489 - "unairedEpisodes": "One or more episodes in this season have been disabled because they haven't been aired yet.", 490 - "seasons": "Seasons" 491 - }, 492 - "playback": { 493 - "speedLabel": "Playback speed", 494 - "title": "Playback settings" 495 - }, 496 - "quality": { 497 - "automaticLabel": "Automatic quality", 498 - "hint": "You can try <0>switching source</0> to get different quality options.", 499 - "iosNoQuality": "Due to Apple-defined (common IOS L) limitations, quality selection is not available on iOS for this source. You can try <0>switching to another source</0> to get different quality options.", 500 - "title": "Quality" 501 - }, 502 - "settings": { 503 - "downloadItem": "Download", 504 - "enableSubtitles": "Enable Subtitles", 505 - "experienceSection": "Viewing experience", 506 - "playbackItem": "Playback settings", 507 - "audioItem": "Audio", 508 - "qualityItem": "Quality", 509 - "sourceItem": "Video sources", 510 - "subtitleItem": "Subtitle settings", 511 - "videoSection": "Video settings" 512 - }, 513 - "sources": { 514 - "failed": { 515 - "text": "There was an error while trying to find any videos... Try a different source?", 516 - "title": "Failed to scrape" 517 - }, 518 - "noEmbeds": { 519 - "text": "We were unable to find any embeds, please try a different source.", 520 - "title": "No embeds found" 521 - }, 522 - "noStream": { 523 - "text": "This source has no streams for this movie or show. /ᐠ - ˕ -マ Ⳋ", 524 - "title": "No stream :(" 525 - }, 526 - "title": "Sources", 527 - "unknownOption": "Unknown", 528 - "editOrder": "Edit order" 529 - }, 530 - "subtitles": { 531 - "customChoice": "Drop or upload file", 532 - "customizeLabel": "Customize", 533 - "offChoice": "Off", 534 - "onChoice": "On", 535 - "SourceChoice": "Source Captions", 536 - "OpenSubtitlesChoice": "OpenSubtitles", 537 - "settings": { 538 - "backlink": "Custom subtitles", 539 - "delay": "Subtitle delay", 540 - "fixCapitals": "Fix capitalization" 541 - }, 542 - "title": "Subtitles", 543 - "unknownLanguage": "Unknown", 544 - "dropSubtitleFile": "Drop subtitle file here! >_<", 545 - "scrapeButton": "Scrape subtitles", 546 - "empty": "There are no provided subtitles for this.", 547 - "notFound": "None of the available options match your query", 548 - "useNativeSubtitles": "Use native video subtitles", 549 - "useNativeSubtitlesDescription": "May fix subtitles when casting and in PiP" 550 - }, 551 - "watchparty": { 552 - "watchpartyItem": "Watch Party", 553 - "notice": "Legacy Watch Party might not be available for some sources", 554 - "legacyWatchparty": "Use legacy Watch Party" 555 - } 465 + "onPc": { 466 + "1": "On PC, click the download button then, on the new page, right click the video and select <bold>Save video as</bold>", 467 + "shortTitle": "Download / PC", 468 + "title": "Downloading on PC" 556 469 }, 557 - "metadata": { 558 - "api": { 559 - "text": "Could not load API metadata, please check your internet connection.", 560 - "title": "Failed to load API metadata" 561 - }, 562 - "dmca": { 563 - "badge": "Removed", 564 - "text": "This media is no longer available due to a takedown notice or copyright claim. 😨", 565 - "title": "Media has been removed" 566 - }, 567 - "extensionPermission": { 568 - "badge": "Permission Missing", 569 - "button": "Use extension", 570 - "text": "You have the browser extension, but we need your permission to get started using the extension. (¬_¬)", 571 - "title": "Configure the extension" 572 - }, 573 - "failed": { 574 - "badge": "Failed", 575 - "homeButton": "Go home", 576 - "text": "Could not load the media's metadata from TMDB. Please check whether TMDB is down or blocked on your internet connection.", 577 - "title": "Failed to load metadata" 578 - }, 579 - "notFound": { 580 - "badge": "Not found", 581 - "homeButton": "Back to home", 582 - "text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL (naughty, naughty).", 583 - "title": "Couldn't find that media." 584 - } 470 + "title": "Download" 471 + }, 472 + "episodes": { 473 + "button": "Episodes", 474 + "emptyState": "There are no episodes in this season, check back later (sorry :3)...", 475 + "episodeBadge": "E{{episode}}", 476 + "loadingError": "Error loading season", 477 + "loadingList": "Loading...", 478 + "loadingTitle": "Loading...", 479 + "unairedEpisodes": "One or more episodes in this season have been disabled because they haven't been aired yet.", 480 + "seasons": "Seasons" 481 + }, 482 + "playback": { 483 + "speedLabel": "Playback speed", 484 + "title": "Playback settings" 485 + }, 486 + "quality": { 487 + "automaticLabel": "Automatic quality", 488 + "hint": "You can try <0>switching source</0> to get different quality options.", 489 + "iosNoQuality": "Due to Apple-defined (common IOS L) limitations, quality selection is not available on iOS for this source. You can try <0>switching to another source</0> to get different quality options.", 490 + "title": "Quality" 491 + }, 492 + "settings": { 493 + "downloadItem": "Download", 494 + "enableSubtitles": "Enable Subtitles", 495 + "experienceSection": "Viewing experience", 496 + "playbackItem": "Playback settings", 497 + "audioItem": "Audio", 498 + "qualityItem": "Quality", 499 + "sourceItem": "Video sources", 500 + "subtitleItem": "Subtitle settings", 501 + "videoSection": "Video settings" 502 + }, 503 + "sources": { 504 + "failed": { 505 + "text": "There was an error while trying to find any videos... Try a different source?", 506 + "title": "Failed to scrape" 585 507 }, 586 - "nextEpisode": { 587 - "replay": "Replay", 588 - "next": "Next episode", 589 - "nextSeason": "Next season" 508 + "noEmbeds": { 509 + "text": "We were unable to find any embeds, please try a different source.", 510 + "title": "No embeds found" 590 511 }, 591 - "playbackError": { 592 - "badge": "Playback error", 593 - "errors": { 594 - "errorAborted": "The fetching of the media was aborted by the user's request.", 595 - "errorDecode": "Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.", 596 - "errorGenericMedia": "Unknown media error occurred.", 597 - "errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.", 598 - "errorNotSupported": "The media or media provider object is not supported." 599 - }, 600 - "homeButton": "Go home", 601 - "text": "There was an error trying to play the media 😖. Please try again or try a different source!", 602 - "title": "Failed to play video!" 512 + "noStream": { 513 + "text": "This source has no streams for this movie or show. /ᐠ - ˕ -マ Ⳋ", 514 + "title": "No stream :(" 603 515 }, 604 - "scraping": { 605 - "items": { 606 - "failure": "Error occurred", 607 - "notFound": "Doesn't have the video (╥﹏╥)", 608 - "pending": "Checking for videos..." 609 - }, 610 - "notFound": { 611 - "badge": "Not found", 612 - "detailsButton": "Show details", 613 - "homeButton": "Go home", 614 - "discoverButton": "Discover more", 615 - "text": "We have searched through our providers and cannot find the media you are looking for! We do not host the media and have no control over what is available. Please click 'Show details' below for more details.", 616 - "title": "We couldn't find that" 617 - }, 618 - "extensionFailure": { 619 - "badge": "Extension disabled", 620 - "homeButton": "Go home", 621 - "enableExtension": "Enable extension", 622 - "title": "Please enable the extension", 623 - "text": "You've installed the P-Stream extension. To start using it, you need to enable the extension for this site." 624 - }, 625 - "tips": { 626 - "1": "Tap the gear icon to switch sources!", 627 - "2": "Tap the title to copy the link!", 628 - "3": "Tap and hold or hold SHIFT to show widescreen button instead of fullscreen!", 629 - "4": "Some sources work better than others!", 630 - "5": "Get the extension for more sources!", 631 - "6": "Hold bookmarks to edit or delete them!", 632 - "7": "Hold SHIFT and tap the title to copy the link with time!", 633 - "8": "Set a custom subtitle color!", 634 - "9": "Migrate your account to a new backend in settings!", 635 - "10": "Join the Discord!", 636 - "11": "Use [ and ] to adjust subtitle timing!", 637 - "12": "Press SPACE or K to play/pause!", 638 - "13": "Use LEFT and RIGHT arrow keys to skip 5 seconds!", 639 - "14": "Use J and L keys to skip 10 seconds!", 640 - "15": "Press F to toggle fullscreen!", 641 - "16": "Press M to toggle mute!", 642 - "17": "Use UP and DOWN arrows to change volume!", 643 - "18": "Press < and > to change playback speed!", 644 - "19": "Press . and , to move frame by frame when paused!", 645 - "20": "Press C to toggle subtitles!", 646 - "21": "Press R to do a barrel roll!" 647 - } 516 + "title": "Sources", 517 + "unknownOption": "Unknown", 518 + "editOrder": "Edit order" 519 + }, 520 + "subtitles": { 521 + "customChoice": "Drop or upload file", 522 + "customizeLabel": "Customize", 523 + "offChoice": "Off", 524 + "onChoice": "On", 525 + "SourceChoice": "Source Captions", 526 + "OpenSubtitlesChoice": "OpenSubtitles", 527 + "settings": { 528 + "backlink": "Custom subtitles", 529 + "delay": "Subtitle delay", 530 + "fixCapitals": "Fix capitalization" 648 531 }, 649 - "time": { 650 - "regular": "{{timeWatched}} / {{duration}}", 651 - "remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}", 652 - "shortRegular": "{{timeWatched}}", 653 - "shortRemaining": "-{{timeLeft}}" 654 - }, 655 - "turnstile": { 656 - "description": "Please prove your humanity by completing the quick challenge, this is to keep P-Stream safe.", 657 - "error": "Failed to verify your humanity - stream failed to load. Clear your cache and try again, or switch to a different source (tap the gear).", 658 - "title": "Are You a Robot 🤖?", 659 - "verifyingHumanity": "Verifying your humanity... (^▽^)👍" 660 - } 532 + "title": "Subtitles", 533 + "unknownLanguage": "Unknown", 534 + "dropSubtitleFile": "Drop subtitle file here! >_<", 535 + "scrapeButton": "Scrape subtitles", 536 + "empty": "There are no provided subtitles for this.", 537 + "notFound": "None of the available options match your query", 538 + "useNativeSubtitles": "Use native video subtitles", 539 + "useNativeSubtitlesDescription": "May fix subtitles when casting and in PiP" 540 + }, 541 + "watchparty": { 542 + "watchpartyItem": "Watch Party", 543 + "notice": "Legacy Watch Party might not be available for some sources", 544 + "legacyWatchparty": "Use legacy Watch Party" 545 + } 661 546 }, 662 - "support": { 663 - "title": "Support", 664 - "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", 665 - "q1": { 666 - "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.", 667 - "title": "Where can I get help?" 668 - }, 669 - "q2": { 670 - "body": "We have a <0>GitHub</0> where you can create a detailed issue in our repository. Additionally, if you wish, you can create a pull request to fix the issue yourself.", 671 - "title": "How can I report a bug or issue?" 672 - } 547 + "metadata": { 548 + "api": { 549 + "text": "Could not load API metadata, please check your internet connection.", 550 + "title": "Failed to load API metadata" 551 + }, 552 + "dmca": { 553 + "badge": "Removed", 554 + "text": "This media is no longer available due to a takedown notice or copyright claim. 😨", 555 + "title": "Media has been removed" 556 + }, 557 + "extensionPermission": { 558 + "badge": "Permission Missing", 559 + "button": "Use extension", 560 + "text": "You have the browser extension, but we need your permission to get started using the extension. (¬_¬)", 561 + "title": "Configure the extension" 562 + }, 563 + "failed": { 564 + "badge": "Failed", 565 + "homeButton": "Go home", 566 + "text": "Could not load the media's metadata from TMDB. Please check whether TMDB is down or blocked on your internet connection.", 567 + "title": "Failed to load metadata" 568 + }, 569 + "notFound": { 570 + "badge": "Not found", 571 + "homeButton": "Back to home", 572 + "text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL (naughty, naughty).", 573 + "title": "Couldn't find that media." 574 + } 673 575 }, 674 - "jip": { 675 - "title": "Jip", 676 - "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).", 677 - "q1": { 678 - "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.", 679 - "title": "Where can I get help?" 680 - }, 681 - "q2": { 682 - "body": "We have a <0>GitHub</0> where you can create a detailed issue in our repository. Additionally, if you wish, you can create a pull request to fix the issue yourself.", 683 - "title": "How can I report a bug or issue?" 684 - } 576 + "nextEpisode": { 577 + "replay": "Replay", 578 + "next": "Next episode", 579 + "nextSeason": "Next season" 580 + }, 581 + "playbackError": { 582 + "badge": "Playback error", 583 + "errors": { 584 + "errorAborted": "The fetching of the media was aborted by the user's request.", 585 + "errorDecode": "Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.", 586 + "errorGenericMedia": "Unknown media error occurred.", 587 + "errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.", 588 + "errorNotSupported": "The media or media provider object is not supported." 589 + }, 590 + "homeButton": "Go home", 591 + "text": "There was an error trying to play the media 😖. Please try again or try a different source!", 592 + "title": "Failed to play video!" 593 + }, 594 + "scraping": { 595 + "items": { 596 + "failure": "Error occurred", 597 + "notFound": "Doesn't have the video (╥﹏╥)", 598 + "pending": "Checking for videos..." 599 + }, 600 + "notFound": { 601 + "badge": "Not found", 602 + "detailsButton": "Show details", 603 + "homeButton": "Go home", 604 + "discoverButton": "Discover more", 605 + "text": "We have searched through our providers and cannot find the media you are looking for! We do not host the media and have no control over what is available. Please click 'Show details' below for more details.", 606 + "title": "We couldn't find that" 607 + }, 608 + "extensionFailure": { 609 + "badge": "Extension disabled", 610 + "homeButton": "Go home", 611 + "enableExtension": "Enable extension", 612 + "title": "Please enable the extension", 613 + "text": "You've installed the P-Stream extension. To start using it, you need to enable the extension for this site." 614 + }, 615 + "tips": { 616 + "1": "Tap the gear icon to switch sources!", 617 + "2": "Tap the title to copy the link!", 618 + "3": "Tap and hold or hold SHIFT to show widescreen button instead of fullscreen!", 619 + "4": "Some sources work better than others!", 620 + "5": "Get the extension for more sources!", 621 + "6": "Hold bookmarks to edit or delete them!", 622 + "7": "Hold SHIFT and tap the title to copy the link with time!", 623 + "8": "Set a custom subtitle color!", 624 + "9": "Migrate your account to a new backend in settings!", 625 + "10": "Join the Discord!", 626 + "11": "Use [ and ] to adjust subtitle timing!", 627 + "12": "Press SPACE or K to play/pause!", 628 + "13": "Use LEFT and RIGHT arrow keys to skip 5 seconds!", 629 + "14": "Use J and L keys to skip 10 seconds!", 630 + "15": "Press F to toggle fullscreen!", 631 + "16": "Press M to toggle mute!", 632 + "17": "Use UP and DOWN arrows to change volume!", 633 + "18": "Press < and > to change playback speed!", 634 + "19": "Press . and , to move frame by frame when paused!", 635 + "20": "Press C to toggle subtitles!", 636 + "21": "Press R to do a barrel roll!" 637 + } 638 + }, 639 + "time": { 640 + "regular": "{{timeWatched}} / {{duration}}", 641 + "remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}", 642 + "shortRegular": "{{timeWatched}}", 643 + "shortRemaining": "-{{timeLeft}}" 644 + }, 645 + "turnstile": { 646 + "description": "Please prove your humanity by completing the quick challenge, this is to keep P-Stream safe.", 647 + "error": "Failed to verify your humanity - stream failed to load. Clear your cache and try again, or switch to a different source (tap the gear).", 648 + "title": "Are You a Robot 🤖?", 649 + "verifyingHumanity": "Verifying your humanity... (^▽^)👍" 650 + } 651 + }, 652 + "support": { 653 + "title": "Support", 654 + "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", 655 + "q1": { 656 + "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.", 657 + "title": "Where can I get help?" 658 + }, 659 + "q2": { 660 + "body": "We have a <0>GitHub</0> where you can create a detailed issue in our repository. Additionally, if you wish, you can create a pull request to fix the issue yourself.", 661 + "title": "How can I report a bug or issue?" 662 + } 663 + }, 664 + "jip": { 665 + "title": "Jip", 666 + "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).", 667 + "q1": { 668 + "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.", 669 + "title": "Where can I get help?" 670 + }, 671 + "q2": { 672 + "body": "We have a <0>GitHub</0> where you can create a detailed issue in our repository. Additionally, if you wish, you can create a pull request to fix the issue yourself.", 673 + "title": "How can I report a bug or issue?" 674 + } 675 + }, 676 + "screens": { 677 + "dmca": { 678 + "title": "DMCA" 679 + }, 680 + "loadingApp": "Loading application", 681 + "loadingUser": "Loading your profile", 682 + "loadingUserError": { 683 + "logout": "Logout", 684 + "reset": "Reset custom server", 685 + "text": "Failed to load your profile", 686 + "reload": "Reload", 687 + "textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?" 685 688 }, 686 - "screens": { 687 - "dmca": { 688 - "title": "DMCA" 689 + "migration": { 690 + "failed": "Failed to migrate your data. 😿", 691 + "inProgress": "Please hold, we are migrating your data. This shouldn't take long..." 692 + } 693 + }, 694 + "settings": { 695 + "account": { 696 + "accountDetails": { 697 + "deviceNameLabel": "Device name", 698 + "deviceNamePlaceholder": "Personal phone", 699 + "editProfile": "Edit", 700 + "logoutButton": "Log out" 701 + }, 702 + "admin": { 703 + "title": "Admin panel", 704 + "text": "Utilize tools made for testing P-Stream's condition.", 705 + "button": "Check it out" 706 + }, 707 + "actions": { 708 + "delete": { 709 + "button": "Delete account", 710 + "confirmButton": "Delete account", 711 + "confirmDescription": "Are you sure you want to delete your account? All your data will be lost! ૮₍˶Ó﹏Ò ⑅₎ა", 712 + "confirmTitle": "Are you sure?", 713 + "text": "This action is irreversible. All data will be deleted and nothing can be recovered.", 714 + "title": "Delete account" 689 715 }, 690 - "loadingApp": "Loading application", 691 - "loadingUser": "Loading your profile", 692 - "loadingUserError": { 693 - "logout": "Logout", 694 - "reset": "Reset custom server", 695 - "text": "Failed to load your profile", 696 - "reload": "Reload", 697 - "textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?" 716 + "migration": { 717 + "title": "Account migration", 718 + "text": "Migrate your account to a new server or download your data.", 719 + "button": "Migrate account" 698 720 }, 699 - "migration": { 700 - "failed": "Failed to migrate your data. 😿", 701 - "inProgress": "Please hold, we are migrating your data. This shouldn't take long..." 721 + "title": "Actions", 722 + "logoutAllDevices": { 723 + "title": "End All Sessions", 724 + "text": "This will sign you out from all devices linked to your account.", 725 + "button": "Log Out of All Devices" 702 726 } 727 + }, 728 + "devices": { 729 + "deviceNameLabel": "Device name", 730 + "failed": "Failed to load sessions", 731 + "removeDevice": "Remove", 732 + "title": "Devices" 733 + }, 734 + "profile": { 735 + "finish": "Finish editing", 736 + "firstColor": "Profile color one", 737 + "secondColor": "Profile color two", 738 + "title": "Edit profile picture", 739 + "userIcon": "User icon" 740 + }, 741 + "register": { 742 + "cta": "Get started", 743 + "text": "Share your watch progress between devices and keep them synced. ( ̧⸝⸝⍢⸝⸝)ෆ", 744 + "title": "Sync to the Cloud" 745 + }, 746 + "title": "Account" 703 747 }, 704 - "settings": { 705 - "account": { 706 - "accountDetails": { 707 - "deviceNameLabel": "Device name", 708 - "deviceNamePlaceholder": "Personal phone", 709 - "editProfile": "Edit", 710 - "logoutButton": "Log out" 711 - }, 712 - "admin": { 713 - "title": "Admin panel", 714 - "text": "Utilize tools made for testing P-Stream's condition.", 715 - "button": "Check it out" 716 - }, 717 - "actions": { 718 - "delete": { 719 - "button": "Delete account", 720 - "confirmButton": "Delete account", 721 - "confirmDescription": "Are you sure you want to delete your account? All your data will be lost! ૮₍˶Ó﹏Ò ⑅₎ა", 722 - "confirmTitle": "Are you sure?", 723 - "text": "This action is irreversible. All data will be deleted and nothing can be recovered.", 724 - "title": "Delete account" 725 - }, 726 - "migration": { 727 - "title": "Account migration", 728 - "text": "Migrate your account to a new server or download your data.", 729 - "button": "Migrate account" 730 - }, 731 - "title": "Actions", 732 - "logoutAllDevices": { 733 - "title": "End All Sessions", 734 - "text": "This will sign you out from all devices linked to your account.", 735 - "button": "Log Out of All Devices" 736 - } 737 - }, 738 - "devices": { 739 - "deviceNameLabel": "Device name", 740 - "failed": "Failed to load sessions", 741 - "removeDevice": "Remove", 742 - "title": "Devices" 743 - }, 744 - "profile": { 745 - "finish": "Finish editing", 746 - "firstColor": "Profile color one", 747 - "secondColor": "Profile color two", 748 - "title": "Edit profile picture", 749 - "userIcon": "User icon" 750 - }, 751 - "register": { 752 - "cta": "Get started", 753 - "text": "Share your watch progress between devices and keep them synced. ( ̧⸝⸝⍢⸝⸝)ෆ", 754 - "title": "Sync to the Cloud" 755 - }, 756 - "title": "Account" 757 - }, 758 - "appearance": { 759 - "activeTheme": "Active", 760 - "themes": { 761 - "blue": "Blue", 762 - "default": "Default", 763 - "gray": "Gray", 764 - "red": "Red", 765 - "teal": "Teal", 766 - "classic": "Classic", 767 - "green": "Green", 768 - "mocha": "Mocha", 769 - "pink": "Pink", 770 - "noir": "Noir", 771 - "ember": "Ember", 772 - "acid": "Acid", 773 - "spark": "Spark", 774 - "grape": "Grape", 775 - "spiderman": "Spiderman", 776 - "forest": "Forest", 777 - "wolverine": "Wolverine", 778 - "popsicle": "Popsicle", 779 - "hulk": "Hulk" 780 - }, 781 - "title": "Appearance", 782 - "options": { 783 - "discover": "Discover section", 784 - "discoverDescription": "Show the Discover section on the Homepage below your bookmarked media. Enabled by default.", 785 - "discoverLabel": "Discover section", 786 - "modal": "Details modal", 787 - "modalDescription": "Show the details modal when you click on a media card instead of going to the watch page. Proxy or Extension required for trailer. Disabled by default.", 788 - "modalLabel": "Details modal" 789 - } 790 - }, 791 - "connections": { 792 - "server": { 793 - "description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>", 794 - "label": "Custom server", 795 - "urlLabel": "Custom server URL", 796 - "migration": { 797 - "description": "<0>Migrate my data</0> to a new server.", 798 - "link": "Migrate my data" 799 - }, 800 - "documentation": "Backend documentation" 801 - }, 802 - "setup": { 803 - "doSetup": "Do setup", 804 - "errorStatus": { 805 - "description": "It seems that one or more items in this setup need your attention.", 806 - "title": "Something needs your attention 😱" 807 - }, 808 - "itemError": "There is something wrong with this setting. Go through setup again to fix it. (ᴗ_ ᴗ。)", 809 - "items": { 810 - "default": "Default setup", 811 - "extension": "Extension", 812 - "proxy": "Custom proxy" 813 - }, 814 - "redoSetup": "Redo setup", 815 - "successStatus": { 816 - "description": "All things are in place for you to start watching your favorite media. (๑>◡<๑)", 817 - "title": "Everything is set up!" 818 - }, 819 - "unsetStatus": { 820 - "description": "Please click the button to the right to start the setup process.", 821 - "title": "You haven't gone through setup" 822 - } 823 - }, 824 - "title": "Connections", 825 - "workers": { 826 - "addButton": "Add new worker", 827 - "description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers. <0>Instructions.</0>", 828 - "documentation": "Proxy documentation", 829 - "emptyState": "No workers yet (。•́︿•̀。), add one below", 830 - "label": "Use custom proxy workers", 831 - "urlLabel": "Worker URLs", 832 - "urlPlaceholder": "https://", 833 - "proxyTMDB": { 834 - "title": "Proxy TMDB", 835 - "description": "Only needed if you can't access TheMovieDB directly, such as if your ISP blocks it. It is recomended to disable the Discover secton to improve performance with this." 836 - } 837 - } 838 - }, 839 - "preferences": { 840 - "language": "Application language", 841 - "languageDescription": "Language applied to the entire application, only English has silly stuff 🙁.", 842 - "thumbnail": "Generate thumbnails", 843 - "thumbnailDescription": "Most of the time, videos don't have thumbnails. You can enable this setting to generate them on the fly but they can make your video slower.", 844 - "thumbnailLabel": "Generate thumbnails", 845 - "autoplay": "Autoplay", 846 - "autoplayDescription": "Automatically play the next episode in a series after reaching the end. Can be enabled by users with the browser extension, a custom proxy, or with the default setup if allowed by the host.", 847 - "autoplayLabel": "Autoplay", 848 - "skipCredits": "Skip End Credits", 849 - "skipCreditsDescription": "When enabled, automatically play the next episode at 99% completion to skip end credits. When disabled, wait until the episode is fully completed.", 850 - "skipCreditsLabel": "Skip end credits", 851 - "sourceOrder": "Reordering sources", 852 - "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>", 853 - "title": "Preferences", 854 - "sourceOrderEnableLabel": "Custom source order" 855 - }, 856 - "reset": "Reset", 857 - "save": "Save", 858 - "sidebar": { 859 - "info": { 860 - "appVersion": "App version", 861 - "backendUrl": "Backend URL", 862 - "backendVersion": "Backend version", 863 - "hostname": "Hostname", 864 - "insecure": "Insecure", 865 - "notLoggedIn": "You are not logged in", 866 - "secure": "Secure", 867 - "title": "App stats", 868 - "unknownVersion": "Unknown", 869 - "userId": "User ID" 870 - } 871 - }, 872 - "subtitles": { 873 - "backgroundLabel": "Background opacity", 874 - "backgroundBlurLabel": "Background blur", 875 - "colorLabel": "Color", 876 - "previewQuote": "Convinced life is meaningless, I lack the courage of my conviction.", 877 - "textSizeLabel": "Text size", 878 - "title": "Subtitles", 879 - "textBoldLabel": "Bold text", 880 - "verticalPositionLabel": "Vertical position", 881 - "default": "Default", 882 - "low": "Low", 883 - "textStyle": { 884 - "title": "Text style", 885 - "default": "Default", 886 - "raised": "Raised", 887 - "depressed": "Depressed", 888 - "uniform": "Uniform", 889 - "dropShadow": "Drop Shadow" 890 - } 891 - }, 892 - "unsaved": "You have unsaved changes... ฅ^•ﻌ•^ฅ" 748 + "appearance": { 749 + "activeTheme": "Active", 750 + "themes": { 751 + "blue": "Blue", 752 + "default": "Default", 753 + "gray": "Gray", 754 + "red": "Red", 755 + "teal": "Teal", 756 + "classic": "Classic", 757 + "green": "Green", 758 + "mocha": "Mocha", 759 + "pink": "Pink", 760 + "noir": "Noir", 761 + "ember": "Ember", 762 + "acid": "Acid", 763 + "spark": "Spark", 764 + "grape": "Grape", 765 + "spiderman": "Spiderman", 766 + "forest": "Forest", 767 + "wolverine": "Wolverine", 768 + "popsicle": "Popsicle", 769 + "hulk": "Hulk" 770 + }, 771 + "title": "Appearance", 772 + "options": { 773 + "discover": "Discover section", 774 + "discoverDescription": "Show the Discover section on the Homepage below your bookmarked media. Enabled by default.", 775 + "discoverLabel": "Discover section", 776 + "featured": "Featured media", 777 + "featuredDescription": "Show a carousel of featured movies and shows at the top of your homepage! Disabled by default.", 778 + "featuredLabel": "Featured media", 779 + "modal": "Details modal", 780 + "modalDescription": "Show the details modal when you click on a media card instead of going to the watch page. Proxy or Extension required for trailer. Disabled by default.", 781 + "modalLabel": "Details modal", 782 + "logos": "Image logos", 783 + "logosDescription": "Show image logos instead of text titles in the details modal and featured section. Enabled by default.", 784 + "logosNotice": "Most of the time, logos are English only. Other languages might want to disable this!", 785 + "logosLabel": "Image logos" 786 + } 893 787 }, 894 - "discover": { 895 - "tabs": { 896 - "movies": "Movies", 897 - "tvshows": "TV Shows", 898 - "editorpicks": "Editor Picks" 899 - }, 900 - "carousel": { 901 - "title": { 902 - "movies": "{{category}} Movies", 903 - "tvshows": "{{category}} Shows", 904 - "inCinemas": "In Cinemas", 905 - "popularOn": "Popular {{type}} on {{provider}}", 906 - "editorPicks": "Editor Picks" 907 - } 908 - }, 909 - "providers": { 910 - "netflix": "Netflix", 911 - "appleTv": "Apple TV+", 912 - "amazonPrime": "Amazon Prime Video", 913 - "hulu": "Hulu", 914 - "max": "Max", 915 - "paramountPlus": "Paramount Plus", 916 - "disneyPlus": "Disney Plus", 917 - "shudder": "Shudder", 918 - "fubuTV": "fubuTV" 788 + "connections": { 789 + "server": { 790 + "description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>", 791 + "label": "Custom server", 792 + "urlLabel": "Custom server URL", 793 + "migration": { 794 + "description": "<0>Migrate my data</0> to a new server.", 795 + "link": "Migrate my data" 919 796 }, 920 - "randomMovie": { 921 - "button": "Watch Something Random", 922 - "cancel": "Cancel Countdown", 923 - "countdown": "{{countdown}}s", 924 - "nowPlaying": "Now Playing", 925 - "in": "in" 926 - }, 927 - "page": { 928 - "title": "Discover Movies & TV", 929 - "subtitle": "Explore the latest hits and timeless classics." 797 + "documentation": "Backend documentation" 798 + }, 799 + "setup": { 800 + "doSetup": "Do setup", 801 + "errorStatus": { 802 + "description": "It seems that one or more items in this setup need your attention.", 803 + "title": "Something needs your attention 😱" 930 804 }, 931 - "scrollToTop": "Back to top" 932 - }, 933 - "fedapi": { 934 - "onboarding": { 935 - "title": "FED API (Private) token", 936 - "description": "Bring your own FREE Febbox account to gain access to FED API (Private), the best source with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" 805 + "itemError": "There is something wrong with this setting. Go through setup again to fix it. (ᴗ_ ᴗ。)", 806 + "items": { 807 + "default": "Default setup", 808 + "extension": "Extension", 809 + "proxy": "Custom proxy" 937 810 }, 938 - "setup": { 939 - "title": "To get your UI token:", 940 - "showVideo": "Show Video Tutorial", 941 - "hideVideo": "Hide Video Tutorial", 942 - "step": { 943 - "1": "1. Go to <0>febbox.com</0> and log in with Google (use a fresh account!)", 944 - "2": "2. Open DevTools or inspect the page", 945 - "3": "3. Go to Application tab → Cookies", 946 - "4": "4. Copy the 'ui' cookie.", 947 - "5": "5. Close the tab, but do NOT logout!", 948 - "warning": "(Do not share this token!)" 949 - } 811 + "redoSetup": "Redo setup", 812 + "successStatus": { 813 + "description": "All things are in place for you to start watching your favorite media. (๑>◡<๑)", 814 + "title": "Everything is set up!" 950 815 }, 951 - "status": { 952 - "success": "success", 953 - "api_down": "Cannot reach FED API!", 954 - "invalid_token": "Failed to fetch a 'VIP' stream. Your token is invalid!" 816 + "unsetStatus": { 817 + "description": "Please click the button to the right to start the setup process.", 818 + "title": "You haven't gone through setup" 819 + } 820 + }, 821 + "title": "Connections", 822 + "workers": { 823 + "addButton": "Add new worker", 824 + "description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers. <0>Instructions.</0>", 825 + "documentation": "Proxy documentation", 826 + "emptyState": "No workers yet (。•́︿•̀。), add one below", 827 + "label": "Use custom proxy workers", 828 + "urlLabel": "Worker URLs", 829 + "urlPlaceholder": "https://", 830 + "proxyTMDB": { 831 + "title": "Proxy TMDB", 832 + "description": "Only needed if you can't access TheMovieDB directly, such as if your ISP blocks it. It is recomended to disable the Discover secton to improve performance with this." 955 833 } 834 + } 956 835 }, 957 - "watchParty": { 958 - "status": { 959 - "inSync": "In sync", 960 - "outOfSync": "Out of sync" 961 - }, 962 - "alone": "Alone", 963 - "withCount": "With {{count}} others", 964 - "isHost": "Hosting on <0>{{backendName}}</0>", 965 - "isGuest": "Watching as a guest on <0>{{backendName}}</0>", 966 - "hosting": "Hosting", 967 - "watching": "Watching", 968 - "syncing": "Syncing...", 969 - "behindHost": "Behind host by {{seconds}} seconds", 970 - "aheadOfHost": "Ahead of host by {{seconds}} seconds", 971 - "showStatusOverlay": "Show status overlay", 972 - "leaveWatchParty": "Leave Watch Party", 973 - "shareCode": "Share this code with friends (click to copy)", 974 - "connectedAsGuest": "Connected to watch party as guest", 975 - "hostParty": "Host a Watch Party", 976 - "joinParty": "Join a Watch Party", 977 - "viewers": "Viewers ({{count}})", 978 - "copyCode": "Click to copy", 979 - "join": "Join", 980 - "cancel": "Cancel", 981 - "save": "Save", 982 - "emptyRoom": "No one is in this room yet", 983 - "invalidRoom": "Unable to connect to this room", 984 - "contentMismatch": "Cannot join watch party: The content does not match the host's content.", 985 - "episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.", 986 - "validating": "Validating watch party..." 836 + "preferences": { 837 + "language": "Application language", 838 + "languageDescription": "Language applied to the entire application, only English has silly stuff 🙁.", 839 + "thumbnail": "Generate thumbnails", 840 + "thumbnailDescription": "Most of the time, videos don't have thumbnails. You can enable this setting to generate them on the fly but they can make your video slower.", 841 + "thumbnailLabel": "Generate thumbnails", 842 + "autoplay": "Autoplay", 843 + "autoplayDescription": "Automatically play the next episode in a series after reaching the end. Can be enabled by users with the browser extension, a custom proxy, or with the default setup if allowed by the host.", 844 + "autoplayLabel": "Autoplay", 845 + "skipCredits": "Skip End Credits", 846 + "skipCreditsDescription": "When enabled, automatically play the next episode at 99% completion to skip end credits. When disabled, wait until the episode is fully completed.", 847 + "skipCreditsLabel": "Skip end credits", 848 + "sourceOrder": "Reordering sources", 849 + "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>", 850 + "title": "Preferences", 851 + "sourceOrderEnableLabel": "Custom source order" 852 + }, 853 + "reset": "Reset", 854 + "save": "Save", 855 + "sidebar": { 856 + "info": { 857 + "appVersion": "App version", 858 + "backendUrl": "Backend URL", 859 + "backendVersion": "Backend version", 860 + "hostname": "Hostname", 861 + "insecure": "Insecure", 862 + "notLoggedIn": "You are not logged in", 863 + "secure": "Secure", 864 + "title": "App stats", 865 + "unknownVersion": "Unknown", 866 + "userId": "User ID" 867 + } 868 + }, 869 + "subtitles": { 870 + "backgroundLabel": "Background opacity", 871 + "backgroundBlurLabel": "Background blur", 872 + "colorLabel": "Color", 873 + "previewQuote": "Convinced life is meaningless, I lack the courage of my conviction.", 874 + "textSizeLabel": "Text size", 875 + "title": "Subtitles", 876 + "textBoldLabel": "Bold text", 877 + "verticalPositionLabel": "Vertical position", 878 + "default": "Default", 879 + "low": "Low", 880 + "textStyle": { 881 + "title": "Text style", 882 + "default": "Default", 883 + "raised": "Raised", 884 + "depressed": "Depressed", 885 + "uniform": "Uniform", 886 + "dropShadow": "Drop Shadow" 887 + } 888 + }, 889 + "unsaved": "You have unsaved changes... ฅ^•ﻌ•^ฅ" 890 + }, 891 + "discover": { 892 + "tabs": { 893 + "movies": "Movies", 894 + "tvshows": "TV Shows", 895 + "editorpicks": "Editor Picks" 896 + }, 897 + "carousel": { 898 + "title": { 899 + "movies": "{{category}} Movies", 900 + "tvshows": "{{category}} Shows", 901 + "inCinemas": "In Cinemas", 902 + "popularOn": "Popular {{type}} on {{provider}}", 903 + "editorPicksMovies": "Editor Picks Movies", 904 + "editorPicksShows": "Editor Picks Shows", 905 + "moviesOn": "Movies on {{provider}}", 906 + "tvshowsOn": "Shows on {{provider}}", 907 + "recommended": "Because You Watched: {{title}}", 908 + "genreMovies": "{{genre}} Movies", 909 + "genreShows": "{{genre}} Shows", 910 + "categoryMovies": "{{category}} Movies", 911 + "categoryShows": "{{category}} Shows" 912 + }, 913 + "change": "Change", 914 + "more": "View more" 915 + }, 916 + "featured": { 917 + "playNow": "Play Now", 918 + "moreInfo": "More Info" 919 + }, 920 + "randomMovie": { 921 + "button": "Watch Something Random", 922 + "cancel": "Cancel Countdown", 923 + "countdown": "{{countdown}}s", 924 + "nowPlaying": "Now Playing", 925 + "in": "in" 926 + }, 927 + "page": { 928 + "title": "Discover Movies & TV", 929 + "subtitle": "Explore the latest hits and timeless classics.", 930 + "loadMore": "Load more", 931 + "loading": "Loading...", 932 + "back": "Go back" 933 + }, 934 + "scrollToTop": "Back to top" 935 + }, 936 + "fedapi": { 937 + "onboarding": { 938 + "title": "FED API (Private) token", 939 + "description": "Bring your own FREE Febbox account to gain access to FED API (Private), the best source with 4K quality, Dolby Atmos, skip intro and the fastest load times! Highly recommended option!" 940 + }, 941 + "setup": { 942 + "title": "To get your UI token:", 943 + "showVideo": "Show Video Tutorial", 944 + "hideVideo": "Hide Video Tutorial", 945 + "step": { 946 + "1": "1. Go to <0>febbox.com</0> and log in with Google (use a fresh account!)", 947 + "2": "2. Open DevTools or inspect the page", 948 + "3": "3. Go to Application tab → Cookies", 949 + "4": "4. Copy the 'ui' cookie.", 950 + "5": "5. Close the tab, but do NOT logout!", 951 + "warning": "(Do not share this token!)" 952 + } 953 + }, 954 + "status": { 955 + "success": "success", 956 + "api_down": "Cannot reach FED API!", 957 + "invalid_token": "Failed to fetch a 'VIP' stream. Your token is invalid!" 987 958 } 959 + }, 960 + "watchParty": { 961 + "status": { 962 + "inSync": "In sync", 963 + "outOfSync": "Out of sync" 964 + }, 965 + "alone": "Alone", 966 + "withCount": "With {{count}} others", 967 + "isHost": "Hosting on <0>{{backendName}}</0>", 968 + "isGuest": "Watching as a guest on <0>{{backendName}}</0>", 969 + "hosting": "Hosting", 970 + "watching": "Watching", 971 + "syncing": "Syncing...", 972 + "behindHost": "Behind host by {{seconds}} seconds", 973 + "aheadOfHost": "Ahead of host by {{seconds}} seconds", 974 + "showStatusOverlay": "Show status overlay", 975 + "leaveWatchParty": "Leave Watch Party", 976 + "shareCode": "Share this code with friends (click to copy)", 977 + "connectedAsGuest": "Connected to watch party as guest", 978 + "hostParty": "Host a Watch Party", 979 + "joinParty": "Join a Watch Party", 980 + "viewers": "Viewers ({{count}})", 981 + "copyCode": "Click to copy", 982 + "join": "Join", 983 + "cancel": "Cancel", 984 + "save": "Save", 985 + "emptyRoom": "No one is in this room yet", 986 + "invalidRoom": "Unable to connect to this room", 987 + "contentMismatch": "Cannot join watch party: The content does not match the host's content.", 988 + "episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.", 989 + "validating": "Validating watch party..." 990 + } 988 991 }
+4 -1
src/components/LinksDropdown.tsx
··· 114 114 return ( 115 115 <div className="relative is-dropdown"> 116 116 <div 117 - className="cursor-pointer tabbable rounded-full flex gap-2 text-white items-center py-2 px-3 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover backdrop-blur-lg transition-[background,transform] duration-100 hover:scale-105" 117 + className={classNames( 118 + "cursor-pointer tabbable rounded-full flex gap-2 text-white items-center py-2 px-3 bg-pill-background hover:bg-pill-backgroundHover backdrop-blur-lg transition-all duration-100 hover:scale-105", 119 + open ? "bg-opacity-100" : "bg-opacity-50", 120 + )} 118 121 tabIndex={0} 119 122 onClick={toggleOpen} 120 123 onKeyUp={(evt) => evt.key === "Enter" && toggleOpen()}
+9 -4
src/components/buttons/Button.tsx
··· 11 11 event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement, MouseEvent>, 12 12 ) => void; 13 13 children?: ReactNode; 14 - theme?: "white" | "purple" | "secondary" | "danger"; 14 + theme?: "white" | "purple" | "secondary" | "danger" | "glass"; 15 15 padding?: string; 16 16 className?: string; 17 17 href?: string; ··· 45 45 46 46 let colorClasses = "bg-white hover:bg-gray-200 text-black"; 47 47 if (props.theme === "purple") 48 - colorClasses = "bg-buttons-purple hover:bg-buttons-purpleHover text-white"; 48 + colorClasses = 49 + "bg-buttons-purple hover:bg-buttons-purpleHover text-white gap-2"; 49 50 if (props.theme === "secondary") 50 51 colorClasses = 51 - "bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white"; 52 + "bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white gap-2"; 52 53 if (props.theme === "danger") 53 - colorClasses = "bg-buttons-danger hover:bg-buttons-dangerHover text-white"; 54 + colorClasses = 55 + "bg-buttons-danger hover:bg-buttons-dangerHover text-white gap-2"; 56 + if (props.theme === "glass") 57 + colorClasses = 58 + "text-white hover:scale-105 bg-buttons-purple hover:bg-buttons-purpleHover bg-opacity-45 hover:bg-opacity-60 !backdrop-blur-md border-2 border-gray-400 border-opacity-20 gap-2"; 54 59 55 60 let classes = classNames( 56 61 "tabbable cursor-pointer inline-flex items-center justify-center rounded-lg font-medium transition-[transform,background-color] duration-100 active:scale-105 md:px-8",
+5 -1
src/components/buttons/IconPatch.tsx
··· 8 8 icon: Icons; 9 9 transparent?: boolean; 10 10 downsized?: boolean; 11 + navigation?: boolean; 11 12 } 12 13 13 14 export function IconPatch(props: IconPatchProps) { ··· 17 18 const transparentClasses = props.transparent 18 19 ? "bg-opacity-0 hover:bg-opacity-50" 19 20 : ""; 21 + const navigationClasses = props.navigation 22 + ? "bg-opacity-50 hover:bg-opacity-100" 23 + : ""; 20 24 const activeClasses = props.active 21 25 ? "bg-pill-backgroundHover text-white" 22 26 : ""; ··· 25 29 return ( 26 30 <div className={props.className || undefined} onClick={props.onClick}> 27 31 <div 28 - className={`flex items-center justify-center rounded-full border-2 border-transparent bg-pill-background bg-opacity-100 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses} ${sizeClasses}`} 32 + className={`flex items-center justify-center rounded-full border-2 border-transparent bg-pill-background bg-opacity-100 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${navigationClasses} ${clickableClasses} ${activeClasses} ${sizeClasses}`} 29 33 > 30 34 <Icon icon={props.icon} /> 31 35 </div>
+57 -42
src/components/form/Dropdown.tsx
··· 1 - import { Listbox, Transition } from "@headlessui/react"; 1 + import { Listbox } from "@headlessui/react"; 2 2 import React, { Fragment } from "react"; 3 3 4 4 import { Icon, Icons } from "@/components/Icon"; 5 + import { Transition } from "@/components/utils/Transition"; 5 6 6 7 export interface OptionItem { 7 8 id: string; ··· 14 15 setSelectedItem: (value: OptionItem) => void; 15 16 options: Array<OptionItem>; 16 17 direction?: "up" | "down"; 18 + side?: "left" | "right"; 19 + customButton?: React.ReactNode; 20 + customMenu?: React.ReactNode; 21 + className?: string; 22 + preventWrap?: boolean; 17 23 } 18 24 19 25 export function Dropdown(props: DropdownProps) { 20 - const { direction = "down" } = props; 26 + const { direction = "down", customButton, customMenu } = props; 21 27 22 28 return ( 23 - <div className="relative my-4 max-w-[25rem]"> 29 + <div className={`relative my-4 w-fit max-w-[25rem] ${props.className}`}> 24 30 <Listbox value={props.selectedItem} onChange={props.setSelectedItem}> 25 - {() => ( 31 + {({ open }) => ( 26 32 <> 27 - <Listbox.Button className="relative w-full rounded-lg bg-dropdown-background hover:bg-dropdown-hoverBackground py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer"> 28 - <span className="flex gap-4 items-center truncate"> 29 - {props.selectedItem.leftIcon 30 - ? props.selectedItem.leftIcon 31 - : null} 32 - {props.selectedItem.name} 33 - </span> 34 - <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> 35 - <Icon 36 - icon={Icons.UP_DOWN_ARROW} 37 - className={`transform transition-transform text-xl text-dropdown-secondary ${direction === "up" ? "rotate-180" : ""}`} 38 - /> 39 - </span> 40 - </Listbox.Button> 33 + {customButton ? ( 34 + <Listbox.Button as={Fragment}>{customButton}</Listbox.Button> 35 + ) : ( 36 + <Listbox.Button className="relative z-[101] w-full rounded-lg bg-dropdown-background hover:bg-dropdown-hoverBackground py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer"> 37 + <span className="flex gap-4 items-center truncate"> 38 + {props.selectedItem.leftIcon 39 + ? props.selectedItem.leftIcon 40 + : null} 41 + {props.selectedItem.name} 42 + </span> 43 + <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> 44 + <Icon 45 + icon={Icons.UP_DOWN_ARROW} 46 + className={`transform transition-transform text-xl text-dropdown-secondary ${direction === "up" ? "rotate-180" : ""}`} 47 + /> 48 + </span> 49 + </Listbox.Button> 50 + )} 41 51 <Transition 42 - as={Fragment} 43 - leave="transition ease-in duration-100" 44 - leaveFrom="opacity-100" 45 - leaveTo="opacity-0" 52 + animation="slide-down" 53 + show={open} 54 + className={`absolute z-[102] min-w-[20px] w-fit max-h-60 overflow-auto rounded-lg bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${ 55 + direction === "up" ? "bottom-full mb-4" : "top-full mt-1" 56 + } ${props.side === "right" ? "right-0" : "left-0"}`} 46 57 > 47 - <Listbox.Options 48 - className={`absolute left-0 right-0 z-[100] mt-4 max-h-60 overflow-auto rounded-md bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${direction === "up" ? "bottom-full mb-4" : "top-full"}`} 49 - > 50 - {props.options.map((opt) => ( 51 - <Listbox.Option 52 - className={({ active }) => 53 - `cursor-pointer flex gap-4 items-center relative select-none py-3 pl-4 pr-4 ${ 54 - active 55 - ? "bg-background-secondaryHover text-type-link" 56 - : "text-white" 57 - }` 58 - } 59 - key={opt.id} 60 - value={opt} 61 - > 62 - {opt.leftIcon ? opt.leftIcon : null} 63 - {opt.name} 64 - </Listbox.Option> 65 - ))} 66 - </Listbox.Options> 58 + {customMenu ? ( 59 + <Listbox.Options static as={Fragment}> 60 + {customMenu} 61 + </Listbox.Options> 62 + ) : ( 63 + <Listbox.Options static className="py-1"> 64 + {props.options.map((opt) => ( 65 + <Listbox.Option 66 + className={({ active }) => 67 + `cursor-pointer flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 68 + active 69 + ? "bg-background-secondaryHover text-type-link" 70 + : "text-type-secondary" 71 + } ${props.preventWrap ? "whitespace-nowrap" : ""}` 72 + } 73 + key={opt.id} 74 + value={opt} 75 + > 76 + {opt.leftIcon ? opt.leftIcon : null} 77 + {opt.name} 78 + </Listbox.Option> 79 + ))} 80 + </Listbox.Options> 81 + )} 67 82 </Transition> 68 83 </> 69 84 )}
+37 -6
src/components/form/SearchBar.tsx
··· 1 1 import c from "classnames"; 2 - import { forwardRef, useRef, useState } from "react"; 2 + import { forwardRef, useEffect, useRef, useState } from "react"; 3 3 4 4 import { Flare } from "@/components/utils/Flare"; 5 5 ··· 11 11 onChange: (value: string, force: boolean) => void; 12 12 onUnFocus: (newSearch?: string) => void; 13 13 value: string; 14 + isSticky?: boolean; 15 + isInFeatured?: boolean; 14 16 } 15 17 16 18 export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>( 17 19 (props, ref) => { 18 20 const [focused, setFocused] = useState(false); 21 + const [lightTheme, setLightTheme] = useState( 22 + Boolean(props.isInFeatured) && window.scrollY < 600, 23 + ); 19 24 const containerRef = useRef<HTMLDivElement>(null); 20 25 const [showTooltip, setShowTooltip] = useState(false); 21 26 ··· 23 28 props.onChange(value, true); 24 29 } 25 30 31 + useEffect(() => { 32 + const handleScroll = () => { 33 + setLightTheme(Boolean(props.isInFeatured) && window.scrollY < 600); 34 + }; 35 + window.addEventListener("scroll", handleScroll); 36 + return () => window.removeEventListener("scroll", handleScroll); 37 + }, [props.isInFeatured]); 38 + 26 39 return ( 27 40 <div ref={containerRef}> 28 41 <Flare.Base 29 42 className={c({ 30 - "hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative": 43 + "hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative backdrop-blur-sm": 31 44 true, 32 - "bg-search-background": !focused, 33 - "bg-search-focused": focused, 45 + "transition-colors duration-300": true, 46 + "bg-search-background/50": !focused && lightTheme, 47 + "bg-search-background": 48 + focused || props.isSticky || !props.isInFeatured, 34 49 })} 35 50 > 36 51 <Flare.Light ··· 45 60 /> 46 61 <Flare.Child className="flex flex-1 flex-col"> 47 62 <div 48 - className="absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon cursor-pointer z-10" 63 + className={c( 64 + "absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon cursor-pointer z-10", 65 + "transition-colors duration-300", 66 + props.isInFeatured 67 + ? lightTheme 68 + ? "text-white/50" 69 + : "" 70 + : "text-search-icon", 71 + )} 49 72 onClick={(e) => { 50 73 e.preventDefault(); 51 74 setShowTooltip(!showTooltip); ··· 66 89 onFocus={() => setFocused(true)} 67 90 onChange={(val) => setSearch(val)} 68 91 value={props.value} 69 - className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-search-text placeholder-search-placeholder focus:outline-none sm:py-4 sm:pr-2" 92 + className={c( 93 + "w-full flex-1 bg-transparent px-4 py-4 pl-12 !text-search-text focus:outline-none sm:py-4 sm:pr-2 transition-colors duration-300", 94 + "transition-colors duration-300", 95 + props.isInFeatured 96 + ? lightTheme 97 + ? "text-white/50" 98 + : "placeholder-search-placeholder" 99 + : "placeholder-search-placeholder", 100 + )} 70 101 placeholder={props.placeholder} 71 102 /> 72 103
+85 -17
src/components/layout/Navigation.tsx
··· 1 1 import classNames from "classnames"; 2 + import { useEffect, useState } from "react"; 2 3 import { Link, To, useNavigate } from "react-router-dom"; 3 4 4 5 import { NoUserAvatar, UserAvatar } from "@/components/Avatar"; ··· 17 18 bg?: boolean; 18 19 noLightbar?: boolean; 19 20 doBackground?: boolean; 21 + clearBackground?: boolean; 20 22 } 21 23 22 24 export function Navigation(props: NavigationProps) { 23 25 const bannerHeight = useBannerSize(); 24 26 const navigate = useNavigate(); 25 27 const { loggedIn } = useAuth(); 28 + const [scrollPosition, setScrollPosition] = useState(0); 29 + 30 + useEffect(() => { 31 + const handleScroll = () => { 32 + setScrollPosition(window.scrollY); 33 + }; 34 + 35 + window.addEventListener("scroll", handleScroll); 36 + return () => window.removeEventListener("scroll", handleScroll); 37 + }, []); 26 38 27 39 const handleClick = (path: To) => { 28 40 window.scrollTo(0, 0); 29 41 navigate(path); 30 42 }; 31 43 44 + // Calculate mask length based on scroll position 45 + const getMaskLength = () => { 46 + // When at top (0), use longer mask (200px) 47 + // When scrolled down (300px+), use shorter mask (100px) 48 + const maxScroll = 300; 49 + const minLength = 100; 50 + const maxLength = 180; 51 + const scrollFactor = Math.min(scrollPosition, maxScroll) / maxScroll; 52 + return minLength + (maxLength - minLength) * (1 - scrollFactor); 53 + }; 54 + 32 55 return ( 33 56 <> 34 57 {/* lightbar */} ··· 54 77 > 55 78 <div 56 79 className={classNames( 57 - "fixed left-0 right-0 top-0 flex items-center", 80 + "fixed left-0 right-0 top-0 flex items-center", // border-b border-utils-divider border-opacity-50 81 + "transition-[background-color,backdrop-filter] duration-300 ease-in-out", 58 82 props.doBackground 59 - ? "bg-background-main border-b border-utils-divider border-opacity-50" 60 - : null, 83 + ? props.clearBackground 84 + ? "backdrop-blur-md bg-transparent" 85 + : "bg-background-main" 86 + : "bg-transparent", 61 87 )} 62 88 > 63 89 {props.doBackground ? ( ··· 67 93 ) : null} 68 94 <div className="opacity-0 absolute inset-0 block h-20 pointer-events-auto" /> 69 95 <div 70 - className={`${ 71 - props.bg ? "opacity-100" : "opacity-0" 72 - } absolute inset-0 block h-24 bg-background-main transition-opacity duration-300`} 73 - > 74 - <div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" /> 75 - </div> 96 + className={classNames( 97 + "transition-[background-color,backdrop-filter,opacity] duration-300 ease-in-out", 98 + props.bg ? "opacity-100" : "opacity-0", 99 + "absolute inset-0 block h-[11rem]", 100 + props.clearBackground 101 + ? "backdrop-blur-md bg-transparent" 102 + : "bg-background-main", 103 + )} 104 + style={{ 105 + maskImage: `linear-gradient( 106 + to bottom, 107 + rgba(0, 0, 0, 1), 108 + rgba(0, 0, 0, 1) calc(100% - ${getMaskLength()}px), 109 + rgba(0, 0, 0, 0) 100% 110 + )`, 111 + WebkitMaskImage: `linear-gradient( 112 + to bottom, 113 + rgba(0, 0, 0, 1), 114 + rgba(0, 0, 0, 1) calc(100% - ${getMaskLength()}px), 115 + rgba(0, 0, 0, 0) 100% 116 + )`, 117 + }} 118 + /> 76 119 </div> 77 120 </div> 78 121 ··· 99 142 rel="noreferrer" 100 143 className="text-xl text-white tabbable rounded-full" 101 144 > 102 - <IconPatch icon={Icons.DISCORD} clickable downsized /> 145 + <IconPatch 146 + icon={Icons.DISCORD} 147 + clickable 148 + downsized 149 + navigation 150 + /> 103 151 </a> 104 - <a 105 - onClick={() => handleClick("/discover")} 106 - rel="noreferrer" 107 - className="text-xl text-white tabbable rounded-full" 108 - > 109 - <IconPatch icon={Icons.RISING_STAR} clickable downsized /> 110 - </a> 152 + {window.location.pathname !== "/discover" ? ( 153 + <a 154 + onClick={() => handleClick("/discover")} 155 + rel="noreferrer" 156 + className="text-xl text-white tabbable rounded-full" 157 + > 158 + <IconPatch 159 + icon={Icons.RISING_STAR} 160 + clickable 161 + downsized 162 + navigation 163 + /> 164 + </a> 165 + ) : ( 166 + <a 167 + onClick={() => handleClick("/")} 168 + rel="noreferrer" 169 + className="text-lg text-white tabbable rounded-full" 170 + > 171 + <IconPatch 172 + icon={Icons.SEARCH} 173 + clickable 174 + downsized 175 + navigation 176 + /> 177 + </a> 178 + )} 111 179 </div> 112 180 <div className="relative pointer-events-auto"> 113 181 <LinksDropdown>
+1 -1
src/components/layout/SectionHeading.tsx
··· 13 13 return ( 14 14 <div className={props.className}> 15 15 <div className="mb-5 flex items-center"> 16 - <p className="flex flex-1 items-center font-bold uppercase text-type-text"> 16 + <p className="flex flex-1 items-center font-bold uppercase text-type-text z-[19]"> 17 17 {props.icon ? ( 18 18 <span className="mr-2 text-xl"> 19 19 <Icon icon={props.icon} />
+1 -1
src/components/layout/WideContainer.tsx
··· 12 12 className={`mx-auto max-w-full px-8 ${ 13 13 props.ultraWide 14 14 ? "w-[1300px] xl:w-[18000px] 3xl:w-[2400px] 4xl:w-[2800px]" 15 - : "w-[900px] xl:w-[1200px] 3xl:w-[1600px] 4xl:w-[1800px]" 15 + : "w-[950px] xl:w-[1250px] 3xl:w-[1650px] 4xl:w-[1850px]" 16 16 } ${props.classNames || ""}`} 17 17 > 18 18 {props.children}
+6 -21
src/components/media/MediaCard.tsx
··· 254 254 <div> 255 255 <div className="absolute inset-0 flex flex-col items-center justify-start gap-y-2 pt-8 md:pt-12"> 256 256 <Button 257 - theme="secondary" 258 - className={classNames( 259 - "w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100", 260 - "text-md text-white flex items-center justify-center", 261 - "bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md", 262 - "border-2 border-gray-400 border-opacity-20", 263 - )} 257 + theme="glass" 258 + className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1" 264 259 onClick={(e) => { 265 260 e.preventDefault(); 266 261 e.stopPropagation(); ··· 272 267 273 268 {canLink ? ( 274 269 <Button 275 - theme="secondary" 276 - className={classNames( 277 - "w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100", 278 - "text-md text-white flex items-center justify-center", 279 - "bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md", 280 - "border-2 border-gray-400 border-opacity-20", 281 - )} 270 + theme="glass" 271 + className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1" 282 272 href={link} 283 273 onClick={handleCopyClick} 284 274 > ··· 294 284 ) : null} 295 285 296 286 <Button 297 - theme="secondary" 298 - className={classNames( 299 - "w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100", 300 - "text-md text-white flex items-center justify-center", 301 - "bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md", 302 - "border-2 border-gray-400 border-opacity-20", 303 - )} 287 + theme="glass" 288 + className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1" 304 289 onClick={() => setOverlayVisible(false)} 305 290 > 306 291 {t("home.mediaCard.close")}
+1 -1
src/components/media/MediaGrid.tsx
··· 8 8 (props, ref) => { 9 9 return ( 10 10 <div 11 - className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10" 11 + className="grid grid-cols-2 gap-7 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10" 12 12 ref={ref} 13 13 > 14 14 {props.children}
+16 -13
src/components/overlays/DetailsModal.tsx
··· 20 20 import { hasAired } from "@/components/player/utils/aired"; 21 21 import { useBookmarkStore } from "@/stores/bookmarks"; 22 22 import { useLanguageStore } from "@/stores/language"; 23 + import { usePreferencesStore } from "@/stores/preferences"; 23 24 import { useProgressStore } from "@/stores/progress"; 24 25 import { shouldShowProgress } from "@/stores/progress/utils"; 25 26 import { scrapeIMDb } from "@/utils/imdbScraper"; ··· 151 152 const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 152 153 const bookmarks = useBookmarkStore((s) => s.bookmarks); 153 154 const isBookmarked = !!bookmarks[data.id?.toString() ?? ""]; 155 + const enableImageLogos = usePreferencesStore( 156 + (state) => state.enableImageLogos, 157 + ); 154 158 155 159 const showProgress = useMemo(() => { 156 160 if (!data.id) return null; ··· 533 537 )} 534 538 </div> 535 539 {/* Content */} 536 - <div className="px-6 pb-6 mt-[-70px] flex-grow"> 540 + <div 541 + className={classNames( 542 + "px-6 pb-6 flex-grow relative", 543 + enableImageLogos ? "-mt-32" : "", 544 + )} 545 + > 537 546 {/* Title and Genres Row */} 538 - <div className="pb-2"> 539 - {data.logoUrl ? ( 547 + <div className="pb-4 relative z-10"> 548 + {data.logoUrl && enableImageLogos ? ( 540 549 <img 541 550 src={data.logoUrl} 542 551 alt={data.title} 543 - className="max-w-[12rem] md:max-w-[20rem] object-contain drop-shadow-lg bg-transparent" 552 + className="max-w-[12rem] md:max-w-[20rem] max-h-[14vh] object-contain drop-shadow-lg bg-transparent" 544 553 style={{ background: "none" }} 545 554 /> 546 555 ) : ( 547 - <h3 className="text-2xl font-bold text-white z-[999]"> 556 + <h3 className="text-3xl font-bold text-white z-[999]"> 548 557 {data.title} 549 558 </h3> 550 559 )} 551 560 </div> 552 - <div className="flex flex-col sm:flex-row justify-between items-start mb-6"> 561 + <div className="flex flex-col sm:flex-row justify-between items-start mb-6 relative z-10"> 553 562 <div className="flex items-center gap-4"> 554 563 {!minimal && ( 555 564 <Button ··· 571 580 } 572 581 } 573 582 }} 574 - theme="secondary" 575 - className={classNames( 576 - "gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100", 577 - "text-md text-white flex items-center justify-center", 578 - "bg-buttons-purple bg-opacity-45 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md", 579 - "border-2 border-gray-400 border-opacity-20", 580 - )} 583 + theme="glass" 581 584 > 582 585 <Icon icon={Icons.PLAY} className="text-white" /> 583 586 <span className="text-white text-sm pr-1">
+36 -38
src/components/player/atoms/settings/CaptionSettingsView.tsx
··· 339 339 <Menu.FieldTitle> 340 340 {t("settings.subtitles.textStyle.title") || "Font Style"} 341 341 </Menu.FieldTitle> 342 - <div className="w-64"> 343 - <Dropdown 344 - options={[ 345 - { 346 - id: "default", 347 - name: t("settings.subtitles.textStyle.default"), 348 - }, 349 - { 350 - id: "raised", 351 - name: t("settings.subtitles.textStyle.raised"), 352 - }, 353 - { 354 - id: "depressed", 355 - name: t("settings.subtitles.textStyle.depressed"), 356 - }, 357 - { 358 - id: "uniform", 359 - name: t("settings.subtitles.textStyle.uniform"), 360 - }, 361 - { 362 - id: "dropShadow", 363 - name: t("settings.subtitles.textStyle.dropShadow"), 364 - }, 365 - ]} 366 - selectedItem={{ 367 - id: styling.fontStyle, 368 - name: 369 - t(`settings.subtitles.textStyle.${styling.fontStyle}`) || 370 - styling.fontStyle, 371 - }} 372 - setSelectedItem={(item) => 373 - handleStylingChange({ 374 - ...styling, 375 - fontStyle: item.id, 376 - }) 377 - } 378 - /> 379 - </div> 342 + <Dropdown 343 + options={[ 344 + { 345 + id: "default", 346 + name: t("settings.subtitles.textStyle.default"), 347 + }, 348 + { 349 + id: "raised", 350 + name: t("settings.subtitles.textStyle.raised"), 351 + }, 352 + { 353 + id: "depressed", 354 + name: t("settings.subtitles.textStyle.depressed"), 355 + }, 356 + { 357 + id: "uniform", 358 + name: t("settings.subtitles.textStyle.uniform"), 359 + }, 360 + { 361 + id: "dropShadow", 362 + name: t("settings.subtitles.textStyle.dropShadow"), 363 + }, 364 + ]} 365 + selectedItem={{ 366 + id: styling.fontStyle, 367 + name: 368 + t(`settings.subtitles.textStyle.${styling.fontStyle}`) || 369 + styling.fontStyle, 370 + }} 371 + setSelectedItem={(item) => 372 + handleStylingChange({ 373 + ...styling, 374 + fontStyle: item.id, 375 + }) 376 + } 377 + /> 380 378 </div> 381 379 <div className="flex justify-between items-center"> 382 380 <Menu.FieldTitle>
+28
src/hooks/useSettingsState.ts
··· 54 54 enableThumbnails: boolean, 55 55 enableAutoplay: boolean, 56 56 enableDiscover: boolean, 57 + enableFeatured: boolean, 57 58 enableDetailsModal: boolean, 58 59 sourceOrder: string[], 59 60 enableSourceOrder: boolean, 60 61 proxyTmdb: boolean, 61 62 enableSkipCredits: boolean, 63 + enableImageLogos: boolean, 62 64 ) { 63 65 const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = 64 66 useDerived(proxyUrls); ··· 117 119 enableDiscoverChanged, 118 120 ] = useDerived(enableDiscover); 119 121 const [ 122 + enableFeaturedState, 123 + setEnableFeaturedState, 124 + resetEnableFeatured, 125 + enableFeaturedChanged, 126 + ] = useDerived(enableFeatured); 127 + const [ 120 128 enableDetailsModalState, 121 129 setEnableDetailsModalState, 122 130 resetEnableDetailsModal, 123 131 enableDetailsModalChanged, 124 132 ] = useDerived(enableDetailsModal); 133 + const [ 134 + enableImageLogosState, 135 + setEnableImageLogosState, 136 + resetEnableImageLogos, 137 + enableImageLogosChanged, 138 + ] = useDerived(enableImageLogos); 125 139 const [ 126 140 sourceOrderState, 127 141 setSourceOrderState, ··· 151 165 resetEnableAutoplay(); 152 166 resetEnableSkipCredits(); 153 167 resetEnableDiscover(); 168 + resetEnableFeatured(); 154 169 resetEnableDetailsModal(); 170 + resetEnableImageLogos(); 155 171 resetSourceOrder(); 156 172 resetEnableSourceOrder(); 157 173 resetProxyTmdb(); ··· 170 186 enableAutoplayChanged || 171 187 enableSkipCreditsChanged || 172 188 enableDiscoverChanged || 189 + enableFeaturedChanged || 173 190 enableDetailsModalChanged || 191 + enableImageLogosChanged || 174 192 sourceOrderChanged || 175 193 enableSourceOrderChanged || 176 194 proxyTmdbChanged; ··· 238 256 set: setEnableDiscoverState, 239 257 changed: enableDiscoverChanged, 240 258 }, 259 + enableFeatured: { 260 + state: enableFeaturedState, 261 + set: setEnableFeaturedState, 262 + changed: enableFeaturedChanged, 263 + }, 241 264 enableDetailsModal: { 242 265 state: enableDetailsModalState, 243 266 set: setEnableDetailsModalState, 244 267 changed: enableDetailsModalChanged, 268 + }, 269 + enableImageLogos: { 270 + state: enableImageLogosState, 271 + set: setEnableImageLogosState, 272 + changed: enableImageLogosChanged, 245 273 }, 246 274 sourceOrder: { 247 275 state: sourceOrderState,
+52 -16
src/pages/HomePage.tsx
··· 9 9 import { useDebounce } from "@/hooks/useDebounce"; 10 10 import { useRandomTranslation } from "@/hooks/useRandomTranslation"; 11 11 import { useSearchQuery } from "@/hooks/useSearchQuery"; 12 + import { FeaturedCarousel } from "@/pages/discover/components/FeaturedCarousel"; 13 + import type { FeaturedMedia } from "@/pages/discover/components/FeaturedCarousel"; 12 14 import DiscoverContent from "@/pages/discover/discoverContent"; 13 15 import { HomeLayout } from "@/pages/layouts/HomeLayout"; 14 16 import { BookmarksPart } from "@/pages/parts/home/BookmarksPart"; ··· 16 18 import { WatchingPart } from "@/pages/parts/home/WatchingPart"; 17 19 import { SearchListPart } from "@/pages/parts/search/SearchListPart"; 18 20 import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; 21 + import { conf } from "@/setup/config"; 19 22 import { usePreferencesStore } from "@/stores/preferences"; 20 23 import { MediaItem } from "@/utils/mediaTypes"; 21 24 22 25 import { Button } from "./About"; 26 + import { AdsPart } from "./parts/home/AdsPart"; 23 27 24 28 function useSearch(search: string) { 25 29 const [searching, setSearching] = useState<boolean>(false); ··· 29 33 useEffect(() => { 30 34 setSearching(search !== ""); 31 35 setLoading(search !== ""); 36 + if (search !== "") { 37 + window.scrollTo(0, 0); 38 + } 32 39 }, [search]); 33 40 useEffect(() => { 34 41 setLoading(false); ··· 54 61 const [showBookmarks, setShowBookmarks] = useState(false); 55 62 const [showWatching, setShowWatching] = useState(false); 56 63 const [detailsData, setDetailsData] = useState<any>(); 57 - // const [isLoadingDetails, setIsLoadingDetails] = useState(false); 58 64 const detailsModal = useModal("details"); 65 + const enableDiscover = usePreferencesStore((state) => state.enableDiscover); 66 + const enableFeatured = usePreferencesStore((state) => state.enableFeatured); 59 67 60 68 const handleClick = (path: To) => { 61 69 window.scrollTo(0, 0); 62 70 navigate(path); 63 71 }; 64 72 65 - const enableDiscover = usePreferencesStore((state) => state.enableDiscover); 66 - 67 - const handleShowDetails = async (media: MediaItem) => { 73 + const handleShowDetails = async (media: MediaItem | FeaturedMedia) => { 68 74 setDetailsData({ 69 75 id: Number(media.id), 70 76 type: media.type === "movie" ? "movie" : "show", ··· 89 95 <span className="font-bold select-none">READ</span> 90 96 </IconPill> 91 97 </a> */} 92 - <div className="mb-16 sm:mb-24"> 98 + <div className="mb-2"> 93 99 <Helmet> 94 100 <style type="text/css">{` 95 101 html, body { ··· 190 196 </div> 191 197 </FancyModal> 192 198 */} 193 - 194 - <HeroPart searchParams={searchParams} setIsSticky={setShowBg} /> 199 + {enableFeatured ? ( 200 + <FeaturedCarousel 201 + forcedCategory="editorpicks" 202 + onShowDetails={handleShowDetails} 203 + searching={s.searching} 204 + shorter 205 + > 206 + <HeroPart 207 + searchParams={searchParams} 208 + setIsSticky={setShowBg} 209 + isInFeatured 210 + /> 211 + </FeaturedCarousel> 212 + ) : ( 213 + <HeroPart 214 + searchParams={searchParams} 215 + setIsSticky={setShowBg} 216 + showTitle 217 + /> 218 + )} 219 + {conf().SHOW_AD ? <AdsPart /> : null} 195 220 </div> 196 221 <WideContainer> 197 222 {s.loading ? ( ··· 214 239 </div> 215 240 )} 216 241 {!(showBookmarks || showWatching) && !enableDiscover ? ( 217 - <div className="flex flex-col translate-y-[-30px] items-center justify-center"> 242 + <div className="flex flex-col translate-y-[-30px] items-center justify-center pt-20"> 218 243 <p className="text-[18.5px] pb-3">{emptyText}</p> 219 244 </div> 220 245 ) : null} 246 + {enableDiscover && 247 + (enableFeatured ? ( 248 + <div className="pb-4" /> 249 + ) : showBookmarks || showWatching ? ( 250 + <div className="pb-10" /> 251 + ) : ( 252 + <div className="pb-20" /> 253 + ))} 254 + {/* there... perfect. */} 221 255 </WideContainer> 222 - {enableDiscover ? ( 223 - <div className="pt-12 w-full max-w-[100dvw] justify-center items-center"> 256 + {enableDiscover && !search ? ( 257 + <div className="w-full max-w-[100dvw] justify-center items-center"> 224 258 <DiscoverContent /> 225 259 </div> 226 260 ) : ( 227 261 <div className="flex flex-col justify-center items-center h-40 space-y-4"> 228 262 <div className="flex flex-col items-center justify-center"> 229 - <Button 230 - className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center" 231 - onClick={() => handleClick("/discover")} 232 - > 233 - {t("home.search.discover")} 234 - </Button> 263 + {!search && ( 264 + <Button 265 + className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center" 266 + onClick={() => handleClick("/discover")} 267 + > 268 + {t("home.search.discover")} 269 + </Button> 270 + )} 235 271 </div> 236 272 </div> 237 273 )}
+16
src/pages/Settings.tsx
··· 151 151 const enableDiscover = usePreferencesStore((s) => s.enableDiscover); 152 152 const setEnableDiscover = usePreferencesStore((s) => s.setEnableDiscover); 153 153 154 + const enableFeatured = usePreferencesStore((s) => s.enableFeatured); 155 + const setEnableFeatured = usePreferencesStore((s) => s.setEnableFeatured); 156 + 154 157 const enableDetailsModal = usePreferencesStore((s) => s.enableDetailsModal); 155 158 const setEnableDetailsModal = usePreferencesStore( 156 159 (s) => s.setEnableDetailsModal, 157 160 ); 161 + 162 + const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos); 163 + const setEnableImageLogos = usePreferencesStore((s) => s.setEnableImageLogos); 158 164 159 165 const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder); 160 166 const setEnableSourceOrder = usePreferencesStore( ··· 201 207 enableThumbnails, 202 208 enableAutoplay, 203 209 enableDiscover, 210 + enableFeatured, 204 211 enableDetailsModal, 205 212 sourceOrder, 206 213 enableSourceOrder, 207 214 proxyTmdb, 208 215 enableSkipCredits, 216 + enableImageLogos, 209 217 ); 210 218 211 219 const availableSources = useMemo(() => { ··· 279 287 setEnableAutoplay(state.enableAutoplay.state); 280 288 setEnableSkipCredits(state.enableSkipCredits.state); 281 289 setEnableDiscover(state.enableDiscover.state); 290 + setEnableFeatured(state.enableFeatured.state); 282 291 setEnableDetailsModal(state.enableDetailsModal.state); 292 + setEnableImageLogos(state.enableImageLogos.state); 283 293 setSourceOrder(state.sourceOrder.state); 284 294 setAppLanguage(state.appLanguage.state); 285 295 setTheme(state.theme.state); ··· 313 323 setEnableAutoplay, 314 324 setEnableSkipCredits, 315 325 setEnableDiscover, 326 + setEnableFeatured, 316 327 setEnableDetailsModal, 328 + setEnableImageLogos, 317 329 setSourceOrder, 318 330 setAppLanguage, 319 331 setTheme, ··· 382 394 setTheme={setThemeWithPreview} 383 395 enableDiscover={state.enableDiscover.state} 384 396 setEnableDiscover={state.enableDiscover.set} 397 + enableFeatured={state.enableFeatured.state} 398 + setEnableFeatured={state.enableFeatured.set} 385 399 enableDetailsModal={state.enableDetailsModal.state} 386 400 setEnableDetailsModal={state.enableDetailsModal.set} 401 + enableImageLogos={state.enableImageLogos.state} 402 + setEnableImageLogos={state.enableImageLogos.set} 387 403 /> 388 404 </div> 389 405 <div id="settings-captions" className="mt-28">
+32 -22
src/pages/discover/Discover.tsx
··· 1 + import { useEffect, useState } from "react"; 1 2 import { Helmet } from "react-helmet-async"; 2 - import { useTranslation } from "react-i18next"; 3 3 4 - import DiscoverContent from "@/pages/discover/discoverContent"; 4 + import { DetailsModal } from "@/components/overlays/DetailsModal"; 5 + import { useModal } from "@/components/overlays/Modal"; 5 6 6 7 import { SubPageLayout } from "../layouts/SubPageLayout"; 8 + import { FeaturedCarousel } from "./components/FeaturedCarousel"; 9 + import type { FeaturedMedia } from "./components/FeaturedCarousel"; 10 + import DiscoverContent from "./discoverContent"; 7 11 import { PageTitle } from "../parts/util/PageTitle"; 8 12 9 13 export function Discover() { 10 - const { t } = useTranslation(); 14 + const [detailsData, setDetailsData] = useState<any>(); 15 + const detailsModal = useModal("discover-details"); 16 + 17 + // Clear details data when modal is closed 18 + useEffect(() => { 19 + if (!detailsModal.isShown) { 20 + setDetailsData(undefined); 21 + } 22 + }, [detailsModal.isShown]); 23 + 24 + const handleShowDetails = (media: FeaturedMedia) => { 25 + setDetailsData({ 26 + id: Number(media.id), 27 + type: media.type, 28 + }); 29 + detailsModal.show(); 30 + }; 11 31 12 32 return ( 13 33 <SubPageLayout> ··· 23 43 24 44 <PageTitle subpage k="global.pages.discover" /> 25 45 26 - <div className="relative w-full max-w-screen-xl mx-auto px-4 text-center mt-12 mb-12"> 27 - <div 28 - className="absolute inset-0 mx-auto h-[400px] max-w-[800px] rounded-full blur-[100px] opacity-20 transform -translate-y-[100px] pointer-events-none" 29 - style={{ 30 - backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`, 31 - }} 32 - /> 33 - <h1 34 - className="relative text-4xl md:text-5xl font-extrabold text-transparent bg-clip-text z-10" 35 - style={{ 36 - backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`, 37 - }} 38 - > 39 - {t("discover.page.title")} 40 - </h1> 41 - <p className="relative text-lg mt-4 text-gray-400 z-10"> 42 - {t("discover.page.subtitle")} 43 - </p> 46 + <div className="!mt-[-170px]"> 47 + {/* Featured Carousel */} 48 + <FeaturedCarousel onShowDetails={handleShowDetails} /> 49 + </div> 50 + 51 + {/* Main Content */} 52 + <div className="relative z-20"> 53 + <DiscoverContent /> 44 54 </div> 45 55 46 - <DiscoverContent /> 56 + {detailsData && <DetailsModal id="discover-details" data={detailsData} />} 47 57 </SubPageLayout> 48 58 ); 49 59 }
+669
src/pages/discover/MoreContent.tsx
··· 1 + import { Listbox } from "@headlessui/react"; 2 + import React, { useCallback, useEffect, useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + import { useNavigate, useParams } from "react-router-dom"; 5 + import { useWindowSize } from "react-use"; 6 + 7 + import { get } from "@/backend/metadata/tmdb"; 8 + import { Button } from "@/components/buttons/Button"; 9 + import { Dropdown, OptionItem } from "@/components/form/Dropdown"; 10 + import { Icon, Icons } from "@/components/Icon"; 11 + import { WideContainer } from "@/components/layout/WideContainer"; 12 + import { MediaCard } from "@/components/media/MediaCard"; 13 + import { MediaGrid } from "@/components/media/MediaGrid"; 14 + import { DetailsModal } from "@/components/overlays/DetailsModal"; 15 + import { useModal } from "@/components/overlays/Modal"; 16 + import { Heading1 } from "@/components/utils/Text"; 17 + import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 18 + import { conf } from "@/setup/config"; 19 + import { useDiscoverStore } from "@/stores/discover"; 20 + import { useLanguageStore } from "@/stores/language"; 21 + import { ProgressMediaItem, useProgressStore } from "@/stores/progress"; 22 + import { getTmdbLanguageCode } from "@/utils/language"; 23 + import { MediaItem } from "@/utils/mediaTypes"; 24 + 25 + import { Genre, categories, tvCategories } from "./common"; 26 + import { 27 + EDITOR_PICKS_MOVIES, 28 + EDITOR_PICKS_TV_SHOWS, 29 + MOVIE_PROVIDERS, 30 + TV_PROVIDERS, 31 + } from "./discoverContent"; 32 + 33 + interface MoreContentProps { 34 + onShowDetails?: (media: MediaItem) => void; 35 + } 36 + 37 + interface Provider { 38 + id: string; 39 + name: string; 40 + } 41 + 42 + export function MoreContent({ onShowDetails }: MoreContentProps) { 43 + const { category, type: contentType, id, mediaType } = useParams(); 44 + const [medias, setMedias] = useState<any[]>([]); 45 + const [loading, setLoading] = useState(true); 46 + const [loadingMore, setLoadingMore] = useState(false); 47 + const [currentPage, setCurrentPage] = useState(1); 48 + const [hasMore, setHasMore] = useState(true); 49 + const [detailsData, setDetailsData] = useState<any>(); 50 + const [genres, setGenres] = useState<Genre[]>([]); 51 + const [tvGenres, setTVGenres] = useState<Genre[]>([]); 52 + const [selectedProvider, setSelectedProvider] = useState<OptionItem | null>( 53 + null, 54 + ); 55 + const [selectedGenre, setSelectedGenre] = useState<OptionItem | null>(null); 56 + const { t } = useTranslation(); 57 + const navigate = useNavigate(); 58 + const detailsModal = useModal("discover-details"); 59 + const { lastView } = useDiscoverStore(); 60 + const userLanguage = useLanguageStore.getState().language; 61 + const formattedLanguage = getTmdbLanguageCode(userLanguage); 62 + const [sourceTitle, setSourceTitle] = useState(""); 63 + const progressStore = useProgressStore(); 64 + const { width: windowWidth } = useWindowSize(); 65 + const [recommendationSources, setRecommendationSources] = useState< 66 + Array<{ id: string; title: string }> 67 + >([]); 68 + const [selectedRecommendationSource, setSelectedRecommendationSource] = 69 + useState<string>(""); 70 + 71 + const handleBack = () => { 72 + if (lastView) { 73 + navigate(lastView.url); 74 + window.scrollTo(0, lastView.scrollPosition); 75 + } else { 76 + navigate(-1); 77 + } 78 + }; 79 + 80 + useEffect(() => { 81 + window.scrollTo(0, 0); 82 + }, []); 83 + 84 + // Fetch genres when component mounts 85 + useEffect(() => { 86 + const fetchGenres = async () => { 87 + try { 88 + const [movieData, tvData] = await Promise.all([ 89 + get<any>("/genre/movie/list", { 90 + api_key: conf().TMDB_READ_API_KEY, 91 + language: formattedLanguage, 92 + }), 93 + get<any>("/genre/tv/list", { 94 + api_key: conf().TMDB_READ_API_KEY, 95 + language: formattedLanguage, 96 + }), 97 + ]); 98 + setGenres(movieData.genres); 99 + setTVGenres(tvData.genres); 100 + } catch (error) { 101 + console.error("Error fetching genres:", error); 102 + } 103 + }; 104 + 105 + fetchGenres(); 106 + }, [formattedLanguage]); 107 + 108 + const handleShowDetails = async (media: MediaItem) => { 109 + if (onShowDetails) { 110 + onShowDetails(media); 111 + return; 112 + } 113 + setDetailsData({ 114 + id: Number(media.id), 115 + type: media.type === "movie" ? "movie" : "show", 116 + }); 117 + detailsModal.show(); 118 + }; 119 + 120 + const fetchContent = useCallback( 121 + async (page: number, append: boolean = false) => { 122 + try { 123 + const isTVShow = mediaType === "tv"; 124 + let endpoint = ""; 125 + 126 + // Handle recommendations separately 127 + if (contentType === "recommendations") { 128 + // Get title from progress store instead of fetching details 129 + const progressItem = progressStore.items[id || ""]; 130 + if (progressItem) { 131 + setSourceTitle(progressItem.title || ""); 132 + } 133 + 134 + // Get recommendations with proper page number 135 + const results = await get<any>( 136 + `/${isTVShow ? "tv" : "movie"}/${id}/recommendations`, 137 + { 138 + api_key: conf().TMDB_READ_API_KEY, 139 + language: formattedLanguage, 140 + page, 141 + }, 142 + ); 143 + 144 + if (append) { 145 + setMedias((prev) => [...prev, ...results.results]); 146 + } else { 147 + setMedias(results.results); 148 + } 149 + setHasMore(page < results.total_pages); 150 + setCurrentPage(page); 151 + return; 152 + } 153 + 154 + // Handle editor picks separately 155 + if (category?.includes("editor-picks")) { 156 + const editorPicks = isTVShow 157 + ? EDITOR_PICKS_TV_SHOWS 158 + : EDITOR_PICKS_MOVIES; 159 + 160 + // Fetch details for all editor picks 161 + const promises = editorPicks.map((item) => 162 + get<any>(`/${isTVShow ? "tv" : "movie"}/${item.id}`, { 163 + api_key: conf().TMDB_READ_API_KEY, 164 + language: formattedLanguage, 165 + }), 166 + ); 167 + 168 + const results = await Promise.all(promises); 169 + setMedias(results); 170 + setHasMore(false); 171 + return; 172 + } 173 + 174 + // Determine the correct endpoint based on the type 175 + if (contentType === "category") { 176 + const categoryList = isTVShow ? tvCategories : categories; 177 + const categoryData = categoryList.find((c) => c.urlPath === id); 178 + if (categoryData) { 179 + endpoint = categoryData.endpoint; 180 + } else { 181 + endpoint = isTVShow ? "/discover/tv" : "/discover/movie"; 182 + } 183 + } else { 184 + endpoint = isTVShow ? "/discover/tv" : "/discover/movie"; 185 + } 186 + 187 + const allResults: any[] = []; 188 + const pagesToFetch = 2; // Fetch 2 pages at a time 189 + 190 + for (let i = 0; i < pagesToFetch; i += 1) { 191 + const currentPageNum = page + i; 192 + const params: any = { 193 + api_key: conf().TMDB_READ_API_KEY, 194 + language: formattedLanguage, 195 + page: currentPageNum, 196 + }; 197 + 198 + if (contentType === "provider") { 199 + params.with_watch_providers = id; 200 + params.watch_region = "US"; 201 + } else if (contentType === "genre") { 202 + params.with_genres = id; 203 + } 204 + 205 + const data = await get<any>(endpoint, params); 206 + allResults.push(...data.results); 207 + 208 + // Check if we've reached the end 209 + if (currentPageNum >= data.total_pages) { 210 + setHasMore(false); 211 + break; 212 + } 213 + } 214 + 215 + if (append) { 216 + setMedias((prev) => [...prev, ...allResults]); 217 + } else { 218 + setMedias(allResults); 219 + } 220 + } catch (error) { 221 + console.error("Error fetching content:", error); 222 + } 223 + }, 224 + [ 225 + contentType, 226 + id, 227 + mediaType, 228 + category, 229 + formattedLanguage, 230 + progressStore.items, 231 + ], 232 + ); 233 + 234 + useEffect(() => { 235 + const loadInitialContent = async () => { 236 + setLoading(true); 237 + await fetchContent(1); 238 + setLoading(false); 239 + }; 240 + 241 + loadInitialContent(); 242 + }, [contentType, id, mediaType, category, formattedLanguage, fetchContent]); 243 + 244 + const handleLoadMore = async () => { 245 + setLoadingMore(true); 246 + const nextPage = 247 + contentType === "recommendations" ? currentPage + 1 : currentPage + 2; 248 + await fetchContent(nextPage, true); 249 + setCurrentPage(nextPage); 250 + setLoadingMore(false); 251 + }; 252 + 253 + const getDisplayTitle = () => { 254 + const isTVShow = mediaType === "tv"; 255 + 256 + if (contentType === "recommendations") { 257 + return t("discover.carousel.title.recommended", { 258 + title: sourceTitle, 259 + }); 260 + } 261 + 262 + if (category === "editor-picks-tv" || category === "editor-picks-movie") { 263 + return category === "editor-picks-tv" 264 + ? t("discover.carousel.title.editorPicksShows") 265 + : t("discover.carousel.title.editorPicksMovies"); 266 + } 267 + 268 + if (!contentType || !id) return ""; 269 + 270 + if (contentType === "provider") { 271 + const providers = isTVShow ? TV_PROVIDERS : MOVIE_PROVIDERS; 272 + const provider = providers.find((p: Provider) => p.id === id); 273 + return isTVShow 274 + ? t("discover.carousel.title.tvshowsOn", { 275 + provider: provider?.name, 276 + }) 277 + : t("discover.carousel.title.moviesOn", { 278 + provider: provider?.name, 279 + }); 280 + } 281 + 282 + if (contentType === "genre") { 283 + const genreList = isTVShow ? tvGenres : genres; 284 + const genre = genreList.find((g: Genre) => g.id.toString() === id); 285 + return isTVShow 286 + ? t("discover.carousel.title.genreShows", { genre: genre?.name || id }) 287 + : t("discover.carousel.title.genreMovies", { 288 + genre: genre?.name || id, 289 + }); 290 + } 291 + 292 + if (contentType === "category") { 293 + const categoryList = isTVShow ? tvCategories : categories; 294 + const categoryData = categoryList.find((c) => c.urlPath === id); 295 + if (categoryData) { 296 + return isTVShow 297 + ? t("discover.carousel.title.categoryShows", { 298 + category: categoryData.name, 299 + }) 300 + : t("discover.carousel.title.categoryMovies", { 301 + category: categoryData.name, 302 + }); 303 + } 304 + } 305 + }; 306 + 307 + useEffect(() => { 308 + if (contentType === "provider" && selectedProvider) { 309 + navigate(`/discover/more/provider/${selectedProvider.id}/${mediaType}`); 310 + } else if (contentType === "genre" && selectedGenre) { 311 + navigate(`/discover/more/genre/${selectedGenre.id}/${mediaType}`); 312 + } 313 + }, [selectedProvider, selectedGenre, contentType, mediaType, navigate]); 314 + 315 + useEffect(() => { 316 + if (contentType === "provider" && id) { 317 + const providers = mediaType === "tv" ? TV_PROVIDERS : MOVIE_PROVIDERS; 318 + const provider = providers.find((p) => p.id === id); 319 + if (provider) { 320 + setSelectedProvider({ id: provider.id, name: provider.name }); 321 + } 322 + } else if (contentType === "genre" && id) { 323 + const genreList = mediaType === "tv" ? tvGenres : genres; 324 + const genre = genreList.find((g) => g.id.toString() === id); 325 + if (genre) { 326 + setSelectedGenre({ id: genre.id.toString(), name: genre.name }); 327 + } 328 + } 329 + }, [contentType, id, mediaType, genres, tvGenres]); 330 + 331 + const providerButtons = useMemo(() => { 332 + if (contentType !== "provider") 333 + return { visibleButtons: [], dropdownButtons: [] }; 334 + const providers = mediaType === "tv" ? TV_PROVIDERS : MOVIE_PROVIDERS; 335 + const visible = 336 + windowWidth > 850 ? providers.slice(0, 7) : providers.slice(0, 2); 337 + const dropdown = 338 + windowWidth > 850 ? providers.slice(5) : providers.slice(0); 339 + return { visibleButtons: visible, dropdownButtons: dropdown }; 340 + }, [contentType, mediaType, windowWidth]); 341 + 342 + const genreButtons = useMemo(() => { 343 + if (contentType !== "genre") 344 + return { visibleButtons: [], dropdownButtons: [] }; 345 + const genreList = mediaType === "tv" ? tvGenres : genres; 346 + const visible = 347 + windowWidth > 850 ? genreList.slice(0, 7) : genreList.slice(0, 2); 348 + const dropdown = 349 + windowWidth > 850 ? genreList.slice(5) : genreList.slice(0); 350 + return { visibleButtons: visible, dropdownButtons: dropdown }; 351 + }, [contentType, mediaType, windowWidth, tvGenres, genres]); 352 + 353 + const renderProviderButtons = () => { 354 + if (contentType !== "provider") return null; 355 + const { visibleButtons, dropdownButtons } = providerButtons; 356 + 357 + return ( 358 + <div className="flex items-center space-x-2"> 359 + {visibleButtons.map((provider) => ( 360 + <button 361 + type="button" 362 + key={provider.id} 363 + onClick={() => 364 + setSelectedProvider({ id: provider.id, name: provider.name }) 365 + } 366 + className="px-3 py-1 text-sm rounded-full transition-colors whitespace-nowrap flex-shrink-0 bg-mediaCard-hoverBackground hover:bg-mediaCard-background" 367 + > 368 + {provider.name} 369 + </button> 370 + ))} 371 + {dropdownButtons.length > 0 && ( 372 + <div className="relative"> 373 + <Dropdown 374 + selectedItem={selectedProvider || { id: "", name: "..." }} 375 + setSelectedItem={(item) => setSelectedProvider(item)} 376 + options={dropdownButtons.map((p) => ({ id: p.id, name: p.name }))} 377 + customButton={ 378 + <button 379 + type="button" 380 + className="px-3 py-1 text-sm bg-mediaCard-hoverBackground hover:bg-mediaCard-background rounded-full transition-colors flex items-center gap-1" 381 + > 382 + <span>...</span> 383 + <Icon 384 + icon={Icons.UP_DOWN_ARROW} 385 + className="text-xs text-dropdown-secondary" 386 + /> 387 + </button> 388 + } 389 + side="right" 390 + /> 391 + </div> 392 + )} 393 + </div> 394 + ); 395 + }; 396 + 397 + const renderGenreButtons = () => { 398 + if (contentType !== "genre") return null; 399 + const { visibleButtons, dropdownButtons } = genreButtons; 400 + 401 + return ( 402 + <div className="flex items-center space-x-2"> 403 + {visibleButtons.map((genre) => ( 404 + <button 405 + type="button" 406 + key={genre.id} 407 + onClick={() => 408 + setSelectedGenre({ id: genre.id.toString(), name: genre.name }) 409 + } 410 + className="px-3 py-1 text-sm rounded-full transition-colors whitespace-nowrap flex-shrink-0 bg-mediaCard-hoverBackground hover:bg-mediaCard-background" 411 + > 412 + {genre.name} 413 + </button> 414 + ))} 415 + {dropdownButtons.length > 0 && ( 416 + <div className="relative"> 417 + <Dropdown 418 + selectedItem={selectedGenre || { id: "", name: "..." }} 419 + setSelectedItem={(item) => setSelectedGenre(item)} 420 + options={dropdownButtons.map((g) => ({ 421 + id: g.id.toString(), 422 + name: g.name, 423 + }))} 424 + customButton={ 425 + <button 426 + type="button" 427 + className="px-3 py-1 text-sm bg-mediaCard-hoverBackground hover:bg-mediaCard-background rounded-full transition-colors flex items-center gap-1" 428 + > 429 + <span>...</span> 430 + <Icon 431 + icon={Icons.UP_DOWN_ARROW} 432 + className="text-xs text-dropdown-secondary" 433 + /> 434 + </button> 435 + } 436 + side="right" 437 + /> 438 + </div> 439 + )} 440 + </div> 441 + ); 442 + }; 443 + 444 + // Add effect to set up recommendation sources 445 + useEffect(() => { 446 + const setupRecommendationSources = async () => { 447 + if ( 448 + contentType !== "recommendations" || 449 + !progressStore.items || 450 + Object.keys(progressStore.items).length === 0 451 + ) 452 + return; 453 + 454 + try { 455 + const progressItems = Object.entries(progressStore.items) as [ 456 + string, 457 + ProgressMediaItem, 458 + ][]; 459 + const items = progressItems.filter( 460 + ([_, item]) => item.type === (mediaType === "tv" ? "show" : "movie"), 461 + ); 462 + 463 + if (items.length > 0) { 464 + const sources = items.map(([itemId, item]) => ({ 465 + id: itemId, 466 + title: item.title || "", 467 + })); 468 + setRecommendationSources(sources); 469 + 470 + // Set initial source if not set 471 + if (!selectedRecommendationSource && sources.length > 0) { 472 + setSelectedRecommendationSource(sources[0].id); 473 + } 474 + } 475 + } catch (error) { 476 + console.error("Error setting up recommendation sources:", error); 477 + } 478 + }; 479 + 480 + setupRecommendationSources(); 481 + }, [ 482 + contentType, 483 + mediaType, 484 + progressStore.items, 485 + selectedRecommendationSource, 486 + ]); 487 + 488 + // Add effect to handle recommendation source changes 489 + useEffect(() => { 490 + if (contentType === "recommendations" && selectedRecommendationSource) { 491 + navigate( 492 + `/discover/more/recommendations/${selectedRecommendationSource}/${mediaType}`, 493 + ); 494 + } 495 + }, [selectedRecommendationSource, contentType, mediaType, navigate]); 496 + 497 + const renderRecommendationSourceDropdown = () => { 498 + if (contentType !== "recommendations" || recommendationSources.length === 0) 499 + return null; 500 + 501 + return ( 502 + <div className="flex items-center gap-2"> 503 + <div className="relative pr-4"> 504 + <Dropdown 505 + selectedItem={ 506 + recommendationSources.find( 507 + (s) => s.id === selectedRecommendationSource, 508 + ) 509 + ? { 510 + id: selectedRecommendationSource || "", 511 + name: 512 + recommendationSources.find( 513 + (s) => s.id === selectedRecommendationSource, 514 + )?.title || "", 515 + } 516 + : { 517 + id: "", 518 + name: recommendationSources[0]?.title || "", 519 + } 520 + } 521 + setSelectedItem={(item) => setSelectedRecommendationSource(item.id)} 522 + options={recommendationSources.map((source) => ({ 523 + id: source.id, 524 + name: source.title, 525 + }))} 526 + customButton={ 527 + <button 528 + type="button" 529 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 530 + > 531 + <span>{t("discover.carousel.change")}</span> 532 + <Icon 533 + icon={Icons.UP_DOWN_ARROW} 534 + className="text-xs text-dropdown-secondary" 535 + /> 536 + </button> 537 + } 538 + side="right" 539 + customMenu={ 540 + <Listbox.Options static className="py-1"> 541 + {recommendationSources.map((opt) => ( 542 + <Listbox.Option 543 + className={({ active }) => 544 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 545 + active 546 + ? "bg-background-secondaryHover text-type-link" 547 + : "text-type-secondary" 548 + }` 549 + } 550 + key={opt.id} 551 + value={{ id: opt.id, name: opt.title }} 552 + > 553 + {({ selected }) => ( 554 + <> 555 + <span 556 + className={`block ${selected ? "font-medium" : "font-normal"}`} 557 + > 558 + {opt.title} 559 + </span> 560 + {selected && ( 561 + <Icon 562 + icon={Icons.CHECKMARK} 563 + className="text-xs text-type-link" 564 + /> 565 + )} 566 + </> 567 + )} 568 + </Listbox.Option> 569 + ))} 570 + </Listbox.Options> 571 + } 572 + /> 573 + </div> 574 + </div> 575 + ); 576 + }; 577 + 578 + if (loading) { 579 + return ( 580 + <SubPageLayout> 581 + <WideContainer> 582 + <div className="animate-pulse"> 583 + <div className="h-8 bg-gray-700 rounded w-1/4 mb-8" /> 584 + <MediaGrid> 585 + {Array.from({ length: 20 }).map(() => ( 586 + <div 587 + key={crypto.randomUUID()} 588 + className="aspect-[2/3] bg-gray-700 rounded-lg" 589 + /> 590 + ))} 591 + </MediaGrid> 592 + </div> 593 + </WideContainer> 594 + </SubPageLayout> 595 + ); 596 + } 597 + 598 + return ( 599 + <SubPageLayout> 600 + <WideContainer> 601 + <div className="flex items-center justify-between gap-8"> 602 + <Heading1 className="text-2xl font-bold text-white"> 603 + {getDisplayTitle()} 604 + </Heading1> 605 + {renderRecommendationSourceDropdown()} 606 + </div> 607 + 608 + <div className="flex items-center gap-4 mb-2"> 609 + <button 610 + type="button" 611 + onClick={handleBack} 612 + className="flex items-center text-white hover:text-gray-300 transition-colors" 613 + > 614 + <Icon className="text-xl" icon={Icons.ARROW_LEFT} /> 615 + <span className="ml-2">{t("discover.page.back")}</span> 616 + </button> 617 + </div> 618 + 619 + {renderProviderButtons()} 620 + {renderGenreButtons()} 621 + 622 + <div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10 pt-8"> 623 + {medias.map((media) => ( 624 + <div 625 + key={media.id} 626 + style={{ userSelect: "none" }} 627 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 628 + e.preventDefault() 629 + } 630 + > 631 + <MediaCard 632 + media={{ 633 + id: media.id.toString(), 634 + title: media.title || media.name || "", 635 + poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`, 636 + type: mediaType === "tv" ? "show" : "movie", 637 + year: 638 + mediaType === "tv" 639 + ? media.first_air_date 640 + ? parseInt(media.first_air_date.split("-")[0], 10) 641 + : undefined 642 + : media.release_date 643 + ? parseInt(media.release_date.split("-")[0], 10) 644 + : undefined, 645 + }} 646 + onShowDetails={handleShowDetails} 647 + linkable={!category?.includes("upcoming")} 648 + /> 649 + </div> 650 + ))} 651 + </div> 652 + {hasMore && ( 653 + <div className="flex justify-center mt-8"> 654 + <Button 655 + theme="purple" 656 + onClick={handleLoadMore} 657 + disabled={loadingMore} 658 + > 659 + {loadingMore 660 + ? t("discover.page.loading") 661 + : t("discover.page.loadMore")} 662 + </Button> 663 + </div> 664 + )} 665 + </WideContainer> 666 + {detailsData && <DetailsModal id="discover-details" data={detailsData} />} 667 + </SubPageLayout> 668 + ); 669 + }
+23 -5
src/pages/discover/common.ts
··· 29 29 export interface Category { 30 30 name: string; 31 31 endpoint: string; 32 + urlPath: string; 33 + mediaType: "movie" | "tv"; 32 34 } 33 35 34 36 // Define the categories 35 37 export const categories: Category[] = [ 36 38 { 37 39 name: "Now Playing", 38 - endpoint: "/movie/now_playing?language=en-US", 40 + endpoint: "/movie/now_playing", 41 + urlPath: "now-playing", 42 + mediaType: "movie", 39 43 }, 40 44 { 41 45 name: "Top Rated", 42 - endpoint: "/movie/top_rated?language=en-US", 46 + endpoint: "/movie/top_rated", 47 + urlPath: "top-rated", 48 + mediaType: "movie", 43 49 }, 44 50 { 45 51 name: "Most Popular", 46 - endpoint: "/movie/popular?language=en-US", 52 + endpoint: "/movie/popular", 53 + urlPath: "popular", 54 + mediaType: "movie", 47 55 }, 48 56 ]; 49 57 50 58 export const tvCategories: Category[] = [ 51 59 { 60 + name: "On The Air", 61 + endpoint: "/tv/on_the_air", 62 + urlPath: "on-air", 63 + mediaType: "tv", 64 + }, 65 + { 52 66 name: "Top Rated", 53 - endpoint: "/tv/top_rated?language=en-US", 67 + endpoint: "/tv/top_rated", 68 + urlPath: "top-rated", 69 + mediaType: "tv", 54 70 }, 55 71 { 56 72 name: "Most Popular", 57 - endpoint: "/tv/popular?language=en-US", 73 + endpoint: "/tv/popular", 74 + urlPath: "popular", 75 + mediaType: "tv", 58 76 }, 59 77 ];
+36
src/pages/discover/components/DiscoverNavigation.tsx
··· 1 + import { useTranslation } from "react-i18next"; 2 + 3 + interface DiscoverNavigationProps { 4 + selectedCategory: string; 5 + onCategoryChange: (category: string) => void; 6 + } 7 + 8 + export function DiscoverNavigation({ 9 + selectedCategory, 10 + onCategoryChange, 11 + }: DiscoverNavigationProps) { 12 + const { t } = useTranslation(); 13 + 14 + return ( 15 + <div className="pb-4 w-full max-w-screen-xl mx-auto"> 16 + <div className="relative flex justify-center"> 17 + <div className="flex space-x-4"> 18 + {["movies", "tvshows", "editorpicks"].map((category) => ( 19 + <button 20 + key={category} 21 + type="button" 22 + className={`text-xl md:text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${ 23 + selectedCategory === category 24 + ? "transform scale-105 text-type-link" 25 + : "text-type-secondary" 26 + }`} 27 + onClick={() => onCategoryChange(category)} 28 + > 29 + {t(`discover.tabs.${category}`)} 30 + </button> 31 + ))} 32 + </div> 33 + </div> 34 + </div> 35 + ); 36 + }
+521
src/pages/discover/components/FeaturedCarousel.tsx
··· 1 + import classNames from "classnames"; 2 + import { t } from "i18next"; 3 + import { ReactNode, useEffect, useRef, useState } from "react"; 4 + import { useNavigate } from "react-router-dom"; 5 + import { useWindowSize } from "react-use"; 6 + 7 + import { get, getMediaLogo } from "@/backend/metadata/tmdb"; 8 + import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; 9 + import { Button } from "@/components/buttons/Button"; 10 + import { Icon, Icons } from "@/components/Icon"; 11 + import { Movie, TVShow } from "@/pages/discover/common"; 12 + import { conf } from "@/setup/config"; 13 + import { useDiscoverStore } from "@/stores/discover"; 14 + import { useLanguageStore } from "@/stores/language"; 15 + import { usePreferencesStore } from "@/stores/preferences"; 16 + import { getTmdbLanguageCode } from "@/utils/language"; 17 + 18 + import { EDITOR_PICKS_MOVIES, EDITOR_PICKS_TV_SHOWS } from "../discoverContent"; 19 + import { RandomMovieButton } from "./RandomMovieButton"; 20 + 21 + export interface FeaturedMedia extends Partial<Movie & TVShow> { 22 + children?: ReactNode; 23 + backdrop_path: string; 24 + overview: string; 25 + title?: string; 26 + name?: string; 27 + type: "movie" | "show"; 28 + } 29 + 30 + interface FeaturedCarouselProps { 31 + onShowDetails: (media: FeaturedMedia) => void; 32 + children?: ReactNode; 33 + searching?: boolean; 34 + shorter?: boolean; 35 + forcedCategory?: "movies" | "tvshows" | "editorpicks"; 36 + } 37 + 38 + function FeaturedCarouselSkeleton({ shorter }: { shorter?: boolean }) { 39 + return ( 40 + <div 41 + className={classNames( 42 + "relative w-full transition-[height] duration-300 ease-in-out", 43 + shorter ? "h-[75vh]" : "h-[75vh] md:h-[100vh]", 44 + )} 45 + > 46 + <div className="relative w-full h-full overflow-hidden"> 47 + <div 48 + className="absolute inset-0 bg-gray-800" 49 + style={{ 50 + maskImage: 51 + "linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 500px)", 52 + WebkitMaskImage: 53 + "linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 500px)", 54 + }} 55 + /> 56 + </div> 57 + 58 + {/* Navigation Buttons Skeleton */} 59 + <div className="absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30"> 60 + <div className="w-8 h-8 bg-gray-600 rounded-full animate-pulse" /> 61 + </div> 62 + <div className="absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30"> 63 + <div className="w-8 h-8 bg-gray-600 rounded-full animate-pulse" /> 64 + </div> 65 + 66 + {/* Navigation Dots Skeleton */} 67 + <div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[19] flex gap-2"> 68 + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ( 69 + <div 70 + key={i} 71 + className="w-2.5 h-2.5 rounded-full bg-gray-600 animate-pulse" 72 + /> 73 + ))} 74 + </div> 75 + 76 + {/* Content Overlay Skeleton */} 77 + <div className="absolute inset-0 flex items-end pb-20 z-10"> 78 + <div className="container mx-auto px-8 md:px-4"> 79 + <div className="max-w-3xl"> 80 + <div className="h-12 w-48 bg-gray-600 rounded animate-pulse mb-6" /> 81 + <div className="space-y-2 mb-6"> 82 + <div className="h-4 bg-gray-600 rounded animate-pulse w-3/4" /> 83 + <div className="h-4 bg-gray-600 rounded animate-pulse w-1/2" /> 84 + </div> 85 + <div className="flex gap-4 justify-center items-center sm:justify-start"> 86 + <div className="h-10 w-32 bg-gray-600 rounded animate-pulse" /> 87 + <div className="h-10 w-32 bg-gray-600 rounded animate-pulse" /> 88 + </div> 89 + </div> 90 + </div> 91 + </div> 92 + </div> 93 + ); 94 + } 95 + 96 + export function FeaturedCarousel({ 97 + onShowDetails, 98 + children, 99 + searching, 100 + shorter, 101 + forcedCategory, 102 + }: FeaturedCarouselProps) { 103 + const { selectedCategory } = useDiscoverStore(); 104 + const effectiveCategory = forcedCategory || selectedCategory; 105 + const [currentIndex, setCurrentIndex] = useState(0); 106 + const [isAutoPlaying, setIsAutoPlaying] = useState(true); 107 + const [media, setMedia] = useState<FeaturedMedia[]>([]); 108 + const [isLoading, setIsLoading] = useState(true); 109 + const [logoUrl, setLogoUrl] = useState<string | undefined>(); 110 + const [touchStart, setTouchStart] = useState<number | null>(null); 111 + const [touchEnd, setTouchEnd] = useState<number | null>(null); 112 + const logoFetchController = useRef<AbortController | null>(null); 113 + const autoPlayInterval = useRef<NodeJS.Timeout | null>(null); 114 + const navigate = useNavigate(); 115 + const enableImageLogos = usePreferencesStore( 116 + (state) => state.enableImageLogos, 117 + ); 118 + const userLanguage = useLanguageStore.getState().language; 119 + const formattedLanguage = getTmdbLanguageCode(userLanguage); 120 + const { width: windowWidth, height: windowHeight } = useWindowSize(); 121 + 122 + const SLIDE_QUANTITY = 10; 123 + const SLIDE_QUANTITY_EDITOR_PICKS_MOVIES = 6; 124 + const SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS = 4; 125 + const SLIDE_DURATION = 8000; 126 + 127 + // Fetch featured media 128 + useEffect(() => { 129 + const fetchFeaturedMedia = async () => { 130 + setIsLoading(true); 131 + setLogoUrl(undefined); // Clear logo when media changes 132 + if (logoFetchController.current) { 133 + logoFetchController.current.abort(); // Cancel any in-progress logo fetches 134 + } 135 + try { 136 + if (effectiveCategory === "movies") { 137 + const data = await get<any>("/movie/popular", { 138 + api_key: conf().TMDB_READ_API_KEY, 139 + language: formattedLanguage, 140 + }); 141 + setMedia( 142 + data.results.slice(0, SLIDE_QUANTITY).map((movie: any) => ({ 143 + ...movie, 144 + type: "movie" as const, 145 + })), 146 + ); 147 + } else if (effectiveCategory === "tvshows") { 148 + const data = await get<any>("/tv/popular", { 149 + api_key: conf().TMDB_READ_API_KEY, 150 + language: formattedLanguage, 151 + }); 152 + setMedia( 153 + data.results.slice(0, SLIDE_QUANTITY).map((show: any) => ({ 154 + ...show, 155 + type: "show" as const, 156 + })), 157 + ); 158 + } else if (effectiveCategory === "editorpicks") { 159 + // Fetch editor picks movies 160 + const moviePromises = EDITOR_PICKS_MOVIES.slice( 161 + 0, 162 + SLIDE_QUANTITY_EDITOR_PICKS_MOVIES, 163 + ).map((item) => 164 + get<any>(`/movie/${item.id}`, { 165 + api_key: conf().TMDB_READ_API_KEY, 166 + language: formattedLanguage, 167 + }), 168 + ); 169 + 170 + // Fetch editor picks TV shows 171 + const showPromises = EDITOR_PICKS_TV_SHOWS.slice( 172 + 0, 173 + SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS, 174 + ).map((item) => 175 + get<any>(`/tv/${item.id}`, { 176 + api_key: conf().TMDB_READ_API_KEY, 177 + language: formattedLanguage, 178 + }), 179 + ); 180 + 181 + const [movieResults, showResults] = await Promise.all([ 182 + Promise.all(moviePromises), 183 + Promise.all(showPromises), 184 + ]); 185 + 186 + const movies = movieResults.map((movie) => ({ 187 + ...movie, 188 + type: "movie" as const, 189 + })); 190 + const shows = showResults.map((show) => ({ 191 + ...show, 192 + type: "show" as const, 193 + })); 194 + 195 + // Combine and shuffle 196 + const combined = [...movies, ...shows].sort( 197 + () => 0.5 - Math.random(), 198 + ); 199 + setMedia(combined); 200 + } 201 + } catch (error) { 202 + console.error("Error fetching featured media:", error); 203 + } finally { 204 + setIsLoading(false); 205 + } 206 + }; 207 + 208 + fetchFeaturedMedia(); 209 + }, [formattedLanguage, effectiveCategory]); 210 + 211 + const handlePrevSlide = () => { 212 + setCurrentIndex((prev) => (prev - 1 + media.length) % media.length); 213 + // Reset autoplay timer 214 + if (autoPlayInterval.current) { 215 + clearInterval(autoPlayInterval.current); 216 + } 217 + if (isAutoPlaying) { 218 + autoPlayInterval.current = setInterval(() => { 219 + setCurrentIndex((prev) => (prev + 1) % media.length); 220 + }, 5000); 221 + } 222 + }; 223 + 224 + const handleNextSlide = () => { 225 + setCurrentIndex((prev) => (prev + 1) % media.length); 226 + // Reset autoplay timer 227 + if (autoPlayInterval.current) { 228 + clearInterval(autoPlayInterval.current); 229 + } 230 + if (isAutoPlaying) { 231 + autoPlayInterval.current = setInterval(() => { 232 + setCurrentIndex((prev) => (prev + 1) % media.length); 233 + }, 5000); 234 + } 235 + }; 236 + 237 + const handleTouchStart = (e: React.TouchEvent) => { 238 + setTouchStart(e.targetTouches[0].clientX); 239 + }; 240 + 241 + const handleTouchMove = (e: React.TouchEvent) => { 242 + setTouchEnd(e.targetTouches[0].clientX); 243 + }; 244 + 245 + const handleTouchEnd = () => { 246 + if (!touchStart || !touchEnd) return; 247 + 248 + const distance = touchStart - touchEnd; 249 + const minSwipeDistance = 50; 250 + 251 + if (Math.abs(distance) > minSwipeDistance) { 252 + if (distance > 0) { 253 + handleNextSlide(); 254 + } else { 255 + handlePrevSlide(); 256 + } 257 + } 258 + 259 + setTouchStart(null); 260 + setTouchEnd(null); 261 + }; 262 + 263 + // Fetch logo when current media changes 264 + useEffect(() => { 265 + const fetchLogo = async () => { 266 + // Cancel any in-progress logo fetch 267 + if (logoFetchController.current) { 268 + logoFetchController.current.abort(); 269 + } 270 + 271 + // Create new abort controller for this fetch 272 + logoFetchController.current = new AbortController(); 273 + 274 + const currentMediaId = media[currentIndex]?.id; 275 + if (!currentMediaId) { 276 + setLogoUrl(undefined); 277 + return; 278 + } 279 + 280 + try { 281 + const logo = await getMediaLogo( 282 + currentMediaId.toString(), 283 + media[currentIndex].type === "movie" 284 + ? TMDBContentTypes.MOVIE 285 + : TMDBContentTypes.TV, 286 + ); 287 + // Only update if this is still the current media 288 + if (media[currentIndex]?.id === currentMediaId) { 289 + setLogoUrl(logo); 290 + } 291 + } catch (error: unknown) { 292 + if (error instanceof Error && error.name === "AbortError") { 293 + // Ignore abort errors 294 + return; 295 + } 296 + console.error("Error fetching logo:", error); 297 + setLogoUrl(undefined); 298 + } 299 + }; 300 + 301 + fetchLogo(); 302 + 303 + return () => { 304 + if (logoFetchController.current) { 305 + logoFetchController.current.abort(); 306 + } 307 + }; 308 + }, [currentIndex, media]); 309 + 310 + // Cleanup on unmount 311 + useEffect(() => { 312 + return () => { 313 + if (logoFetchController.current) { 314 + logoFetchController.current.abort(); 315 + } 316 + }; 317 + }, []); 318 + 319 + useEffect(() => { 320 + if (isAutoPlaying && media.length > 0) { 321 + autoPlayInterval.current = setInterval(() => { 322 + setCurrentIndex((prev) => (prev + 1) % media.length); 323 + }, SLIDE_DURATION); 324 + } 325 + 326 + return () => { 327 + if (autoPlayInterval.current) { 328 + clearInterval(autoPlayInterval.current); 329 + } 330 + }; 331 + }, [isAutoPlaying, media.length]); 332 + 333 + if (isLoading) { 334 + return <FeaturedCarouselSkeleton shorter={shorter} />; 335 + } 336 + 337 + if (media.length === 0) { 338 + return <FeaturedCarouselSkeleton shorter={shorter} />; 339 + } 340 + 341 + const currentMedia = media[currentIndex]; 342 + const mediaTitle = currentMedia.title || currentMedia.name; 343 + 344 + let searchClasses = ""; 345 + if (searching) searchClasses = "opacity-0 transition-opacity duration-300"; 346 + else searchClasses = "opacity-100 transition-opacity duration-300"; 347 + 348 + return ( 349 + <div 350 + className={classNames( 351 + "relative w-full transition-[height] duration-300 ease-in-out", 352 + searching 353 + ? "h-24" 354 + : shorter 355 + ? windowHeight > 600 356 + ? "h-[40rem] md:h-[85vh]" 357 + : "h-[100vh]" 358 + : "h-[40rem] md:h-[100vh]", 359 + )} 360 + onTouchStart={handleTouchStart} 361 + onTouchMove={handleTouchMove} 362 + onTouchEnd={handleTouchEnd} 363 + > 364 + <div 365 + className={classNames( 366 + "relative w-full h-full overflow-hidden", 367 + searchClasses, 368 + )} 369 + > 370 + {media.map((item, index) => ( 371 + <div 372 + key={item.id} 373 + className={`absolute inset-0 transition-opacity duration-1000 ${ 374 + index === currentIndex ? "opacity-100" : "opacity-0" 375 + }`} 376 + style={{ 377 + backgroundImage: `url(https://image.tmdb.org/t/p/original${item.backdrop_path})`, 378 + backgroundSize: "cover", 379 + backgroundPosition: "center top", 380 + maskImage: 381 + "linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 700px)", 382 + WebkitMaskImage: 383 + "linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 700px)", 384 + }} 385 + /> 386 + ))} 387 + </div> 388 + 389 + {/* Navigation Buttons */} 390 + <button 391 + type="button" 392 + onClick={handlePrevSlide} 393 + className={classNames( 394 + "absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors", 395 + searchClasses, 396 + )} 397 + aria-label="Previous slide" 398 + > 399 + <Icon icon={Icons.CHEVRON_LEFT} className="text-white w-8 h-8" /> 400 + </button> 401 + <button 402 + type="button" 403 + onClick={handleNextSlide} 404 + className={classNames( 405 + "absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors", 406 + searchClasses, 407 + )} 408 + aria-label="Next slide" 409 + > 410 + <Icon icon={Icons.CHEVRON_RIGHT} className="text-white w-8 h-8" /> 411 + </button> 412 + 413 + {/* Navigation Dots */} 414 + <div 415 + className={classNames( 416 + "absolute bottom-8 left-1/2 -translate-x-1/2 z-[19] flex gap-2", 417 + searchClasses, 418 + )} 419 + > 420 + {media.map((item, index) => ( 421 + <button 422 + key={`dot-${item.id}`} 423 + type="button" 424 + onClick={() => { 425 + setCurrentIndex(index); 426 + // Reset autoplay timer when clicking dots 427 + if (autoPlayInterval.current) { 428 + clearInterval(autoPlayInterval.current); 429 + } 430 + if (isAutoPlaying) { 431 + autoPlayInterval.current = setInterval(() => { 432 + setCurrentIndex((prev) => (prev + 1) % media.length); 433 + }, 5000); 434 + } 435 + }} 436 + className={`w-2.5 h-2.5 rounded-full transition-all ${ 437 + index === currentIndex 438 + ? "bg-white scale-125" 439 + : "bg-white/50 hover:bg-white/75" 440 + }`} 441 + aria-label={`Go to slide ${index + 1}`} 442 + /> 443 + ))} 444 + </div> 445 + 446 + {/* Content Overlay */} 447 + <div 448 + className={classNames( 449 + "absolute inset-0 flex items-end pb-20 z-10", 450 + searchClasses, 451 + )} 452 + > 453 + <div className="container mx-auto px-8 md:px-4 flex justify-between items-end w-full"> 454 + <div className="max-w-3xl"> 455 + {logoUrl && enableImageLogos ? ( 456 + <img 457 + src={logoUrl} 458 + alt={mediaTitle} 459 + className="max-w-[14rem] md:max-w-[22rem] max-h-[20vh] object-contain drop-shadow-lg bg-transparent mb-6" 460 + style={{ background: "none" }} 461 + /> 462 + ) : ( 463 + <h1 className="text-4xl md:text-6xl font-bold text-white mb-4"> 464 + {mediaTitle} 465 + </h1> 466 + )} 467 + <p className="text-lg text-white mb-6 line-clamp-3 md:line-clamp-4"> 468 + {currentMedia.overview} 469 + </p> 470 + <div 471 + className="flex gap-4 justify-center items-center sm:justify-start" 472 + onMouseEnter={() => setIsAutoPlaying(false)} 473 + onMouseLeave={() => setIsAutoPlaying(true)} 474 + > 475 + <Button 476 + onClick={() => 477 + navigate( 478 + `/media/tmdb-${currentMedia.type}-${currentMedia.id}-${mediaTitle?.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, 479 + ) 480 + } 481 + theme="secondary" 482 + className="w-full sm:w-auto text-base" 483 + > 484 + <Icon icon={Icons.PLAY} className="text-white" /> 485 + <span className="text-white"> 486 + {t("discover.featured.playNow")} 487 + </span> 488 + </Button> 489 + <Button 490 + onClick={() => onShowDetails(currentMedia)} 491 + theme="secondary" 492 + className="w-full sm:w-auto text-base" 493 + > 494 + <Icon 495 + icon={Icons.CIRCLE_QUESTION} 496 + className="text-white scale-100" 497 + /> 498 + <span className="text-white"> 499 + {t("discover.featured.moreInfo")} 500 + </span> 501 + </Button> 502 + </div> 503 + </div> 504 + <div className="hidden lg:block"> 505 + <RandomMovieButton /> 506 + </div> 507 + </div> 508 + </div> 509 + {children && ( 510 + <div 511 + className={classNames( 512 + "absolute inset-0 pointer-events-none", 513 + windowWidth > 1280 ? "pt-0" : "pt-14", 514 + )} 515 + > 516 + <div className="pointer-events-auto z-50">{children}</div> 517 + </div> 518 + )} 519 + </div> 520 + ); 521 + }
+74 -16
src/pages/discover/components/LazyMediaCarousel.tsx
··· 1 1 import { useEffect, useState } from "react"; 2 2 3 - import { Category, Genre, Media } from "@/pages/discover/common"; 3 + import { get } from "@/backend/metadata/tmdb"; 4 4 import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver"; 5 5 import { useLazyTMDBData } from "@/pages/discover/hooks/useTMDBData"; 6 6 import { MediaItem } from "@/utils/mediaTypes"; 7 7 8 8 import { MediaCarousel } from "./MediaCarousel"; 9 + import { 10 + Category, 11 + Genre, 12 + Media, 13 + Movie, 14 + TVShow, 15 + categories, 16 + tvCategories, 17 + } from "../common"; 9 18 10 19 interface LazyMediaCarouselProps { 11 - category?: Category | null; 12 - genre?: Genre | null; 20 + category?: Category; 21 + genre?: Genre; 13 22 mediaType: "movie" | "tv"; 14 23 isMobile: boolean; 15 24 carouselRefs: React.MutableRefObject<{ 16 25 [key: string]: HTMLDivElement | null; 17 26 }>; 18 - preloadedMedia?: Media[]; 27 + onShowDetails?: (media: MediaItem) => void; 28 + preloadedMedia?: Movie[] | TVShow[]; 29 + genreId?: number; 19 30 title?: string; 20 - onShowDetails?: (media: MediaItem) => void; 31 + relatedButtons?: Array<{ name: string; id: string }>; 32 + onButtonClick?: (id: string, name: string) => void; 33 + moreContent?: boolean; 21 34 } 22 35 23 36 export function LazyMediaCarousel({ ··· 26 39 mediaType, 27 40 isMobile, 28 41 carouselRefs, 42 + onShowDetails, 29 43 preloadedMedia, 44 + genreId, 30 45 title, 31 - onShowDetails, 46 + relatedButtons, 47 + onButtonClick, 48 + moreContent, 32 49 }: LazyMediaCarouselProps) { 33 50 const [medias, setMedias] = useState<Media[]>([]); 51 + const [loading, setLoading] = useState(!preloadedMedia); 52 + 53 + const categoryData = (mediaType === "movie" ? categories : tvCategories).find( 54 + (c: Category) => c.name === (category?.name || genre?.name || title || ""), 55 + ); 34 56 35 57 // Use intersection observer to detect when this component is visible 36 58 const { targetRef, isIntersecting } = useIntersectionObserver( ··· 38 60 ); 39 61 40 62 // Use the lazy loading hook only if we don't have preloaded media 41 - const { media, isLoading } = useLazyTMDBData( 63 + const { media } = useLazyTMDBData( 42 64 !preloadedMedia ? genre || null : null, 43 65 !preloadedMedia ? category || null : null, 44 66 mediaType, ··· 49 71 useEffect(() => { 50 72 if (preloadedMedia) { 51 73 setMedias(preloadedMedia); 74 + setLoading(false); 52 75 } else if (media.length > 0) { 53 76 setMedias(media); 77 + setLoading(false); 54 78 } 55 79 }, [media, preloadedMedia]); 56 80 57 - const categoryName = title || category?.name || genre?.name || ""; 81 + // Only fetch category content if we don't have preloaded media 82 + useEffect(() => { 83 + if (preloadedMedia || !categoryData) return; 84 + 85 + const fetchContent = async () => { 86 + try { 87 + const data = await get<any>(categoryData.endpoint, { 88 + api_key: process.env.TMDB_READ_API_KEY, 89 + language: "en-US", 90 + }); 91 + setMedias(data.results); 92 + } catch (error) { 93 + console.error("Error fetching content:", error); 94 + } finally { 95 + setLoading(false); 96 + } 97 + }; 98 + 99 + fetchContent(); 100 + }, [categoryData, preloadedMedia]); 101 + 102 + const categoryName = category?.name || genre?.name || title || ""; 58 103 const categorySlug = `${categoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${mediaType}`; 59 104 60 - // Test intersection observer 61 - // useEffect(() => { 62 - // // eslint-disable-next-line no-console 63 - // console.log( 64 - // `Carousel ${categoryName}: ${isIntersecting ? "loaded ✅" : "unloaded ❌"}`, 65 - // ); 66 - // }, [isIntersecting, categoryName]); 105 + if (loading) { 106 + return ( 107 + <div className="flex items-center justify-between ml-2 md:ml-8 mt-2"> 108 + <div className="flex gap-4 items-center"> 109 + <h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance"> 110 + {categoryName} 111 + </h2> 112 + </div> 113 + </div> 114 + ); 115 + } 67 116 68 117 return ( 69 118 <div ref={targetRef as React.RefObject<HTMLDivElement>}> ··· 75 124 isMobile={isMobile} 76 125 carouselRefs={carouselRefs} 77 126 onShowDetails={onShowDetails} 127 + genreId={genreId} 128 + relatedButtons={relatedButtons} 129 + onButtonClick={onButtonClick} 130 + moreContent={moreContent} 131 + moreLink={ 132 + categoryData 133 + ? `/discover/more/category/${categoryData.urlPath}/${categoryData.mediaType}` 134 + : undefined 135 + } 78 136 /> 79 137 ) : ( 80 138 <div className="relative overflow-hidden carousel-container"> ··· 84 142 </h2> 85 143 <div className="flex whitespace-nowrap pt-0 pb-4 overflow-auto scrollbar rounded-xl overflow-y-hidden h-[300px] animate-pulse bg-background-secondary/20"> 86 144 <div className="w-full text-center flex items-center justify-center"> 87 - {isLoading ? "Loading..." : ""} 145 + Loading... 88 146 </div> 89 147 </div> 90 148 </div>
+297 -6
src/pages/discover/components/MediaCarousel.tsx
··· 1 + import { Listbox } from "@headlessui/react"; 2 + import React from "react"; 1 3 import { useTranslation } from "react-i18next"; 4 + import { Link } from "react-router-dom"; 5 + import { useWindowSize } from "react-use"; 2 6 7 + import { Dropdown, OptionItem } from "@/components/form/Dropdown"; 8 + import { Icon, Icons } from "@/components/Icon"; 3 9 import { MediaCard } from "@/components/media/MediaCard"; 10 + import { Flare } from "@/components/utils/Flare"; 4 11 import { Media } from "@/pages/discover/common"; 12 + import { useDiscoverStore } from "@/stores/discover"; 5 13 import { MediaItem } from "@/utils/mediaTypes"; 6 14 15 + import { MOVIE_PROVIDERS, TV_PROVIDERS } from "../discoverContent"; 7 16 import { CarouselNavButtons } from "./CarouselNavButtons"; 8 17 9 18 interface MediaCarouselProps { ··· 15 24 [key: string]: HTMLDivElement | null; 16 25 }>; 17 26 onShowDetails?: (media: MediaItem) => void; 27 + genreId?: number; 28 + moreContent?: boolean; 29 + moreLink?: string; 30 + relatedButtons?: Array<{ name: string; id: string }>; 31 + onButtonClick?: (id: string, name: string) => void; 32 + recommendationSources?: Array<{ id: string; title: string }>; 33 + selectedRecommendationSource?: string; 34 + onRecommendationSourceChange?: (id: string) => void; 18 35 } 19 36 20 37 function MediaCardSkeleton() { ··· 28 45 ); 29 46 } 30 47 48 + function MoreCard({ link }: { link: string }) { 49 + const { t } = useTranslation(); 50 + 51 + return ( 52 + <div className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"> 53 + <Link to={link} className="block"> 54 + <Flare.Base className="group -m-[0.705em] h-[20rem] hover:scale-95 transition-all rounded-xl bg-background-main duration-300 hover:bg-mediaCard-hoverBackground tabbable"> 55 + <Flare.Light 56 + flareSize={300} 57 + cssColorVar="--colors-mediaCard-hoverAccent" 58 + backgroundClass="bg-mediaCard-hoverBackground duration-100" 59 + className="rounded-xl bg-background-main group-hover:opacity-100" 60 + /> 61 + <Flare.Child className="pointer-events-auto h-[20rem] relative mb-2 p-[0.4em] transition-transform duration-300"> 62 + <div className="flex absolute inset-0 flex-col items-center justify-center"> 63 + <Icon 64 + icon={Icons.ARROW_RIGHT} 65 + className="text-4xl mb-2 transition-transform duration-300" 66 + /> 67 + <span className="text-sm text-center px-2"> 68 + {t("discover.carousel.more")} 69 + </span> 70 + </div> 71 + </Flare.Child> 72 + </Flare.Base> 73 + </Link> 74 + </div> 75 + ); 76 + } 77 + 31 78 export function MediaCarousel({ 32 79 medias, 33 80 category, ··· 35 82 isMobile, 36 83 carouselRefs, 37 84 onShowDetails, 85 + genreId, 86 + moreContent, 87 + moreLink, 88 + relatedButtons, 89 + onButtonClick, 90 + recommendationSources, 91 + selectedRecommendationSource, 92 + onRecommendationSourceChange, 38 93 }: MediaCarouselProps) { 39 94 const { t } = useTranslation(); 95 + const { width: windowWidth } = useWindowSize(); 96 + const { setLastView } = useDiscoverStore(); 97 + const [selectedGenre, setSelectedGenre] = React.useState<OptionItem | null>( 98 + null, 99 + ); 40 100 const categorySlug = `${category.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`; 41 101 const browser = !!window.chrome; 42 102 let isScrolling = false; ··· 81 141 } 82 142 83 143 if (categoryName === "Editor Picks") { 84 - return t("discover.carousel.title.editorPicks"); 144 + return isTVShow 145 + ? t("discover.carousel.title.editorPicksShows") 146 + : t("discover.carousel.title.editorPicksMovies"); 147 + } 148 + 149 + if ( 150 + categoryName.includes("Movies on") || 151 + categoryName.includes("Shows on") 152 + ) { 153 + const providerName = categoryName.split(" on ")[1]; 154 + const providers = isTVShowCondition ? TV_PROVIDERS : MOVIE_PROVIDERS; 155 + const provider = providers.find( 156 + (p) => p.name.toLowerCase() === providerName.toLowerCase(), 157 + ); 158 + 159 + if (provider) { 160 + return isTVShowCondition 161 + ? t("discover.carousel.title.tvshowsOn", { provider: provider.name }) 162 + : t("discover.carousel.title.moviesOn", { provider: provider.name }); 163 + } 164 + // If provider not found, fall back to using the raw provider name 165 + return isTVShowCondition 166 + ? t("discover.carousel.title.tvshowsOn", { provider: providerName }) 167 + : t("discover.carousel.title.moviesOn", { provider: providerName }); 168 + } 169 + 170 + if (categoryName.includes("Because You Watched")) { 171 + return t("discover.carousel.title.recommended", { 172 + title: categoryName.split("Because You Watched:")[1], 173 + }); 85 174 } 86 175 87 176 return isTVShowCondition ··· 101 190 102 191 const SKELETON_COUNT = 10; 103 192 193 + const { visibleButtons, dropdownButtons } = React.useMemo(() => { 194 + if (!relatedButtons) return { visibleButtons: [], dropdownButtons: [] }; 195 + 196 + const visible = 197 + windowWidth > 850 198 + ? relatedButtons.slice(0, 5) 199 + : relatedButtons.slice(0, 0); 200 + 201 + const dropdown = 202 + windowWidth > 850 ? relatedButtons.slice(5) : relatedButtons.slice(0); 203 + 204 + return { visibleButtons: visible, dropdownButtons: dropdown }; 205 + }, [relatedButtons, windowWidth]); 206 + 207 + const activeButton = relatedButtons?.find( 208 + (btn) => btn.name === category.split(" on ")[1] || btn.name === category, 209 + ); 210 + 211 + const dropdownOptions: OptionItem[] = dropdownButtons.map((button) => ({ 212 + id: button.id, 213 + name: button.name, 214 + })); 215 + 216 + React.useEffect(() => { 217 + if ( 218 + activeButton && 219 + !visibleButtons.find((btn) => btn.id === activeButton.id) 220 + ) { 221 + setSelectedGenre({ id: activeButton.id, name: activeButton.name }); 222 + } 223 + }, [activeButton, visibleButtons]); 224 + 225 + const handleMoreClick = () => { 226 + setLastView({ 227 + url: window.location.pathname, 228 + scrollPosition: window.scrollY, 229 + }); 230 + }; 231 + 104 232 return ( 105 233 <> 106 - <h2 className="ml-2 md:ml-8 mt-2 text-2xl cursor-default font-bold text-white md:text-2xl mx-auto pl-5 text-balance"> 107 - {displayCategory} 108 - </h2> 109 - <div className="relative overflow-hidden carousel-container"> 234 + <div className="flex items-center justify-between ml-2 md:ml-8 mt-2"> 235 + <div className="flex flex-col"> 236 + <div className="flex items-center gap-4"> 237 + <h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance"> 238 + {displayCategory} 239 + </h2> 240 + {recommendationSources && 241 + recommendationSources.length > 0 && 242 + onRecommendationSourceChange && ( 243 + <div className="relative pr-4"> 244 + <Dropdown 245 + selectedItem={ 246 + recommendationSources.find( 247 + (s) => s.id === selectedRecommendationSource, 248 + ) 249 + ? { 250 + id: selectedRecommendationSource || "", 251 + name: 252 + recommendationSources.find( 253 + (s) => s.id === selectedRecommendationSource, 254 + )?.title || "", 255 + } 256 + : { 257 + id: "", 258 + name: recommendationSources[0]?.title || "", 259 + } 260 + } 261 + setSelectedItem={(item) => 262 + onRecommendationSourceChange(item.id) 263 + } 264 + options={recommendationSources.map((source) => ({ 265 + id: source.id, 266 + name: source.title, 267 + }))} 268 + customButton={ 269 + <button 270 + type="button" 271 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 272 + > 273 + <span>{t("discover.carousel.change")}</span> 274 + <Icon 275 + icon={Icons.UP_DOWN_ARROW} 276 + className="text-xs text-dropdown-secondary" 277 + /> 278 + </button> 279 + } 280 + side="right" 281 + customMenu={ 282 + <Listbox.Options static className="py-1"> 283 + {recommendationSources.map((opt) => ( 284 + <Listbox.Option 285 + className={({ active }) => 286 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 287 + active 288 + ? "bg-background-secondaryHover text-type-link" 289 + : "text-type-secondary" 290 + }` 291 + } 292 + key={opt.id} 293 + value={{ id: opt.id, name: opt.title }} 294 + > 295 + {({ selected }) => ( 296 + <> 297 + <span 298 + className={`block ${selected ? "font-medium" : "font-normal"}`} 299 + > 300 + {opt.title} 301 + </span> 302 + {selected && ( 303 + <Icon 304 + icon={Icons.CHECKMARK} 305 + className="text-xs text-type-link" 306 + /> 307 + )} 308 + </> 309 + )} 310 + </Listbox.Option> 311 + ))} 312 + </Listbox.Options> 313 + } 314 + /> 315 + </div> 316 + )} 317 + </div> 318 + {moreContent && ( 319 + <Link 320 + to={ 321 + moreLink || 322 + `/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}` 323 + } 324 + onClick={handleMoreClick} 325 + className="flex px-5 items-center hover:text-type-link transition-colors" 326 + > 327 + <span className="text-sm">{t("discover.carousel.more")}</span> 328 + <Icon className="text-sm ml-1" icon={Icons.ARROW_RIGHT} /> 329 + </Link> 330 + )} 331 + </div> 332 + {relatedButtons && relatedButtons.length > 0 && ( 333 + <div className="flex items-center space-x-2 mr-6"> 334 + {visibleButtons?.map((button) => ( 335 + <button 336 + type="button" 337 + key={button.id} 338 + onClick={() => onButtonClick?.(button.id, button.name)} 339 + className="px-3 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors whitespace-nowrap flex-shrink-0" 340 + > 341 + {button.name} 342 + </button> 343 + ))} 344 + {dropdownButtons && dropdownButtons.length > 0 && ( 345 + <div className="relative my-0"> 346 + <Dropdown 347 + selectedItem={ 348 + selectedGenre || { 349 + id: "", 350 + name: 351 + activeButton && 352 + !visibleButtons.find( 353 + (btn) => btn.id === activeButton.id, 354 + ) 355 + ? activeButton.name 356 + : "...", 357 + } 358 + } 359 + setSelectedItem={(item) => { 360 + setSelectedGenre(item); 361 + onButtonClick?.(item.id, item.name); 362 + }} 363 + options={dropdownOptions} 364 + customButton={ 365 + <button 366 + type="button" 367 + className="px-3 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 368 + > 369 + <span> 370 + {activeButton && 371 + !visibleButtons.find( 372 + (btn) => btn.id === activeButton.id, 373 + ) 374 + ? activeButton.name 375 + : "..."} 376 + </span> 377 + <Icon 378 + icon={Icons.UP_DOWN_ARROW} 379 + className="text-xs text-dropdown-secondary" 380 + /> 381 + </button> 382 + } 383 + side="right" 384 + preventWrap 385 + /> 386 + </div> 387 + )} 388 + </div> 389 + )} 390 + </div> 391 + <div className="relative overflow-hidden carousel-container md:pb-4"> 110 392 <div 111 393 id={`carousel-${categorySlug}`} 112 - className="grid grid-flow-col auto-cols-max gap-4 pt-0 pb-4 overflow-x-scroll scrollbar rounded-xl overflow-y-hidden md:pl-8 md:pr-8" 394 + className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar rounded-xl overflow-y-hidden md:pl-8 md:pr-8" 113 395 ref={(el) => { 114 396 carouselRefs.current[categorySlug] = el; 115 397 }} ··· 151 433 key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} 152 434 /> 153 435 ))} 436 + 437 + {moreContent && ( 438 + <MoreCard 439 + link={ 440 + moreLink || 441 + `/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}` 442 + } 443 + /> 444 + )} 154 445 155 446 <div className="md:w-12" /> 156 447 </div>
+112 -51
src/pages/discover/components/RandomMovieButton.tsx
··· 1 - import React from "react"; 2 - import { useTranslation } from "react-i18next"; 1 + import { useEffect, useState } from "react"; 2 + import { useNavigate } from "react-router-dom"; 3 3 4 - import { Icon, Icons } from "@/components/Icon"; 4 + import { get } from "@/backend/metadata/tmdb"; 5 + import { Movie } from "@/pages/discover/common"; 6 + import { conf } from "@/setup/config"; 7 + import { useLanguageStore } from "@/stores/language"; 8 + import { getTmdbLanguageCode } from "@/utils/language"; 5 9 6 - interface RandomMovieButtonProps { 7 - countdown: number | null; 8 - onClick: () => void; 9 - randomMovieTitle: string | null; 10 + interface TMDBMovieResponse { 11 + results: Movie[]; 10 12 } 11 13 12 - export function RandomMovieButton({ 13 - countdown, 14 - onClick, 15 - randomMovieTitle, 16 - }: RandomMovieButtonProps) { 17 - const { t } = useTranslation(); 14 + export function RandomMovieButton() { 15 + const [randomMovie, setRandomMovie] = useState<Movie | null>(null); 16 + const [countdown, setCountdown] = useState<number | null>(null); 17 + const [countdownTimeout, setCountdownTimeout] = 18 + useState<NodeJS.Timeout | null>(null); 19 + const [movies, setMovies] = useState<Movie[]>([]); 20 + const navigate = useNavigate(); 21 + const userLanguage = useLanguageStore.getState().language; 22 + const formattedLanguage = getTmdbLanguageCode(userLanguage); 23 + 24 + // Fetch popular movies for random selection 25 + useEffect(() => { 26 + const fetchMovies = async () => { 27 + try { 28 + const data = await get<TMDBMovieResponse>("/movie/popular", { 29 + api_key: conf().TMDB_READ_API_KEY, 30 + language: formattedLanguage, 31 + page: 2, 32 + }); 33 + setMovies(data.results); 34 + } catch (error) { 35 + console.error("Error fetching popular movies:", error); 36 + } 37 + }; 38 + 39 + fetchMovies(); 40 + }, [formattedLanguage]); 41 + 42 + useEffect(() => { 43 + let countdownInterval: NodeJS.Timeout; 44 + if (countdown !== null && countdown > 0) { 45 + countdownInterval = setInterval(() => { 46 + setCountdown((prev) => (prev !== null ? prev - 1 : prev)); 47 + }, 1000); 48 + } 49 + return () => clearInterval(countdownInterval); 50 + }, [countdown]); 51 + 52 + const handleRandomMovieClick = () => { 53 + if (movies.length === 0) return; 54 + 55 + const uniqueTitles = new Set(movies.map((movie) => movie.title)); 56 + const uniqueTitlesArray = Array.from(uniqueTitles); 57 + const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length); 58 + const selectedMovie = movies.find( 59 + (movie) => movie.title === uniqueTitlesArray[randomIndex], 60 + ); 61 + 62 + if (selectedMovie) { 63 + if (countdown !== null && countdown > 0) { 64 + setCountdown(null); 65 + if (countdownTimeout) { 66 + clearTimeout(countdownTimeout); 67 + setCountdownTimeout(null); 68 + setRandomMovie(null); 69 + } 70 + } else { 71 + setRandomMovie(selectedMovie); 72 + setCountdown(5); 73 + const timeoutId = setTimeout(() => { 74 + navigate(`/media/tmdb-movie-${selectedMovie.id}-random`); 75 + }, 5000); 76 + setCountdownTimeout(timeoutId); 77 + } 78 + } 79 + }; 18 80 19 81 return ( 20 - <div className="w-full max-w-screen-xl mx-auto px-4"> 21 - <div className="flex items-center justify-center"> 22 - <button 23 - type="button" 24 - className="flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105" 25 - onClick={onClick} 82 + <div className="flex justify-center items-center"> 83 + <button 84 + type="button" 85 + className={` 86 + relative flex items-center overflow-hidden 87 + rounded-full text-white h-10 88 + bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover 89 + transition-all duration-300 ease-in-out 90 + ${countdown !== null && countdown > 0 ? "min-w-[10px] pl-3" : "w-10"} 91 + `} 92 + onClick={handleRandomMovieClick} 93 + > 94 + {/* Title container that slides in */} 95 + <div 96 + className={` 97 + relative whitespace-nowrap 98 + transition-all duration-300 ease-in-out 99 + ${countdown !== null && countdown > 0 ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-4"} 100 + `} 26 101 > 27 - <span className="flex items-center"> 28 - {countdown !== null && countdown > 0 ? ( 29 - <div className="flex items-center"> 30 - <span>{t("discover.randomMovie.cancel")}</span> 31 - <Icon 32 - icon={Icons.X} 33 - className="text-2xl ml-[4.5px] mb-[-0.7px]" 34 - /> 35 - </div> 36 - ) : ( 37 - <div className="flex items-center"> 38 - <span>{t("discover.randomMovie.button")}</span> 39 - <img 40 - src="/lightbar-images/dice.svg" 41 - alt="Dice" 42 - style={{ marginLeft: "8px" }} 43 - /> 44 - </div> 45 - )} 46 - </span> 47 - </button> 48 - </div> 102 + {countdown !== null && countdown > 0 && ( 103 + <span className="font-bold">{randomMovie?.title}</span> 104 + )} 105 + </div> 49 106 50 - {/* Random Movie Countdown */} 51 - {randomMovieTitle && countdown !== null && ( 52 - <div className="mt-4 mb-4 text-center"> 53 - <p> 54 - {t("discover.randomMovie.nowPlaying")}{" "} 55 - <span className="font-bold">{randomMovieTitle}</span>{" "} 56 - {t("discover.randomMovie.in")}{" "} 57 - {t("discover.randomMovie.countdown", { countdown })} 58 - </p> 107 + {/* Icon container that stays fixed on the right */} 108 + <div className="ml-auto flex items-center justify-center w-10 h-10"> 109 + {countdown !== null && countdown > 0 ? ( 110 + <div className="animate-[pulse_1s_ease-in-out_infinite] text-lg font-bold"> 111 + {countdown} 112 + </div> 113 + ) : ( 114 + <img 115 + src="/lightbar-images/dice.svg" 116 + alt="Dice" 117 + className="w-6 h-6" 118 + /> 119 + )} 59 120 </div> 60 - )} 121 + </button> 61 122 </div> 62 123 ); 63 124 }
+1 -1
src/pages/discover/components/ScrollToTopButton.tsx
··· 40 40 <button 41 41 type="button" 42 42 onClick={scrollToTop} 43 - className={`relative flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${ 43 + className={`relative backdrop-blur-sm flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${ 44 44 isVisible ? "opacity-100 visible" : "opacity-0 invisible" 45 45 }`} 46 46 style={{
+436 -235
src/pages/discover/discoverContent.tsx
··· 1 1 import { useEffect, useRef, useState } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 - import { useNavigate } from "react-router-dom"; 4 3 5 4 import { get } from "@/backend/metadata/tmdb"; 6 5 import { DetailsModal } from "@/components/overlays/DetailsModal"; ··· 9 8 import { 10 9 Genre, 11 10 Movie, 11 + TVShow, 12 12 categories, 13 13 tvCategories, 14 14 } from "@/pages/discover/common"; 15 15 import { conf } from "@/setup/config"; 16 + import { useDiscoverStore } from "@/stores/discover"; 16 17 import { useLanguageStore } from "@/stores/language"; 18 + import { ProgressMediaItem, useProgressStore } from "@/stores/progress"; 17 19 import { getTmdbLanguageCode } from "@/utils/language"; 18 20 import { MediaItem } from "@/utils/mediaTypes"; 19 21 20 - import { CategoryButtons } from "./components/CategoryButtons"; 22 + import { DiscoverNavigation } from "./components/DiscoverNavigation"; 23 + import type { FeaturedMedia } from "./components/FeaturedCarousel"; 21 24 import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; 22 25 import { LazyTabContent } from "./components/LazyTabContent"; 23 26 import { MediaCarousel } from "./components/MediaCarousel"; 24 - import { RandomMovieButton } from "./components/RandomMovieButton"; 25 27 import { ScrollToTopButton } from "./components/ScrollToTopButton"; 26 - import { useTMDBData } from "./hooks/useTMDBData"; 27 28 28 - const MOVIE_PROVIDERS = [ 29 + // Provider constants moved from DiscoverNavigation 30 + export const MOVIE_PROVIDERS = [ 29 31 { name: "Netflix", id: "8" }, 30 32 { name: "Apple TV+", id: "2" }, 31 33 { name: "Amazon Prime Video", id: "10" }, 32 34 { name: "Hulu", id: "15" }, 35 + { name: "Disney Plus", id: "337" }, 33 36 { name: "Max", id: "1899" }, 34 37 { name: "Paramount Plus", id: "531" }, 35 - { name: "Disney Plus", id: "337" }, 36 38 { name: "Shudder", id: "99" }, 39 + { name: "Crunchyroll", id: "283" }, 40 + { name: "fuboTV", id: "257" }, 41 + { name: "AMC+", id: "526" }, 42 + { name: "Starz", id: "43" }, 43 + { name: "PBS", id: "209" }, 44 + { name: "Lifetime", id: "157" }, 45 + { name: "National Geographic", id: "1964" }, 37 46 ]; 38 47 39 - const TV_PROVIDERS = [ 48 + export const TV_PROVIDERS = [ 40 49 { name: "Netflix", id: "8" }, 41 50 { name: "Apple TV+", id: "350" }, 42 51 { name: "Amazon Prime Video", id: "10" }, 43 52 { name: "Paramount Plus", id: "531" }, 44 53 { name: "Hulu", id: "15" }, 45 54 { name: "Max", id: "1899" }, 55 + { name: "Adult Swim", id: "318" }, 46 56 { name: "Disney Plus", id: "337" }, 47 57 { name: "fubuTV", id: "257" }, 58 + { name: "Crunchyroll", id: "283" }, 59 + { name: "fuboTV", id: "257" }, 60 + { name: "Shudder", id: "99" }, 61 + { name: "Discovery +", id: "520" }, 62 + { name: "National Geographic", id: "1964" }, 63 + { name: "Fox", id: "328" }, 48 64 ]; 49 65 66 + const shuffleArray = <T,>(array: T[]): T[] => { 67 + const shuffled = [...array]; 68 + for (let i = shuffled.length - 1; i > 0; i -= 1) { 69 + const j = Math.floor(Math.random() * (i + 1)); 70 + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 71 + } 72 + return shuffled; 73 + }; 74 + 50 75 // Editor Picks lists 51 - const EDITOR_PICKS_MOVIES = [ 76 + export const EDITOR_PICKS_MOVIES = shuffleArray([ 52 77 { id: 9342, type: "movie" }, // The Mask of Zorro 53 78 { id: 293, type: "movie" }, // A River Runs Through It 54 79 { id: 370172, type: "movie" }, // No Time To Die ··· 80 105 { id: 18971, type: "movie" }, // Rosencrantz and Guildenstern Are Dead 81 106 { id: 26388, type: "movie" }, // Buried 82 107 { id: 152601, type: "movie" }, // Her 83 - ]; 108 + ]); 84 109 85 - const EDITOR_PICKS_TV_SHOWS = [ 110 + export const EDITOR_PICKS_TV_SHOWS = shuffleArray([ 86 111 { id: 456, type: "show" }, // The Simpsons 87 112 { id: 73021, type: "show" }, // Disenchantment 88 113 { id: 1434, type: "show" }, // Family Guy ··· 99 124 { id: 93405, type: "show" }, // Squid Game 100 125 { id: 87108, type: "show" }, // Chernobyl 101 126 { id: 105248, type: "show" }, // Cyberpunk: Edgerunners 102 - ]; 127 + ]); 103 128 104 129 export function DiscoverContent() { 105 - // State management 106 - const [selectedCategory, setSelectedCategory] = useState("movies"); 107 - const [genres, setGenres] = useState<Genre[]>([]); 108 - const [tvGenres, setTVGenres] = useState<Genre[]>([]); 109 - const [randomMovie, setRandomMovie] = useState<Movie | null>(null); 110 - const [countdown, setCountdown] = useState<number | null>(null); 111 - const [countdownTimeout, setCountdownTimeout] = 112 - useState<NodeJS.Timeout | null>(null); 130 + const { selectedCategory, setSelectedCategory } = useDiscoverStore(); 113 131 const [selectedProvider, setSelectedProvider] = useState({ 114 132 name: "", 115 133 id: "", 116 134 }); 135 + const [selectedGenre, setSelectedGenre] = useState({ 136 + name: "", 137 + id: "", 138 + }); 139 + const [genres, setGenres] = useState<Genre[]>([]); 140 + const [tvGenres, setTVGenres] = useState<Genre[]>([]); 117 141 const [providerMovies, setProviderMovies] = useState<Movie[]>([]); 118 - const [providerTVShows, setProviderTVShows] = useState<any[]>([]); 119 - const [editorPicksMovies, setEditorPicksMovies] = useState<Movie[]>([]); 120 - const [editorPicksTVShows, setEditorPicksTVShows] = useState<any[]>([]); 142 + const [providerTVShows, setProviderTVShows] = useState<TVShow[]>([]); 143 + const [filteredGenreMovies, setFilteredGenreMovies] = useState<Movie[]>([]); 144 + const [filteredGenreTVShows, setFilteredGenreTVShows] = useState<TVShow[]>( 145 + [], 146 + ); 121 147 const [detailsData, setDetailsData] = useState<any>(); 122 148 const detailsModal = useModal("discover-details"); 149 + const [movieRecommendations, setMovieRecommendations] = useState<any[]>([]); 150 + const [tvRecommendations, setTVRecommendations] = useState<any[]>([]); 151 + const [movieRecommendationTitle, setMovieRecommendationTitle] = useState(""); 152 + const [tvRecommendationTitle, setTVRecommendationTitle] = useState(""); 153 + const [movieRecommendationSourceId, setMovieRecommendationSourceId] = 154 + useState<string>(""); 155 + const [tvRecommendationSourceId, setTVRecommendationSourceId] = 156 + useState<string>(""); 157 + const [movieRecommendationSources, setMovieRecommendationSources] = useState< 158 + Array<{ id: string; title: string }> 159 + >([]); 160 + const [tvRecommendationSources, setTVRecommendationSources] = useState< 161 + Array<{ id: string; title: string }> 162 + >([]); 163 + const [selectedMovieSource, setSelectedMovieSource] = useState<string>(""); 164 + const [selectedTVSource, setSelectedTVSource] = useState<string>(""); 165 + const progressStore = useProgressStore(); 166 + const { t } = useTranslation(); 123 167 124 - // Refs 125 168 const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); 126 169 127 - // Hooks 128 - const navigate = useNavigate(); 129 170 const { isMobile } = useIsMobile(); 130 - const { genreMedia: genreMovies } = useTMDBData(genres, categories, "movie"); 131 - // const { genreMedia: genreTVShows } = useTMDBData( 132 - // tvGenres, 133 - // tvCategories, 134 - // "tv", 135 - // ); 136 - const { t } = useTranslation(); 137 171 138 172 const userLanguage = useLanguageStore.getState().language; 139 173 const formattedLanguage = getTmdbLanguageCode(userLanguage); ··· 143 177 const isTVShowsTab = selectedCategory === "tvshows"; 144 178 const isEditorPicksTab = selectedCategory === "editorpicks"; 145 179 180 + const handleCategoryChange = (category: string) => { 181 + setSelectedCategory(category as "movies" | "tvshows" | "editorpicks"); 182 + }; 183 + 184 + // Set initial provider when component mounts or category changes 185 + useEffect(() => { 186 + const providers = 187 + selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS; 188 + if (providers.length > 0 && !selectedProvider.id) { 189 + setSelectedProvider({ 190 + name: providers[0].name, 191 + id: providers[0].id, 192 + }); 193 + } 194 + }, [selectedCategory, selectedProvider.id]); 195 + 196 + // Set initial genre when component mounts or category changes 197 + useEffect(() => { 198 + const genreList = selectedCategory === "movies" ? genres : tvGenres; 199 + if (genreList.length > 0) { 200 + // Always reset genre when switching categories to ensure we use the correct genre IDs 201 + if (selectedCategory === "movies") { 202 + setSelectedGenre({ 203 + name: genres[0].name, 204 + id: genres[0].id.toString(), 205 + }); 206 + } else if (selectedCategory === "tvshows") { 207 + setSelectedGenre({ 208 + name: tvGenres[0].name, 209 + id: tvGenres[0].id.toString(), 210 + }); 211 + } 212 + } 213 + }, [selectedCategory, genres, tvGenres]); 214 + 215 + // Fetch provider content when selectedProvider changes 216 + useEffect(() => { 217 + const fetchProviderContent = async () => { 218 + if (!selectedProvider.id) return; 219 + 220 + try { 221 + const endpoint = 222 + selectedCategory === "movies" ? "/discover/movie" : "/discover/tv"; 223 + const setData = 224 + selectedCategory === "movies" 225 + ? setProviderMovies 226 + : setProviderTVShows; 227 + const data = await get<any>(endpoint, { 228 + api_key: conf().TMDB_READ_API_KEY, 229 + with_watch_providers: selectedProvider.id, 230 + watch_region: "US", 231 + language: formattedLanguage, 232 + }); 233 + setData(data.results); 234 + } catch (error) { 235 + console.error("Error fetching provider movies/shows:", error); 236 + } 237 + }; 238 + 239 + fetchProviderContent(); 240 + }, [selectedProvider, selectedCategory, formattedLanguage]); 241 + 242 + // Fetch genre content when selectedGenre changes 243 + useEffect(() => { 244 + const fetchGenreContent = async () => { 245 + if (!selectedGenre.id) return; 246 + try { 247 + const endpoint = 248 + selectedCategory === "movies" ? "/discover/movie" : "/discover/tv"; 249 + const setData = 250 + selectedCategory === "movies" 251 + ? setFilteredGenreMovies 252 + : setFilteredGenreTVShows; 253 + const data = await get<any>(endpoint, { 254 + api_key: conf().TMDB_READ_API_KEY, 255 + with_genres: selectedGenre.id, 256 + language: formattedLanguage, 257 + }); 258 + setData(data.results); 259 + } catch (error) { 260 + console.error("Error fetching genre movies/shows:", error); 261 + } 262 + }; 263 + 264 + fetchGenreContent(); 265 + }, [selectedGenre, selectedCategory, formattedLanguage]); 266 + 146 267 // Fetch TV show genres 147 268 useEffect(() => { 148 269 if (!isTVShowsTab) return; ··· 153 274 api_key: conf().TMDB_READ_API_KEY, 154 275 language: formattedLanguage, 155 276 }); 156 - // Fetch only the first 10 TV show genres 157 - setTVGenres(data.genres.slice(0, 10)); 277 + setTVGenres(data.genres.slice(0, 50)); 158 278 } catch (error) { 159 279 console.error("Error fetching TV show genres:", error); 160 280 } ··· 173 293 api_key: conf().TMDB_READ_API_KEY, 174 294 language: formattedLanguage, 175 295 }); 176 - 177 - // Fetch only the first 12 genres 178 - setGenres(data.genres.slice(0, 12)); 296 + setGenres(data.genres.slice(0, 50)); 179 297 } catch (error) { 180 298 console.error("Error fetching genres:", error); 181 299 } ··· 199 317 ); 200 318 201 319 const results = await Promise.all(moviePromises); 202 - // Shuffle the results to display them randomly 203 - const shuffled = [...results].sort(() => 0.5 - Math.random()); 204 - setEditorPicksMovies(shuffled); 320 + const moviesWithType = results.map((movie) => ({ 321 + ...movie, 322 + type: "movie" as const, 323 + })); 324 + setFilteredGenreMovies(moviesWithType); 205 325 } catch (error) { 206 326 console.error("Error fetching editor picks movies:", error); 207 327 } ··· 225 345 ); 226 346 227 347 const results = await Promise.all(tvShowPromises); 228 - // Shuffle the results to display them randomly 229 - const shuffled = [...results].sort(() => 0.5 - Math.random()); 230 - setEditorPicksTVShows(shuffled); 348 + const showsWithType = results.map((show) => ({ 349 + ...show, 350 + type: "show" as const, 351 + })); 352 + setFilteredGenreTVShows(showsWithType); 231 353 } catch (error) { 232 354 console.error("Error fetching editor picks TV shows:", error); 233 355 } ··· 236 358 fetchEditorPicksTVShows(); 237 359 }, [isEditorPicksTab, formattedLanguage]); 238 360 361 + // Update recommendations effect to store multiple sources 239 362 useEffect(() => { 240 - let countdownInterval: NodeJS.Timeout; 241 - if (countdown !== null && countdown > 0) { 242 - countdownInterval = setInterval(() => { 243 - setCountdown((prev) => (prev !== null ? prev - 1 : prev)); 244 - }, 1000); 245 - } 246 - return () => clearInterval(countdownInterval); 247 - }, [countdown]); 363 + const fetchRecommendations = async () => { 364 + if (!progressStore.items || Object.keys(progressStore.items).length === 0) 365 + return; 248 366 249 - // Handlers 250 - const handleCategoryChange = ( 251 - eventOrValue: React.ChangeEvent<HTMLSelectElement> | string, 252 - ) => { 253 - const value = 254 - typeof eventOrValue === "string" 255 - ? eventOrValue 256 - : eventOrValue.target.value; 257 - setSelectedCategory(value); 258 - }; 367 + try { 368 + // Get all movies and TV shows from progress 369 + const progressItems = Object.entries(progressStore.items) as [ 370 + string, 371 + ProgressMediaItem, 372 + ][]; 373 + const movies = progressItems.filter( 374 + ([_, item]) => item.type === "movie", 375 + ); 376 + const tvShows = progressItems.filter( 377 + ([_, item]) => item.type === "show", 378 + ); 259 379 260 - const handleRandomMovieClick = () => { 261 - const allMovies = Object.values(genreMovies).flat(); 262 - const uniqueTitles = new Set(allMovies.map((movie) => movie.title)); 263 - const uniqueTitlesArray = Array.from(uniqueTitles); 264 - const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length); 265 - const selectedMovie = allMovies.find( 266 - (movie) => movie.title === uniqueTitlesArray[randomIndex], 267 - ); 380 + // Store all movie sources 381 + if (movies.length > 0) { 382 + const movieSources = movies.map(([id, item]) => ({ 383 + id, 384 + title: item.title || "", 385 + })); 386 + setMovieRecommendationSources(movieSources); 268 387 269 - if (selectedMovie) { 270 - if (countdown !== null && countdown > 0) { 271 - setCountdown(null); 272 - if (countdownTimeout) { 273 - clearTimeout(countdownTimeout); 274 - setCountdownTimeout(null); 275 - setRandomMovie(null); 388 + // Set initial source if not set 389 + if (!selectedMovieSource && movieSources.length > 0) { 390 + setSelectedMovieSource(movieSources[0].id); 391 + } 276 392 } 277 - } else { 278 - setRandomMovie(selectedMovie as Movie); 279 - setCountdown(5); 280 - const timeoutId = setTimeout(() => { 281 - navigate(`/media/tmdb-movie-${selectedMovie.id}-discover-random`); 282 - }, 5000); 283 - setCountdownTimeout(timeoutId); 393 + 394 + // Store all TV show sources 395 + if (tvShows.length > 0) { 396 + const tvSources = tvShows.map(([id, item]) => ({ 397 + id, 398 + title: item.title || "", 399 + })); 400 + setTVRecommendationSources(tvSources); 401 + 402 + // Set initial source if not set 403 + if (!selectedTVSource && tvSources.length > 0) { 404 + setSelectedTVSource(tvSources[0].id); 405 + } 406 + } 407 + } catch (error) { 408 + console.error("Error setting up recommendation sources:", error); 284 409 } 285 - } 286 - }; 410 + }; 287 411 288 - const handleProviderClick = async (id: string, name: string) => { 289 - try { 290 - setSelectedProvider({ name, id }); 291 - const endpoint = 292 - selectedCategory === "movies" ? "/discover/movie" : "/discover/tv"; 293 - const setData = 294 - selectedCategory === "movies" ? setProviderMovies : setProviderTVShows; 295 - const data = await get<any>(endpoint, { 296 - api_key: conf().TMDB_READ_API_KEY, 297 - with_watch_providers: id, 298 - watch_region: "US", 299 - language: formattedLanguage, 300 - }); 301 - setData(data.results); 302 - } catch (error) { 303 - console.error("Error fetching provider movies/shows:", error); 304 - } 305 - }; 412 + fetchRecommendations(); 413 + }, [progressStore.items, selectedMovieSource, selectedTVSource]); 414 + 415 + // Add new effect to fetch recommendations when source changes 416 + useEffect(() => { 417 + const fetchRecommendationsForSource = async () => { 418 + if (!selectedMovieSource && !selectedTVSource) return; 419 + 420 + try { 421 + // Fetch movie recommendations if we have a selected movie source 422 + if (selectedMovieSource) { 423 + const movieResults = await get<any>( 424 + `/movie/${selectedMovieSource}/recommendations`, 425 + { 426 + api_key: conf().TMDB_READ_API_KEY, 427 + language: formattedLanguage, 428 + }, 429 + ); 430 + 431 + if (movieResults.results?.length > 0) { 432 + setMovieRecommendations(movieResults.results); 433 + const sourceMovie = movieRecommendationSources.find( 434 + (m) => m.id === selectedMovieSource, 435 + ); 436 + if (sourceMovie) { 437 + setMovieRecommendationTitle( 438 + t("discover.carousel.title.recommended", { 439 + title: sourceMovie.title, 440 + }), 441 + ); 442 + setMovieRecommendationSourceId(selectedMovieSource); 443 + } 444 + } 445 + } 446 + 447 + // Fetch TV show recommendations if we have a selected TV source 448 + if (selectedTVSource) { 449 + const tvResults = await get<any>( 450 + `/tv/${selectedTVSource}/recommendations`, 451 + { 452 + api_key: conf().TMDB_READ_API_KEY, 453 + language: formattedLanguage, 454 + }, 455 + ); 306 456 307 - const handleCategoryClick = (id: string, name: string) => { 308 - // Try both movie and tv versions of the category slug 309 - const categorySlugBase = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); 310 - const movieElement = document.getElementById( 311 - `carousel-${categorySlugBase}-movie`, 312 - ); 313 - const tvElement = document.getElementById( 314 - `carousel-${categorySlugBase}-tv`, 315 - ); 457 + if (tvResults.results?.length > 0) { 458 + setTVRecommendations(tvResults.results); 459 + const sourceTV = tvRecommendationSources.find( 460 + (show) => show.id === selectedTVSource, 461 + ); 462 + if (sourceTV) { 463 + setTVRecommendationTitle( 464 + t("discover.carousel.title.recommended", { 465 + title: sourceTV.title, 466 + }), 467 + ); 468 + setTVRecommendationSourceId(selectedTVSource); 469 + } 470 + } 471 + } 472 + } catch (error) { 473 + console.error("Error fetching recommendations:", error); 474 + } 475 + }; 316 476 317 - // Scroll to the first element that exists 318 - const element = movieElement || tvElement; 319 - if (element) { 320 - element.scrollIntoView({ 321 - behavior: "smooth", 322 - block: "center", 323 - }); 324 - } 325 - }; 477 + fetchRecommendationsForSource(); 478 + }, [ 479 + selectedMovieSource, 480 + selectedTVSource, 481 + movieRecommendationSources, 482 + tvRecommendationSources, 483 + formattedLanguage, 484 + setMovieRecommendationTitle, 485 + setTVRecommendationTitle, 486 + setMovieRecommendationSourceId, 487 + setTVRecommendationSourceId, 488 + t, 489 + ]); 326 490 327 - const handleShowDetails = async (media: MediaItem) => { 491 + const handleShowDetails = async (media: MediaItem | FeaturedMedia) => { 328 492 setDetailsData({ 329 493 id: Number(media.id), 330 494 type: media.type === "movie" ? "movie" : "show", ··· 337 501 return ( 338 502 <> 339 503 <LazyMediaCarousel 340 - preloadedMedia={editorPicksMovies} 504 + preloadedMedia={filteredGenreMovies} 341 505 title="Editor Picks" 342 506 mediaType="movie" 343 507 isMobile={isMobile} 344 508 carouselRefs={carouselRefs} 345 509 onShowDetails={handleShowDetails} 510 + moreContent 346 511 /> 347 512 <LazyMediaCarousel 348 - preloadedMedia={editorPicksTVShows} 513 + preloadedMedia={filteredGenreTVShows} 349 514 title="Editor Picks" 350 515 mediaType="tv" 351 516 isMobile={isMobile} 352 517 carouselRefs={carouselRefs} 353 518 onShowDetails={handleShowDetails} 519 + moreContent 354 520 /> 355 521 </> 356 522 ); ··· 360 526 const renderMoviesContent = () => { 361 527 return ( 362 528 <> 363 - {/* Provider Movies */} 364 - {providerMovies.length > 0 && ( 529 + {/* Movie Recommendations */} 530 + {movieRecommendations.length > 0 && ( 365 531 <MediaCarousel 366 - medias={providerMovies} 367 - category={selectedProvider.name} 532 + medias={movieRecommendations} 533 + category={movieRecommendationTitle} 368 534 isTVShow={false} 369 535 isMobile={isMobile} 370 536 carouselRefs={carouselRefs} 371 537 onShowDetails={handleShowDetails} 538 + moreLink={`/discover/more/recommendations/${movieRecommendationSourceId}/movie`} 539 + moreContent 540 + recommendationSources={movieRecommendationSources} 541 + selectedRecommendationSource={selectedMovieSource} 542 + onRecommendationSourceChange={setSelectedMovieSource} 372 543 /> 373 544 )} 374 545 375 - {/* Categories */} 376 - {categories.map((category) => ( 377 - <LazyMediaCarousel 378 - key={category.name} 379 - category={category} 380 - mediaType="movie" 381 - isMobile={isMobile} 382 - carouselRefs={carouselRefs} 383 - onShowDetails={handleShowDetails} 384 - /> 385 - ))} 546 + {/* In Cinemas */} 547 + <LazyMediaCarousel 548 + category={categories[0]} 549 + mediaType="movie" 550 + isMobile={isMobile} 551 + carouselRefs={carouselRefs} 552 + onShowDetails={handleShowDetails} 553 + moreContent 554 + /> 386 555 387 - {/* Genres */} 388 - {genres.map((genre) => ( 389 - <LazyMediaCarousel 390 - key={genre.id} 391 - genre={genre} 392 - mediaType="movie" 393 - isMobile={isMobile} 394 - carouselRefs={carouselRefs} 395 - onShowDetails={handleShowDetails} 396 - /> 397 - ))} 556 + {/* Top Rated */} 557 + <LazyMediaCarousel 558 + category={categories[1]} 559 + mediaType="movie" 560 + isMobile={isMobile} 561 + carouselRefs={carouselRefs} 562 + onShowDetails={handleShowDetails} 563 + moreContent 564 + /> 565 + 566 + {/* Popular */} 567 + <LazyMediaCarousel 568 + category={categories[2]} 569 + mediaType="movie" 570 + isMobile={isMobile} 571 + carouselRefs={carouselRefs} 572 + onShowDetails={handleShowDetails} 573 + moreContent 574 + /> 575 + 576 + {/* Provider Movies */} 577 + <MediaCarousel 578 + medias={providerMovies} 579 + category={`Movies on ${selectedProvider.name || ""}`} 580 + isTVShow={false} 581 + isMobile={isMobile} 582 + carouselRefs={carouselRefs} 583 + onShowDetails={handleShowDetails} 584 + relatedButtons={MOVIE_PROVIDERS.map((p) => ({ 585 + name: p.name, 586 + id: p.id, 587 + }))} 588 + onButtonClick={(id, name) => setSelectedProvider({ id, name })} 589 + moreLink={`/discover/more/provider/${selectedProvider.id}/movie`} 590 + moreContent 591 + /> 592 + 593 + {/* Genre Movies */} 594 + <MediaCarousel 595 + medias={filteredGenreMovies} 596 + category={`${selectedGenre.name || ""}`} 597 + isTVShow={false} 598 + isMobile={isMobile} 599 + carouselRefs={carouselRefs} 600 + onShowDetails={handleShowDetails} 601 + relatedButtons={genres.map((g) => ({ 602 + name: g.name, 603 + id: g.id.toString(), 604 + }))} 605 + onButtonClick={(id, name) => setSelectedGenre({ id, name })} 606 + moreLink={`/discover/more/genre/${selectedGenre.id}/movie`} 607 + moreContent 608 + /> 398 609 </> 399 610 ); 400 611 }; ··· 403 614 const renderTVShowsContent = () => { 404 615 return ( 405 616 <> 406 - {/* Provider TV Shows */} 407 - {providerTVShows.length > 0 && ( 617 + {/* TV Show Recommendations */} 618 + {tvRecommendations.length > 0 && ( 408 619 <MediaCarousel 409 - medias={providerTVShows} 410 - category={selectedProvider.name} 620 + medias={tvRecommendations} 621 + category={tvRecommendationTitle} 411 622 isTVShow 412 623 isMobile={isMobile} 413 624 carouselRefs={carouselRefs} 414 625 onShowDetails={handleShowDetails} 626 + moreLink={`/discover/more/recommendations/${tvRecommendationSourceId}/tv`} 627 + moreContent 628 + recommendationSources={tvRecommendationSources} 629 + selectedRecommendationSource={selectedTVSource} 630 + onRecommendationSourceChange={setSelectedTVSource} 415 631 /> 416 632 )} 417 633 418 - {/* Categories */} 419 - {tvCategories.map((category) => ( 420 - <LazyMediaCarousel 421 - key={category.name} 422 - category={category} 423 - mediaType="tv" 424 - isMobile={isMobile} 425 - carouselRefs={carouselRefs} 426 - onShowDetails={handleShowDetails} 427 - /> 428 - ))} 634 + {/* On Air */} 635 + <LazyMediaCarousel 636 + category={tvCategories[0]} 637 + mediaType="tv" 638 + isMobile={isMobile} 639 + carouselRefs={carouselRefs} 640 + onShowDetails={handleShowDetails} 641 + moreContent 642 + /> 429 643 430 - {/* Genres */} 431 - {tvGenres.map((genre) => ( 432 - <LazyMediaCarousel 433 - key={genre.id} 434 - genre={genre} 435 - mediaType="tv" 436 - isMobile={isMobile} 437 - carouselRefs={carouselRefs} 438 - onShowDetails={handleShowDetails} 439 - /> 440 - ))} 644 + {/* Top Rated */} 645 + <LazyMediaCarousel 646 + category={tvCategories[1]} 647 + mediaType="tv" 648 + isMobile={isMobile} 649 + carouselRefs={carouselRefs} 650 + onShowDetails={handleShowDetails} 651 + moreContent 652 + /> 653 + 654 + {/* Popular */} 655 + <LazyMediaCarousel 656 + category={tvCategories[2]} 657 + mediaType="tv" 658 + isMobile={isMobile} 659 + carouselRefs={carouselRefs} 660 + onShowDetails={handleShowDetails} 661 + moreContent 662 + /> 663 + 664 + {/* Provider TV Shows */} 665 + <MediaCarousel 666 + medias={providerTVShows} 667 + category={`Shows on ${selectedProvider.name || ""}`} 668 + isTVShow 669 + isMobile={isMobile} 670 + carouselRefs={carouselRefs} 671 + onShowDetails={handleShowDetails} 672 + relatedButtons={TV_PROVIDERS.map((p) => ({ 673 + name: p.name, 674 + id: p.id, 675 + }))} 676 + onButtonClick={(id, name) => setSelectedProvider({ id, name })} 677 + moreLink={`/discover/more/provider/${selectedProvider.id}/tv`} 678 + moreContent 679 + /> 680 + 681 + {/* Genre TV Shows */} 682 + <MediaCarousel 683 + medias={filteredGenreTVShows} 684 + category={`${selectedGenre.name || ""}`} 685 + isTVShow 686 + isMobile={isMobile} 687 + carouselRefs={carouselRefs} 688 + onShowDetails={handleShowDetails} 689 + relatedButtons={tvGenres.map((g) => ({ 690 + name: g.name, 691 + id: g.id.toString(), 692 + }))} 693 + onButtonClick={(id, name) => setSelectedGenre({ id, name })} 694 + moreLink={`/discover/more/genre/${selectedGenre.id}/tv`} 695 + moreContent 696 + /> 441 697 </> 442 698 ); 443 699 }; 444 700 445 701 return ( 446 - <div className="pt-6"> 447 - {/* Random Movie Button */} 448 - <RandomMovieButton 449 - countdown={countdown} 450 - onClick={handleRandomMovieClick} 451 - randomMovieTitle={randomMovie ? randomMovie.title : null} 702 + <div className="relative min-h-screen"> 703 + <DiscoverNavigation 704 + selectedCategory={selectedCategory} 705 + onCategoryChange={handleCategoryChange} 452 706 /> 453 - 454 - {/* Category Tabs */} 455 - <div className="mt-8 pb-2 w-full max-w-screen-xl mx-auto"> 456 - <div className="relative flex justify-center mb-4"> 457 - <div className="flex space-x-4"> 458 - {["movies", "tvshows", "editorpicks"].map((category) => ( 459 - <button 460 - key={category} 461 - type="button" 462 - className={`text-xl md:text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${ 463 - selectedCategory === category 464 - ? "transform scale-105 text-type-link" 465 - : "text-type-secondary" 466 - }`} 467 - onClick={() => handleCategoryChange(category)} 468 - > 469 - {t(`discover.tabs.${category}`)} 470 - </button> 471 - ))} 472 - </div> 473 - </div> 474 - 475 - {/* Only show provider and genre buttons for movies and tvshows categories */} 476 - {selectedCategory !== "editorpicks" && ( 477 - <> 478 - <div className="flex justify-center overflow-x-auto"> 479 - <CategoryButtons 480 - categories={ 481 - selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS 482 - } 483 - onCategoryClick={handleProviderClick} 484 - categoryType="providers" 485 - isMobile={isMobile} 486 - showAlwaysScroll={false} 487 - /> 488 - </div> 489 - <div className="flex overflow-x-auto"> 490 - <CategoryButtons 491 - categories={ 492 - selectedCategory === "movies" 493 - ? [...categories, ...genres] 494 - : [...tvCategories, ...tvGenres] 495 - } 496 - onCategoryClick={handleCategoryClick} 497 - categoryType="movies" 498 - isMobile={isMobile} 499 - showAlwaysScroll 500 - /> 501 - </div> 502 - </> 503 - )} 504 - </div> 505 - 506 707 {/* Content Section with Lazy Loading Tabs */} 507 708 <div className="w-full md:w-[90%] max-w-[2400px] mx-auto"> 508 709 {/* Movies Tab */}
+24 -1
src/pages/layouts/HomeLayout.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + 1 3 import { FooterView } from "@/components/layout/Footer"; 2 4 import { Navigation } from "@/components/layout/Navigation"; 5 + import { usePreferencesStore } from "@/stores/preferences"; 3 6 4 7 export function HomeLayout(props: { 5 8 showBg: boolean; 6 9 children: React.ReactNode; 7 10 }) { 11 + const enableFeatured = usePreferencesStore((state) => state.enableFeatured); 12 + const [clearBackground, setClearBackground] = useState(false); 13 + 14 + useEffect(() => { 15 + const handleScroll = () => { 16 + setClearBackground(Boolean(enableFeatured) && window.scrollY < 600); 17 + }; 18 + window.addEventListener("scroll", handleScroll); 19 + // Initial check 20 + handleScroll(); 21 + 22 + return () => { 23 + window.removeEventListener("scroll", handleScroll); 24 + }; 25 + }, [enableFeatured]); 26 + 8 27 return ( 9 28 <FooterView> 10 - <Navigation bg={props.showBg} /> 29 + <Navigation 30 + bg={enableFeatured ? true : props.showBg} 31 + clearBackground={clearBackground} 32 + noLightbar={enableFeatured} 33 + /> 11 34 {props.children} 12 35 </FooterView> 13 36 );
+140
src/pages/parts/home/AdsPart.tsx
··· 1 + import { useCallback, useState } from "react"; 2 + 3 + import { Icon, Icons } from "@/components/Icon"; 4 + import { conf } from "@/setup/config"; 5 + 6 + function getCookie(name: string): string | null { 7 + const cookies = document.cookie.split(";"); 8 + for (let i = 0; i < cookies.length; i += 1) { 9 + const cookie = cookies[i].trim(); 10 + if (cookie.startsWith(`${name}=`)) { 11 + return cookie.substring(name.length + 1); 12 + } 13 + } 14 + return null; 15 + } 16 + 17 + function setCookie(name: string, value: string, expiryDays: number): void { 18 + const date = new Date(); 19 + date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000); 20 + const expires = `expires=${date.toUTCString()}`; 21 + document.cookie = `${name}=${value};${expires};path=/`; 22 + } 23 + 24 + export function AdsPart(): JSX.Element | null { 25 + const [isAdDismissed, setIsAdDismissed] = useState(() => { 26 + return getCookie("adDismissed") === "true"; 27 + }); 28 + 29 + const dismissAd = useCallback(() => { 30 + setIsAdDismissed(true); 31 + setCookie("adDismissed", "true", 2); // Expires after 2 days 32 + }, []); 33 + 34 + if (isAdDismissed) return null; 35 + 36 + return ( 37 + <div className="w-fit max-w-[32rem] mx-auto relative group pb-4"> 38 + {(() => { 39 + const adContentUrl = conf().AD_CONTENT_URL; 40 + 41 + // VITE_AD_CONTENT_URL=default message (null will be nothing),referal link,image link, card message 42 + // Ensure adContentUrl is an array. If not, render nothing for ads. 43 + if (!Array.isArray(adContentUrl)) { 44 + return null; 45 + } 46 + 47 + const ad1LinkIsValid = 48 + typeof adContentUrl[1] === "string" && adContentUrl[1].length > 0; 49 + const ad1ImageIsProvided = typeof adContentUrl[2] === "string"; 50 + const showAd1 = 51 + adContentUrl.length >= 2 && ad1LinkIsValid && ad1ImageIsProvided; 52 + 53 + const ad2LinkIsValid = 54 + typeof adContentUrl[3] === "string" && adContentUrl[3].length > 0; 55 + const ad2ImageIsProvided = typeof adContentUrl[4] === "string"; 56 + const showAd2 = 57 + adContentUrl.length >= 5 && ad2LinkIsValid && ad2ImageIsProvided; 58 + 59 + return ( 60 + <> 61 + <div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0 justify-center w-full items-center md:items-start"> 62 + {showAd1 ? ( 63 + <div className="rounded-xl bg-background-main hover:scale-[1.02] max-w-[16rem] transition-all duration-300 md:flex-1 relative group"> 64 + <div className="bg-opacity-10 bg-buttons-purple rounded-xl border-2 border-buttons-purple border-opacity-30 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20"> 65 + {" "} 66 + <button 67 + onClick={dismissAd} 68 + type="button" 69 + className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" 70 + aria-label="Dismiss ad" 71 + > 72 + <Icon 73 + className="text-xs font-semibold text-type-secondary" 74 + icon={Icons.X} 75 + /> 76 + </button> 77 + <a href={adContentUrl[1]} className="block"> 78 + <div className="overflow-hidden rounded-t-xl"> 79 + <img 80 + src={adContentUrl[2]} 81 + alt="ad banner" 82 + className="w-full h-auto transition-transform duration-300" 83 + /> 84 + </div> 85 + <p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary"> 86 + <span>{adContentUrl[3]}</span> 87 + </p> 88 + </a> 89 + </div> 90 + </div> 91 + ) : null} 92 + {showAd2 ? ( 93 + <div className="rounded-xl bg-background-main hover:scale-[1.02] max-w-[16rem] transition-all duration-300 md:flex-1 relative group"> 94 + <div className="bg-opacity-10 bg-buttons-purple rounded-xl border-2 border-buttons-purple border-opacity-30 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20"> 95 + <button 96 + onClick={dismissAd} 97 + type="button" 98 + className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" 99 + aria-label="Dismiss ad" 100 + > 101 + <Icon 102 + className="text-xs font-semibold text-type-secondary" 103 + icon={Icons.X} 104 + /> 105 + </button> 106 + <a href={adContentUrl[4]} className="block"> 107 + <div className="overflow-hidden rounded-t-xl"> 108 + <img 109 + src={adContentUrl[5]} 110 + alt="ad banner" 111 + className="w-full h-auto transition-transform duration-300" 112 + /> 113 + </div> 114 + <p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary"> 115 + <span>{adContentUrl[6]}</span> 116 + </p> 117 + </a> 118 + </div> 119 + </div> 120 + ) : null} 121 + </div> 122 + {adContentUrl[0] !== "null" && ( 123 + <div> 124 + <p className="text-xs text-type-dimmed text-center pt-2 mx-4"> 125 + <a 126 + href="https://discord.gg/mcjnJK98Gd" 127 + target="_blank" 128 + rel="noreferrer" 129 + > 130 + {adContentUrl[0]} 131 + </a> 132 + </p> 133 + </div> 134 + )} 135 + </> 136 + ); 137 + })()} 138 + </div> 139 + ); 140 + }
+25 -137
src/pages/parts/home/HeroPart.tsx
··· 1 + import classNames from "classnames"; 1 2 import { useCallback, useEffect, useRef, useState } from "react"; 2 3 import Sticky from "react-sticky-el"; 3 4 import { useWindowSize } from "react-use"; 4 5 5 6 import { SearchBarInput } from "@/components/form/SearchBar"; 6 - import { Icon, Icons } from "@/components/Icon"; 7 7 import { ThinContainer } from "@/components/layout/ThinContainer"; 8 8 import { useSlashFocus } from "@/components/player/hooks/useSlashFocus"; 9 9 import { HeroTitle } from "@/components/text/HeroTitle"; 10 10 import { useIsTV } from "@/hooks/useIsTv"; 11 11 import { useRandomTranslation } from "@/hooks/useRandomTranslation"; 12 12 import { useSearchQuery } from "@/hooks/useSearchQuery"; 13 - import { conf } from "@/setup/config"; 14 13 import { useBannerSize } from "@/stores/banner"; 15 14 16 15 export interface HeroPartProps { 17 16 setIsSticky: (val: boolean) => void; 18 17 searchParams: ReturnType<typeof useSearchQuery>; 18 + showTitle?: boolean; 19 + isInFeatured?: boolean; 19 20 } 20 21 21 22 function getTimeOfDay(date: Date): "night" | "morning" | "day" | "420" | "69" { ··· 30 31 return "night"; 31 32 } 32 33 33 - function getCookie(name: string): string | null { 34 - const cookies = document.cookie.split(";"); 35 - for (let i = 0; i < cookies.length; i += 1) { 36 - const cookie = cookies[i].trim(); 37 - if (cookie.startsWith(`${name}=`)) { 38 - return cookie.substring(name.length + 1); 39 - } 40 - } 41 - return null; 42 - } 43 - 44 - function setCookie(name: string, value: string, expiryDays: number): void { 45 - const date = new Date(); 46 - date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000); 47 - const expires = `expires=${date.toUTCString()}`; 48 - document.cookie = `${name}=${value};${expires};path=/`; 49 - } 50 - 51 - export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) { 34 + export function HeroPart({ 35 + setIsSticky, 36 + searchParams, 37 + showTitle, 38 + isInFeatured, 39 + }: HeroPartProps) { 52 40 const { t: randomT } = useRandomTranslation(); 53 41 const [search, setSearch, setSearchUnFocus] = searchParams; 54 - const [, setShowBg] = useState(false); 55 - const [isAdDismissed, setIsAdDismissed] = useState(() => { 56 - return getCookie("adDismissed") === "true"; 57 - }); 42 + const [showBg, setShowBg] = useState(false); 58 43 const bannerSize = useBannerSize(); 44 + 59 45 const stickStateChanged = useCallback( 60 46 (isFixed: boolean) => { 61 47 setShowBg(isFixed); ··· 63 49 }, 64 50 [setShowBg, setIsSticky], 65 51 ); 52 + 66 53 const { width: windowWidth, height: windowHeight } = useWindowSize(); 67 54 68 55 const { isTV } = useIsTV(); ··· 80 67 ? -40 // landscape 81 68 : 0; // portrait 82 69 83 - const dismissAd = useCallback(() => { 84 - setIsAdDismissed(true); 85 - setCookie("adDismissed", "true", 2); // Expires after 2 days 86 - }, []); 87 - 88 70 useEffect(() => { 89 71 if (windowWidth > 1280) { 90 72 // On large screens the bar goes inline with the nav elements ··· 103 85 104 86 return ( 105 87 <ThinContainer> 106 - <div className="mt-44 space-y-16 text-center"> 107 - <div className="relative z-10 mb-16"> 108 - {isTV && search.length > 0 ? null : ( 88 + <div 89 + className={classNames( 90 + "space-y-16 text-center", 91 + showTitle ? "mt-44" : `mt-4`, 92 + )} 93 + > 94 + {showTitle && (!isTV || search.length === 0) ? ( 95 + <div className="relative z-10 mb-16"> 109 96 <HeroTitle className="mx-auto max-w-md">{title}</HeroTitle> 110 - )} 111 - </div> 97 + </div> 98 + ) : null} 99 + 112 100 <div className="relative h-20 z-30"> 113 101 <Sticky 114 102 topOffset={stickyOffset * -1 + bannerSize} ··· 123 111 value={search} 124 112 onUnFocus={setSearchUnFocus} 125 113 placeholder={placeholder ?? ""} 114 + isSticky={showBg} 115 + isInFeatured={isInFeatured} 126 116 /> 127 117 </Sticky> 128 118 </div> 129 119 </div> 130 - 131 - {/* Optional ad */} 132 - {conf().SHOW_AD && !isAdDismissed ? ( 133 - <div className="-mb-10 md:-mb-20 w-fit max-w-[32rem] mx-auto relative group pb-4"> 134 - {(() => { 135 - const adContentUrl = conf().AD_CONTENT_URL; 136 - 137 - // VITE_AD_CONTENT_URL=default message (null will be nothing),referal link,image link, card message 138 - // Ensure adContentUrl is an array. If not, render nothing for ads. 139 - if (!Array.isArray(adContentUrl)) { 140 - return null; 141 - } 142 - 143 - const ad1LinkIsValid = 144 - typeof adContentUrl[1] === "string" && adContentUrl[1].length > 0; 145 - const ad1ImageIsProvided = typeof adContentUrl[2] === "string"; 146 - const showAd1 = 147 - adContentUrl.length >= 2 && ad1LinkIsValid && ad1ImageIsProvided; 148 - 149 - const ad2LinkIsValid = 150 - typeof adContentUrl[3] === "string" && adContentUrl[3].length > 0; 151 - const ad2ImageIsProvided = typeof adContentUrl[4] === "string"; 152 - const showAd2 = 153 - adContentUrl.length >= 5 && ad2LinkIsValid && ad2ImageIsProvided; 154 - 155 - return ( 156 - <> 157 - <div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0 justify-center w-full items-center md:items-start"> 158 - {showAd1 ? ( 159 - <div className="hover:scale-[1.02] max-w-[16rem] bg-opacity-10 bg-buttons-purple backdrop-blur-sm rounded-xl border-2 border-buttons-purple border-opacity-30 transition-all duration-300 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20 md:flex-1 relative group"> 160 - <button 161 - onClick={dismissAd} 162 - type="button" 163 - className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" 164 - aria-label="Dismiss ad" 165 - > 166 - <Icon 167 - className="text-xs font-semibold text-type-secondary" 168 - icon={Icons.X} 169 - /> 170 - </button> 171 - <a href={adContentUrl[1]} className="block"> 172 - <div className="overflow-hidden rounded-t-xl"> 173 - <img 174 - src={adContentUrl[2]} 175 - alt="ad banner" 176 - className="w-full h-auto transition-transform duration-300" 177 - /> 178 - </div> 179 - <p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary"> 180 - <span>{adContentUrl[3]}</span> 181 - </p> 182 - </a> 183 - </div> 184 - ) : null} 185 - {showAd2 ? ( 186 - <div className="hover:scale-[1.02] max-w-[16rem] bg-opacity-10 bg-buttons-purple backdrop-blur-sm rounded-xl border-2 border-buttons-purple border-opacity-30 transition-all duration-300 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20 md:flex-1 relative group"> 187 - <button 188 - onClick={dismissAd} 189 - type="button" 190 - className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" 191 - aria-label="Dismiss ad" 192 - > 193 - <Icon 194 - className="text-xs font-semibold text-type-secondary" 195 - icon={Icons.X} 196 - /> 197 - </button> 198 - <a href={adContentUrl[4]} className="block"> 199 - <div className="overflow-hidden rounded-t-xl"> 200 - <img 201 - src={adContentUrl[5]} 202 - alt="ad banner" 203 - className="w-full h-auto transition-transform duration-300" 204 - /> 205 - </div> 206 - <p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary"> 207 - <span>{adContentUrl[6]}</span> 208 - </p> 209 - </a> 210 - </div> 211 - ) : null} 212 - </div> 213 - {adContentUrl[0] !== "null" && ( 214 - <div> 215 - <p className="text-xs text-type-dimmed text-center pt-2 mx-4"> 216 - <a 217 - href="https://discord.gg/mcjnJK98Gd" 218 - target="_blank" 219 - rel="noreferrer" 220 - > 221 - {adContentUrl[0]} 222 - </a> 223 - </p> 224 - </div> 225 - )} 226 - </> 227 - ); 228 - })()} 229 - </div> 230 - ) : null} 231 - {/* End of ad */} 232 120 </ThinContainer> 233 121 ); 234 122 }
+61 -1
src/pages/parts/settings/AppearancePart.tsx
··· 205 205 enableDiscover: boolean; 206 206 setEnableDiscover: (v: boolean) => void; 207 207 208 + enableFeatured: boolean; 209 + setEnableFeatured: (v: boolean) => void; 210 + 208 211 enableDetailsModal: boolean; 209 212 setEnableDetailsModal: (v: boolean) => void; 213 + 214 + enableImageLogos: boolean; 215 + setEnableImageLogos: (v: boolean) => void; 210 216 }) { 211 217 const { t } = useTranslation(); 212 218 ··· 263 269 <div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> 264 270 {/* First Column - Preferences */} 265 271 <div className="space-y-8"> 272 + {/* Discover */} 266 273 <div> 267 274 <p className="text-white font-bold mb-3"> 268 275 {t("settings.appearance.options.discover")} ··· 271 278 {t("settings.appearance.options.discoverDescription")} 272 279 </p> 273 280 <div 274 - onClick={() => props.setEnableDiscover(!props.enableDiscover)} 281 + onClick={() => { 282 + const newDiscoverValue = !props.enableDiscover; 283 + props.setEnableDiscover(newDiscoverValue); 284 + if (!newDiscoverValue) { 285 + props.setEnableFeatured(false); 286 + } 287 + }} 275 288 className="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" 276 289 > 277 290 <Toggle enabled={props.enableDiscover} /> ··· 280 293 </p> 281 294 </div> 282 295 </div> 296 + {/* Featured Carousel */} 297 + {props.enableDiscover && ( 298 + <div className="pt-4 pl-4 border-l-8 border-dropdown-background"> 299 + <p className="text-white font-bold mb-3"> 300 + {t("settings.appearance.options.featured")} 301 + </p> 302 + <p className="max-w-[25rem] font-medium"> 303 + {t("settings.appearance.options.featuredDescription")} 304 + </p> 305 + <div 306 + onClick={() => props.setEnableFeatured(!props.enableFeatured)} 307 + className="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" 308 + > 309 + <Toggle enabled={props.enableFeatured} /> 310 + <p className="flex-1 text-white font-bold"> 311 + {t("settings.appearance.options.featuredLabel")} 312 + </p> 313 + </div> 314 + </div> 315 + )} 316 + {/* Detials Modal */} 283 317 <div> 284 318 <p className="text-white font-bold mb-3"> 285 319 {t("settings.appearance.options.modal")} ··· 299 333 <Toggle enabled={props.enableDetailsModal} /> 300 334 <p className="flex-1 text-white font-bold"> 301 335 {t("settings.appearance.options.modalLabel")} 336 + </p> 337 + </div> 338 + </div> 339 + {/* Image Logos */} 340 + <div> 341 + <p className="text-white font-bold mb-3"> 342 + {t("settings.appearance.options.logos")} 343 + </p> 344 + <p className="max-w-[25rem] font-medium"> 345 + {t("settings.appearance.options.logosDescription")} 346 + </p> 347 + <p className="max-w-[25rem] font-medium pt-2 items-center flex gap-4"> 348 + <Icon icon={Icons.CIRCLE_EXCLAMATION} className="" /> 349 + 350 + {t("settings.appearance.options.logosNotice")} 351 + </p> 352 + <div 353 + onClick={() => props.setEnableImageLogos(!props.enableImageLogos)} 354 + className={classNames( 355 + "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", 356 + "cursor-pointer opacity-100 pointer-events-auto", 357 + )} 358 + > 359 + <Toggle enabled={props.enableImageLogos} /> 360 + <p className="flex-1 text-white font-bold"> 361 + {t("settings.appearance.options.logosLabel")} 302 362 </p> 303 363 </div> 304 364 </div>
+2 -1
src/pages/parts/settings/PreferencesPart.tsx
··· 73 73 {t("settings.preferences.languageDescription")} 74 74 </p> 75 75 <Dropdown 76 + className="w-full" 76 77 options={options} 77 78 selectedItem={selected || options[0]} 78 79 setSelectedItem={(opt) => props.setLanguage(opt.id)} ··· 127 128 128 129 {/* Skip End Credits Preference */} 129 130 {props.enableAutoplay && allowAutoplay && ( 130 - <div className="pt-4"> 131 + <div className="pt-4 pl-4 border-l-8 border-dropdown-background"> 131 132 <p className="text-white font-bold mb-3"> 132 133 {t("settings.preferences.skipCredits")} 133 134 </p>
+10 -1
src/setup/App.tsx
··· 16 16 import { AdminPage } from "@/pages/admin/AdminPage"; 17 17 import VideoTesterView from "@/pages/developer/VideoTesterView"; 18 18 import { Discover } from "@/pages/discover/Discover"; 19 + import { MoreContent } from "@/pages/discover/MoreContent"; 19 20 import { DmcaPage } from "@/pages/Dmca"; 20 21 import MaintenancePage from "@/pages/errors/MaintenancePage"; 21 22 import { NotFoundPage } from "@/pages/errors/NotFoundPage"; ··· 166 167 {/* Support page */} 167 168 <Route path="/support" element={<SupportPage />} /> 168 169 <Route path="/jip" element={<JipPage />} /> 169 - {/* Discover page */} 170 + {/* Discover pages */} 170 171 <Route path="/discover" element={<Discover />} /> 172 + <Route 173 + path="/discover/more/:type/:id/:mediaType" 174 + element={<MoreContent />} 175 + /> 176 + <Route 177 + path="/discover/more/:category/:genreId?" 178 + element={<MoreContent />} 179 + /> 171 180 {/* Settings page */} 172 181 <Route 173 182 path="/settings"
+32
src/stores/discover/index.ts
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + 4 + type Category = "movies" | "tvshows" | "editorpicks"; 5 + 6 + interface DiscoverView { 7 + url: string; 8 + scrollPosition: number; 9 + } 10 + 11 + interface DiscoverState { 12 + selectedCategory: Category; 13 + lastView: DiscoverView | null; 14 + setSelectedCategory: (category: Category) => void; 15 + setLastView: (view: DiscoverView) => void; 16 + clearLastView: () => void; 17 + } 18 + 19 + export const useDiscoverStore = create<DiscoverState>()( 20 + persist( 21 + (set) => ({ 22 + selectedCategory: "movies", 23 + lastView: null, 24 + setSelectedCategory: (category) => set({ selectedCategory: category }), 25 + setLastView: (view) => set({ lastView: view }), 26 + clearLastView: () => set({ lastView: null }), 27 + }), 28 + { 29 + name: "__MW::discover", 30 + }, 31 + ), 32 + );
+16
src/stores/preferences/index.tsx
··· 7 7 enableAutoplay: boolean; 8 8 enableSkipCredits: boolean; 9 9 enableDiscover: boolean; 10 + enableFeatured: boolean; 10 11 enableDetailsModal: boolean; 12 + enableImageLogos: boolean; 11 13 sourceOrder: string[]; 12 14 enableSourceOrder: boolean; 13 15 proxyTmdb: boolean; ··· 16 18 setEnableAutoplay(v: boolean): void; 17 19 setEnableSkipCredits(v: boolean): void; 18 20 setEnableDiscover(v: boolean): void; 21 + setEnableFeatured(v: boolean): void; 19 22 setEnableDetailsModal(v: boolean): void; 23 + setEnableImageLogos(v: boolean): void; 20 24 setSourceOrder(v: string[]): void; 21 25 setEnableSourceOrder(v: boolean): void; 22 26 setProxyTmdb(v: boolean): void; ··· 29 33 enableAutoplay: true, 30 34 enableSkipCredits: true, 31 35 enableDiscover: true, 36 + enableFeatured: true, // enabled for testing 32 37 enableDetailsModal: false, 38 + enableImageLogos: true, 33 39 sourceOrder: [], 34 40 enableSourceOrder: false, 35 41 proxyTmdb: false, ··· 53 59 s.enableDiscover = v; 54 60 }); 55 61 }, 62 + setEnableFeatured(v) { 63 + set((s) => { 64 + s.enableFeatured = v; 65 + }); 66 + }, 56 67 setEnableDetailsModal(v) { 57 68 set((s) => { 58 69 s.enableDetailsModal = v; 70 + }); 71 + }, 72 + setEnableImageLogos(v) { 73 + set((s) => { 74 + s.enableImageLogos = v; 59 75 }); 60 76 }, 61 77 setSourceOrder(v) {