Merge pull request #499 from movie-web/various-fixes

Various fixes
This commit is contained in:
mrjvs 2023-11-29 21:08:00 +01:00 committed by GitHub
commit 4652498125
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1066 additions and 514 deletions

View File

@ -4,7 +4,7 @@
html, html,
body { body {
@apply bg-background-main font-open-sans text-type-text overflow-x-hidden; @apply bg-background-main font-open-sans text-type-text;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
} }
@ -221,4 +221,4 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
.tabbable:focus-visible { .tabbable:focus-visible {
outline: 2px solid theme('colors.themePreview.primary'); outline: 2px solid theme('colors.themePreview.primary');
box-shadow: 0 0 10px theme('colors.themePreview.secondary'); box-shadow: 0 0 10px theme('colors.themePreview.secondary');
} }

View File

@ -4,6 +4,7 @@ import en from "@/assets/locales/en.json";
import fr from "@/assets/locales/fr.json"; import fr from "@/assets/locales/fr.json";
import it from "@/assets/locales/it.json"; import it from "@/assets/locales/it.json";
import nl from "@/assets/locales/nl.json"; import nl from "@/assets/locales/nl.json";
import pirate from "@/assets/locales/pirate.json";
import pl from "@/assets/locales/pl.json"; import pl from "@/assets/locales/pl.json";
import tr from "@/assets/locales/tr.json"; import tr from "@/assets/locales/tr.json";
import vi from "@/assets/locales/vi.json"; import vi from "@/assets/locales/vi.json";
@ -20,4 +21,5 @@ export const locales = {
tr, tr,
vi, vi,
zh, zh,
pirate,
}; };

View File

@ -1,387 +1,391 @@
{ {
"auth": { "auth": {
"deviceNameLabel": "Device name", "deviceNameLabel": "Device name",
"deviceNamePlaceholder": "Muad'Dib's Nintendo Switch", "deviceNamePlaceholder": "Muad'Dib's Nintendo Switch",
"register": { "register": {
"information": { "information": {
"title": "Account information", "title": "Account information",
"color1": "First color", "color1": "First color",
"color2": "Second color", "color2": "Second color",
"icon": "User icon", "icon": "User icon",
"header": "Enter a name for your device and choose a user icon and colours" "header": "Enter a name for your device and choose a user icon and colours"
} }
},
"login": {
"title": "Login to your account",
"description": "Oh, you're asking for the key to my top-secret lair, also known as The Fortress of Wordsmithery, accessed only by reciting the sacred incantation of the 12-word passphrase!",
"validationError": "Invalid or incomplete passphrase",
"submit": "Login",
"passphraseLabel": "12-Word Passphrase",
"passphrasePlaceholder": "Passphrase"
},
"generate": {
"title": "Your passphrase",
"description": "If you lose this, you're a silly goose and will be posted on the wall of shame™"
},
"trust": {
"title": "Do you trust this host?",
"host": "Do you trust <0>{{hostname}}</0>?",
"failed": {
"title": "Failed to reach backend",
"text": "Did you configure it correctly?"
},
"yes": "Trust",
"no": "Go back"
},
"verify": {
"title": "Enter your passphrase",
"description": "If you've already lost it, how will you ever be able to take care of a child?",
"invalidData": "Data is not valid",
"noMatch": "Passphrase doesn't match",
"recaptchaFailed": "ReCaptcha validation failed",
"passphraseLabel": "Your passphrase",
"register": "Register"
}
}, },
"errors": { "login": {
"details": "Error details", "title": "Login to your account",
"reloadPage": "Reload the page", "description": "Oh, you're asking for the key to my top-secret lair, also known as The Fortress of Wordsmithery, accessed only by reciting the sacred incantation of the 12-word passphrase!",
"badge": "It broke", "validationError": "Invalid or incomplete passphrase",
"title": "That's an error boss" "submit": "Login",
"passphraseLabel": "12-Word Passphrase",
"passphrasePlaceholder": "Passphrase"
}, },
"notFound": { "generate": {
"badge": "Not found", "title": "Your passphrase",
"title": "Couldn't find that page", "description": "If you lose this, you're a silly goose and will be posted on the wall of shame™"
"message": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for.",
"goHome": "Back to home"
}, },
"global": { "trust": {
"name": "movie-web" "title": "Do you trust this host?",
"host": "Do you trust <0>{{hostname}}</0>?",
"failed": {
"title": "Failed to reach backend",
"text": "Did you configure it correctly?"
},
"yes": "Trust",
"no": "Go back"
}, },
"media": { "verify": {
"types": { "title": "Enter your passphrase",
"movie": "Movie", "description": "If you've already lost it, how will you ever be able to take care of a child?",
"show": "Show" "invalidData": "Data is not valid",
}, "noMatch": "Passphrase doesn't match",
"episodeDisplay": "S{{season}} E{{episode}}" "recaptchaFailed": "ReCaptcha validation failed",
}, "passphraseLabel": "Your passphrase",
"player": { "register": "Register"
"scraping": {
"notFound": {
"badge": "Not found",
"title": "Goo goo gaa gaa",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home"
},
"items": {
"pending": "Checking for videos...",
"notFound": "Doesn't have the video",
"failure": "Error occured"
}
},
"playbackError": {
"badge": "Not found",
"title": "Whoops, it broke!",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home",
"errors": {
"errorAborted": "The fetching of the associated resource was aborted by the user's request.",
"errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
"errorDecode": "Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.",
"errorNotSupported": "The associated resource or media provider object has been found to be unsuitable.",
"errorGenericMedia": "Unknown media error occured"
}
},
"metadata": {
"notFound": {
"badge": "Not found",
"title": "Couldn't find that media.",
"text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL.",
"homeButton": "Back to home"
},
"failed": {
"badge": "Failed",
"title": "Failed to load meta data",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home"
}
},
"back": {
"default": "Back to home",
"short": "Back"
},
"time": {
"short": "-{{timeLeft}}",
"regular": "{{timeWatched}} / {{duration}}",
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}"
},
"nextEpisode": {
"next": "Next episode",
"cancel": "Cancel"
},
"menus": {
"settings": {
"videoSection": "Video settings",
"experienceSection": "Viewing Experience",
"enableCaptions": "Enable Captions",
"captionItem": "Caption settings",
"sourceItem": "Video sources",
"playbackItem": "Playback settings",
"downloadItem": "Download",
"qualityItem": "Quality"
},
"episodes": {
"button": "Episodes",
"loadingTitle": "Loading...",
"loadingList": "Loading...",
"loadingError": "Error loading season",
"emptyState": "There are no episodes in this season, check back later!",
"episodeBadge": "E{{episode}}"
},
"sources": {
"title": "Sources",
"unknownOption": "Unknown",
"noStream": {
"title": "No stream",
"text": "This source has no streams for this movie or show."
},
"noEmbeds": {
"title": "No embeds found",
"text": "We were unable to find any embeds for this source, please try another."
},
"failed": {
"title": "Failed to scrape",
"text": "We were unable to find any videos for this source. Don't come bitchin' to us about it, just try another source."
}
},
"captions": {
"title": "Captions",
"customizeLabel": "Customize",
"settings": {
"fixCapitals": "Fix capitalization",
"delay": "Caption delay"
},
"customChoice": "Upload captions",
"offChoice": "Off",
"unknownLanguage": "Unknown"
},
"downloads": {
"title": "Download",
"disclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided.",
"hlsExplanation": "Insert explanation for why you can't download HLS here",
"downloadVideo": "Download video",
"downloadCaption": "Download current caption",
"onPc": {
"1": "On PC, right click the video and select <bold>Save video as</bold>",
"title": "Downloading on PC",
"shortTitle": "Download / PC"
},
"onAndroid": {
"1": "To download on Android, <bold>tap and hold</bold> on the video, then select <bold>save</bold>.",
"title": "Downloading on Android",
"shortTitle": "Download / Android"
},
"onIos": {
"1": "To download on iOS, click <bold><ios_share /></bold>, then <bold>Save to Files <ios_files /></bold>. All that's left to do now is to pick a nice and cozy folder for your video!",
"title": "Downloading on iOS",
"shortTitle": "Download / iOS"
}
},
"playback": {
"title": "Playback settings",
"speedLabel": "Playback speed"
},
"quality": {
"title": "Quality",
"automaticLabel": "Automatic quality",
"hint": "You can try <0>switching source</0> to get different quality options."
}
}
},
"home": {
"mediaList": {
"stopEditing": "Stop editing"
},
"titles": {
"morning": [
"Morning title"
],
"day": [
"Day title"
],
"night": [
"Night title"
]
},
"search": {
"loading": "Loading...",
"sectionTitle": "Search results",
"allResults": "That's all we have!",
"noResults": "We couldn't find anything!",
"failed": "Failed to find media, try again!",
"placeholder": "What do you want to watch?"
},
"continueWatching": {
"sectionTitle": "Continue Watching"
},
"bookmarks": {
"sectionTitle": "Bookmarks"
}
},
"overlays": {
"close": "Close"
},
"screens": {
"loadingUser": "Loading your profile",
"loadingApp": "Loading application",
"loadingUserError": {
"text": "Failed to load your profile",
"textWithReset": "Failed to load your profile from your custom server, want to reset back to default?",
"reset": "Reset custom server"
},
"migration": {
"failed": "Failed to migrate your data.",
"inProgress": "Please hold, we are migrating your data. This shouldn't take long."
},
"dmca": {
"title": "DMCA",
"text": "In an effort to address the copyright concerns associated with the website known as \"movie-web,\" the DMCA, or Digital Millennium Copyright Act, has been initiated to safeguard the intellectual property rights of content creators by reporting infringements on this platform, thereby adhering to legal protocols for takedown requests, which, like, you know, it's all about, like, maintaining the integrity of intellectual property, and, um, making sure, like, creators get their fair share, but then, it's, like, this intricate dance of digital legalities, where you have to, uh, like, navigate this labyrinth of code and bytes and, uh, send, you know, these, like, electronic documents that, um, point out the, uh, alleged infringement, and it's, like, this whole, like, teeter-totter of legality, where you're, like, balancing, um, the rights of the, you know, creators and the, um, operation of this, like, online, uh, entity, and, like, the DMCA, it's, like, this, um, powerful tool, but, uh, it's also, like, this, um, complex puzzle, where, you know, you're, like, seeking justice in the digital wilderness, and, uh, striving for harmony amidst the chaos of the internet, and, um, yeah, that's, like, the whole, like, DMCA-ing thing with movie-web, you know?"
}
},
"navigation": {
"banner": {
"offline": "Check your internet connection"
},
"menu": {
"register": "Sync to cloud",
"settings": "Settings",
"about": "About us",
"support": "Support",
"logout": "Log out"
}
},
"actions": {
"copy": "Copy",
"copied": "Copied",
"next": "Next"
},
"settings": {
"unsaved": "You have unsaved changes",
"reset": "Reset",
"save": "Save",
"sidebar": {
"info": {
"title": "App information",
"hostname": "Hostname",
"backendUrl": "Backend URL",
"userId": "User ID",
"notLoggedIn": "Not logged in",
"appVersion": "App version",
"backendVersion": "Backend version",
"unknownVersion": "Unknown",
"secure": "Secure",
"insecure": "Insecure"
}
},
"appearance": {
"title": "Appearance",
"activeTheme": "Active",
"themes": {
"default": "Default",
"blue": "Blue",
"teal": "Teal",
"red": "Red",
"gray": "Gray"
}
},
"account": {
"title": "Account",
"register": {
"title": "Sync to the cloud",
"text": "Instantly share your watch progress between devices and keep them synced.",
"cta": "Get started"
},
"profile": {
"title": "Edit profile picture",
"firstColor": "First color",
"secondColor": "Second color",
"userIcon": "User icon",
"finish": "Finish editing"
},
"devices": {
"title": "Devices",
"failed": "Failed to load sessions",
"deviceNameLabel": "Device name",
"removeDevice": "Remove"
},
"accountDetails": {
"editProfile": "Edit",
"deviceNameLabel": "Device name",
"deviceNamePlaceholder": "Fremen tablet",
"logoutButton": "Log out"
},
"actions": {
"title": "Actions",
"delete": {
"title": "Delete account",
"text": "This action is irreversible. All data will be deleted and nothing can be recovered.",
"button": "Delete account",
"confirmTitle": "Are you sure?",
"confirmDescription": "Are you sure you want to delete your account? All your data will be lost!",
"confirmButton": "Delete account"
}
}
},
"locale": {
"title": "Locale",
"language": "Application language",
"languageDescription": "Language applied to the entire application."
},
"captions": {
"title": "Captions",
"previewQuote": "I must not fear. Fear is the mind-killer.",
"backgroundLabel": "Background opacity",
"textSizeLabel": "Text size",
"colorLabel": "Color"
},
"connections": {
"title": "Connections",
"workers": {
"label": "Use custom proxy workers",
"description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers.",
"urlLabel": "Worker URLs",
"emptyState": "No workers yet, add one below",
"urlPlaceholder": "https://",
"addButton": "Add new worker"
},
"server": {
"label": "Custom server",
"description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers.",
"urlLabel": "Custom server URL"
}
}
},
"faq": {
"title": "About us",
"q1": {
"title": "1",
"body": "Body of 1"
},
"how": {
"title": "1",
"body": "Body of 1"
}
},
"footer": {
"tagline": "Watch your favorite shows and movies with this open source streaming app.",
"links": {
"github": "GitHub",
"dmca": "DMCA",
"discord": "Discord"
},
"legal": {
"disclaimer": "Disclaimer",
"disclaimerText": "movie-web does not host any files, it merely links to 3rd party services. Legal issues should be taken up with the file hosts and providers. movie-web is not responsible for any media files shown by the video providers."
}
} }
},
"errors": {
"details": "Error details",
"reloadPage": "Reload the page",
"badge": "It broke",
"title": "That's an error boss"
},
"notFound": {
"badge": "Not found",
"title": "Couldn't find that page",
"message": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for.",
"goHome": "Back to home"
},
"global": {
"name": "movie-web",
"pages": {
"pagetitle": "{{title}} - movie-web",
"dmca": "DMCA",
"settings": "Settings",
"about": "About",
"login": "Login",
"register": "Register"
}
},
"media": {
"types": {
"movie": "Movie",
"show": "Show"
},
"episodeDisplay": "S{{season}} E{{episode}}"
},
"player": {
"scraping": {
"notFound": {
"badge": "Not found",
"title": "Goo goo gaa gaa",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home"
},
"items": {
"pending": "Checking for videos...",
"notFound": "Doesn't have the video",
"failure": "Error occured"
}
},
"playbackError": {
"badge": "Not found",
"title": "Whoops, it broke!",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home",
"errors": {
"errorAborted": "The fetching of the associated resource was aborted by the user's request.",
"errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
"errorDecode": "Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.",
"errorNotSupported": "The associated resource or media provider object has been found to be unsuitable.",
"errorGenericMedia": "Unknown media error occured"
}
},
"metadata": {
"notFound": {
"badge": "Not found",
"title": "Couldn't find that media.",
"text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL.",
"homeButton": "Back to home"
},
"failed": {
"badge": "Failed",
"title": "Failed to load meta data",
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
"homeButton": "Go home"
}
},
"back": {
"default": "Back to home",
"short": "Back"
},
"time": {
"regular": "{{timeWatched}} / {{duration}}",
"shortRegular": "{{timeWatched}}",
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
"shortRemaining": "-{{timeLeft}}"
},
"nextEpisode": {
"next": "Next episode",
"cancel": "Cancel"
},
"menus": {
"settings": {
"videoSection": "Video settings",
"experienceSection": "Viewing Experience",
"enableCaptions": "Enable Captions",
"captionItem": "Caption settings",
"sourceItem": "Video sources",
"playbackItem": "Playback settings",
"downloadItem": "Download",
"qualityItem": "Quality"
},
"episodes": {
"button": "Episodes",
"loadingTitle": "Loading...",
"loadingList": "Loading...",
"loadingError": "Error loading season",
"emptyState": "There are no episodes in this season, check back later!",
"episodeBadge": "E{{episode}}"
},
"sources": {
"title": "Sources",
"unknownOption": "Unknown",
"noStream": {
"title": "No stream",
"text": "This source has no streams for this movie or show."
},
"noEmbeds": {
"title": "No embeds found",
"text": "We were unable to find any embeds for this source, please try another."
},
"failed": {
"title": "Failed to scrape",
"text": "We were unable to find any videos for this source. Don't come bitchin' to us about it, just try another source."
}
},
"captions": {
"title": "Captions",
"customizeLabel": "Customize",
"settings": {
"fixCapitals": "Fix capitalization",
"delay": "Caption delay"
},
"customChoice": "Upload captions",
"offChoice": "Off",
"unknownLanguage": "Unknown"
},
"downloads": {
"title": "Download",
"disclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided.",
"hlsExplanation": "Insert explanation for why you can't download HLS here",
"downloadVideo": "Download video",
"downloadCaption": "Download current caption",
"onPc": {
"1": "On PC, right click the video and select <bold>Save video as</bold>",
"title": "Downloading on PC",
"shortTitle": "Download / PC"
},
"onAndroid": {
"1": "To download on Android, <bold>tap and hold</bold> on the video, then select <bold>save</bold>.",
"title": "Downloading on Android",
"shortTitle": "Download / Android"
},
"onIos": {
"1": "To download on iOS, click <bold><ios_share /></bold>, then <bold>Save to Files <ios_files /></bold>. All that's left to do now is to pick a nice and cozy folder for your video!",
"title": "Downloading on iOS",
"shortTitle": "Download / iOS"
}
},
"playback": {
"title": "Playback settings",
"speedLabel": "Playback speed"
},
"quality": {
"title": "Quality",
"automaticLabel": "Automatic quality",
"hint": "You can try <0>switching source</0> to get different quality options.",
"iosNoQuality": "Due to Apple-defined 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."
}
}
},
"home": {
"mediaList": {
"stopEditing": "Stop editing"
},
"titles": {
"morning": ["Morning title"],
"day": ["Day title"],
"night": ["Night title"]
},
"search": {
"loading": "Loading...",
"sectionTitle": "Search results",
"allResults": "That's all we have!",
"noResults": "We couldn't find anything!",
"failed": "Failed to find media, try again!",
"placeholder": "What do you want to watch?"
},
"continueWatching": {
"sectionTitle": "Continue Watching"
},
"bookmarks": {
"sectionTitle": "Bookmarks"
}
},
"overlays": {
"close": "Close"
},
"screens": {
"loadingUser": "Loading your profile",
"loadingApp": "Loading application",
"loadingUserError": {
"text": "Failed to load your profile",
"textWithReset": "Failed to load your profile from your custom server, want to reset back to default?",
"reset": "Reset custom server"
},
"migration": {
"failed": "Failed to migrate your data.",
"inProgress": "Please hold, we are migrating your data. This shouldn't take long."
},
"dmca": {
"title": "DMCA",
"text": "In an effort to address the copyright concerns associated with the website known as \"movie-web,\" the DMCA, or Digital Millennium Copyright Act, has been initiated to safeguard the intellectual property rights of content creators by reporting infringements on this platform, thereby adhering to legal protocols for takedown requests, which, like, you know, it's all about, like, maintaining the integrity of intellectual property, and, um, making sure, like, creators get their fair share, but then, it's, like, this intricate dance of digital legalities, where you have to, uh, like, navigate this labyrinth of code and bytes and, uh, send, you know, these, like, electronic documents that, um, point out the, uh, alleged infringement, and it's, like, this whole, like, teeter-totter of legality, where you're, like, balancing, um, the rights of the, you know, creators and the, um, operation of this, like, online, uh, entity, and, like, the DMCA, it's, like, this, um, powerful tool, but, uh, it's also, like, this, um, complex puzzle, where, you know, you're, like, seeking justice in the digital wilderness, and, uh, striving for harmony amidst the chaos of the internet, and, um, yeah, that's, like, the whole, like, DMCA-ing thing with movie-web, you know?"
}
},
"navigation": {
"banner": {
"offline": "Check your internet connection"
},
"menu": {
"register": "Sync to cloud",
"settings": "Settings",
"about": "About us",
"support": "Support",
"logout": "Log out"
}
},
"actions": {
"copy": "Copy",
"copied": "Copied",
"next": "Next"
},
"settings": {
"unsaved": "You have unsaved changes",
"reset": "Reset",
"save": "Save",
"sidebar": {
"info": {
"title": "App information",
"hostname": "Hostname",
"backendUrl": "Backend URL",
"userId": "User ID",
"notLoggedIn": "Not logged in",
"appVersion": "App version",
"backendVersion": "Backend version",
"unknownVersion": "Unknown",
"secure": "Secure",
"insecure": "Insecure"
}
},
"appearance": {
"title": "Appearance",
"activeTheme": "Active",
"themes": {
"default": "Default",
"blue": "Blue",
"teal": "Teal",
"red": "Red",
"gray": "Gray"
}
},
"account": {
"title": "Account",
"register": {
"title": "Sync to the cloud",
"text": "Instantly share your watch progress between devices and keep them synced.",
"cta": "Get started"
},
"profile": {
"title": "Edit profile picture",
"firstColor": "First color",
"secondColor": "Second color",
"userIcon": "User icon",
"finish": "Finish editing"
},
"devices": {
"title": "Devices",
"failed": "Failed to load sessions",
"deviceNameLabel": "Device name",
"removeDevice": "Remove"
},
"accountDetails": {
"editProfile": "Edit",
"deviceNameLabel": "Device name",
"deviceNamePlaceholder": "Fremen tablet",
"logoutButton": "Log out"
},
"actions": {
"title": "Actions",
"delete": {
"title": "Delete account",
"text": "This action is irreversible. All data will be deleted and nothing can be recovered.",
"button": "Delete account",
"confirmTitle": "Are you sure?",
"confirmDescription": "Are you sure you want to delete your account? All your data will be lost!",
"confirmButton": "Delete account"
}
}
},
"locale": {
"title": "Locale",
"language": "Application language",
"languageDescription": "Language applied to the entire application."
},
"captions": {
"title": "Captions",
"previewQuote": "I must not fear. Fear is the mind-killer.",
"backgroundLabel": "Background opacity",
"textSizeLabel": "Text size",
"colorLabel": "Color"
},
"connections": {
"title": "Connections",
"workers": {
"label": "Use custom proxy workers",
"description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers.",
"urlLabel": "Worker URLs",
"emptyState": "No workers yet, add one below",
"urlPlaceholder": "https://",
"addButton": "Add new worker"
},
"server": {
"label": "Custom server",
"description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers.",
"urlLabel": "Custom server URL"
}
}
},
"faq": {
"title": "About us",
"q1": {
"title": "1",
"body": "Body of 1"
},
"how": {
"title": "1",
"body": "Body of 1"
}
},
"footer": {
"tagline": "Watch your favorite shows and movies with this open source streaming app.",
"links": {
"github": "GitHub",
"dmca": "DMCA",
"discord": "Discord"
},
"legal": {
"disclaimer": "Disclaimer",
"disclaimerText": "movie-web does not host any files, it merely links to 3rd party services. Legal issues should be taken up with the file hosts and providers. movie-web is not responsible for any media files shown by the video providers."
}
}
} }

View File

@ -0,0 +1,391 @@
{
"auth": {
"deviceNameLabel": "Ship name",
"deviceNamePlaceholder": "Muad'Dib's Pirate Ship",
"register": {
"information": {
"title": "Pirate Account information",
"color1": "First Mate color",
"color2": "Second Mate color",
"icon": "Pirate icon",
"header": "Enter a moniker for yer ship and choose a pirate icon and colors, arrr!"
}
},
"login": {
"title": "Hoist the Jolly Roger",
"description": "Arr, ye be askin' for the key to me top-secret lair, also known as The Fortress of Wordsmithery, accessed only by recitin' the sacred incantation of the 12-word passphrase!",
"validationError": "Arr, invalid or incomplete passphrase",
"submit": "Hoist Anchor",
"passphraseLabel": "12-Word Passphrase",
"passphrasePlaceholder": "Passphrase"
},
"generate": {
"title": "Yer Passphrase",
"description": "If ye lose this, ye be a silly goose and will be posted on the wall of shame™"
},
"trust": {
"title": "Do ye trust this ship?",
"host": "Do ye trust <0>{{hostname}}</0>?",
"failed": {
"title": "Failed to reach the backend",
"text": "Did ye configure it correctly?"
},
"yes": "Trust",
"no": "Abandon Ship"
},
"verify": {
"title": "Enter yer passphrase",
"description": "If ye already lost it, how will ye ever be able to take care of a wee buccaneer?",
"invalidData": "Data be not valid",
"noMatch": "Passphrase doesn't match",
"recaptchaFailed": "ReCaptcha validation failed",
"passphraseLabel": "Yer passphrase",
"register": "Register"
}
},
"errors": {
"details": "Error details",
"reloadPage": "Reload the page",
"badge": "Shiver me timbers",
"title": "That be an error, Captain"
},
"notFound": {
"badge": "Not found",
"title": "Couldn't find that treasure map",
"message": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the treasure map ye be lookin' for.",
"goHome": "Back to home port"
},
"global": {
"name": "movie-web",
"pages": {
"pagetitle": "{{title}} - movie-web",
"dmca": "DMCA",
"settings": "Settings",
"about": "About",
"login": "Login",
"register": "Register"
}
},
"media": {
"types": {
"movie": "Film",
"show": "Show"
},
"episodeDisplay": "S{{season}} E{{episode}}"
},
"player": {
"scraping": {
"notFound": {
"badge": "Not found",
"title": "Goo goo gaa gaa",
"text": "Oh, me apologies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can ye find it in yer heart to forgive? UwU 💖",
"homeButton": "Go home port"
},
"items": {
"pending": "Checkin' for videos...",
"notFound": "Doesn't have the video",
"failure": "Error occurred"
}
},
"playbackError": {
"badge": "Not found",
"title": "Whoops, it broke!",
"text": "Oh, me apologies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can ye find it in yer heart to forgive? UwU 💖",
"homeButton": "Go home port",
"errors": {
"errorAborted": "The fetchin' of the associated resource was aborted by the user's request.",
"errorNetwork": "Some kind of network error occurred which prevented the media from bein' successfully fetched, despite havin' previously been available.",
"errorDecode": "Despite havin' previously been determined to be usable, an error occurred while tryin' to decode the media resource, resultin' in an error.",
"errorNotSupported": "The associated resource or media provider object has been found to be unsuitable.",
"errorGenericMedia": "Unknown media error occurred"
}
},
"metadata": {
"notFound": {
"badge": "Not found",
"title": "Couldn't find that media.",
"text": "We couldn't find the media ye requested. Either it's been removed or ye tampered with the URL.",
"homeButton": "Back to home port"
},
"failed": {
"badge": "Failed",
"title": "Failed to load meta data",
"text": "Oh, me apologies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can ye find it in yer heart to forgive? UwU 💖",
"homeButton": "Go home port"
}
},
"back": {
"default": "Back to home port",
"short": "Back"
},
"time": {
"regular": "{{timeWatched}} / {{duration}}",
"shortRegular": "{{timeWatched}}",
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
"shortRemaining": "-{{timeLeft}}"
},
"nextEpisode": {
"next": "Next episode",
"cancel": "Cancel"
},
"menus": {
"settings": {
"videoSection": "Video settings",
"experienceSection": "Viewing Experience",
"enableCaptions": "Enable Sea Shanties",
"captionItem": "Sea Shanty settings",
"sourceItem": "Video sources",
"playbackItem": "Playback settings",
"downloadItem": "Buried Treasure",
"qualityItem": "Quality"
},
"episodes": {
"button": "Episodes",
"loadingTitle": "Loading...",
"loadingList": "Loading...",
"loadingError": "Error loadin' season",
"emptyState": "There be no episodes in this season, check back later!",
"episodeBadge": "E{{episode}}"
},
"sources": {
"title": "Sources",
"unknownOption": "Unknown",
"noStream": {
"title": "No stream",
"text": "This source has no streams for this film or show."
},
"noEmbeds": {
"title": "No embeds found",
"text": "We were unable to find any embeds for this source, please try another."
},
"failed": {
"title": "Failed to scrape",
"text": "We were unable to find any videos for this source. Don't come bitchin' to us about it, just try another source."
}
},
"captions": {
"title": "Sea Shanties",
"customizeLabel": "Customize",
"settings": {
"fixCapitals": "Fix capitalization",
"delay": "Shanty delay"
},
"customChoice": "Upload sea shanties",
"offChoice": "Off",
"unknownLanguage": "Unknown"
},
"downloads": {
"title": "Buried Treasure",
"disclaimer": "Downloads be taken directly from the provider. movie-web does not have control over how the downloads be provided.",
"hlsExplanation": "Insert explanation for why ye can't download HLS here",
"downloadVideo": "Download film",
"downloadCaption": "Download current sea shanty",
"onPc": {
"1": "On PC, right click the film and select <bold>Save film as</bold>",
"title": "Downloadin' on PC",
"shortTitle": "Download / PC"
},
"onAndroid": {
"1": "To download on Android, <bold>tap and hold</bold> on the film, then select <bold>save</bold>.",
"title": "Downloadin' on Android",
"shortTitle": "Download / Android"
},
"onIos": {
"1": "To download on iOS, click <bold><ios_share /></bold>, then <bold>Save to Files <ios_files /></bold>. All that's left to do now be to pick a nice and cozy chest for yer film!",
"title": "Downloadin' on iOS",
"shortTitle": "Download / iOS"
}
},
"playback": {
"title": "Playback settings",
"speedLabel": "Playback speed"
},
"quality": {
"title": "Quality",
"automaticLabel": "Automatic quality",
"hint": "Ye can try <0>switchin' source</0> to get different quality options.",
"iosNoQuality": "Due to Apple-defined limitations, quality selection be not available on iOS for this source. Ye can try <0>switchin' to another source</0> to get different quality options."
}
}
},
"home": {
"mediaList": {
"stopEditing": "Stop editin'"
},
"titles": {
"morning": ["Morning title"],
"day": ["Day title"],
"night": ["Night title"]
},
"search": {
"loading": "Loading...",
"sectionTitle": "Searchin' results",
"allResults": "That's all we have, me heartie!",
"noResults": "We couldn't find anythin', arrr!",
"failed": "Failed to find media, try again!",
"placeholder": "What do ye want to watch?"
},
"continueWatching": {
"sectionTitle": "Continue Watchin'"
},
"bookmarks": {
"sectionTitle": "Bookmarks"
}
},
"overlays": {
"close": "Close"
},
"screens": {
"loadingUser": "Loadin' yer pirate profile",
"loadingApp": "Loadin' application",
"loadingUserError": {
"text": "Failed to load yer pirate profile",
"textWithReset": "Failed to load yer pirate profile from yer custom ship, want to reset back to default?",
"reset": "Reset custom ship"
},
"migration": {
"failed": "Failed to migrate yer booty.",
"inProgress": "Please hold, we be migratin' yer booty. This shouldn't take long."
},
"dmca": {
"title": "DMCA",
"text": "In an effort to address the copyright concerns associated with the website known as \"movie-web,\" the DMCA, or Digital Jolly Roger Copyright Act, has been initiated to safeguard the intellectual property rights of content creators by reportin' infringements on this platform, thereby adherin' to legal protocols for takedown requests, which, like, ye know, it's all about, like, maintainin' the integrity of intellectual property, and, um, makin' sure, like, creators get their fair share, but then, it's, like, this intricate dance of digital legalities, where ye have to, uh, like, navigate this labyrinth of code and bytes and, uh, send, ye know, these, like, electronic documents that, um, point out the, uh, alleged infringement, and it's, like, this whole, like, teeter-totter of legality, where ye're, like, balancin', um, the rights of the, ye know, creators and the, um, operation of this, like, online, uh, entity, and, like, the DMCA, it's, like, this, um, powerful tool, but, uh, it's also, like, this, um, complex puzzle, where, ye know, ye're, like, seekin' justice in the digital wilderness, and, uh, strivin' for harmony amidst the chaos of the internet, and, um, yeah, that's, like, the whole, like, DMCA-ing thing with movie-web, ye know?"
}
},
"navigation": {
"banner": {
"offline": "Check yer internet connection"
},
"menu": {
"register": "Sync to the cloud",
"settings": "Settings",
"about": "About us",
"support": "Support",
"logout": "Abandon ship"
}
},
"actions": {
"copy": "Copy",
"copied": "Copied",
"next": "Next"
},
"settings": {
"unsaved": "Ye have unsaved changes",
"reset": "Reset",
"save": "Save",
"sidebar": {
"info": {
"title": "App information",
"hostname": "Ship name",
"backendUrl": "Backend URL",
"userId": "Pirate ID",
"notLoggedIn": "Not logged in",
"appVersion": "App version",
"backendVersion": "Backend version",
"unknownVersion": "Unknown",
"secure": "Secure",
"insecure": "Insecure"
}
},
"appearance": {
"title": "Appearance",
"activeTheme": "Active",
"themes": {
"default": "Default",
"blue": "Blue",
"teal": "Teal",
"red": "Red",
"gray": "Gray"
}
},
"account": {
"title": "Treasure Chest",
"register": {
"title": "Sync to the Cloud",
"text": "Instantly share yer watch progress between devices and keep 'em synced.",
"cta": "Get started"
},
"profile": {
"title": "Edit Pirate Portrait",
"firstColor": "First color",
"secondColor": "Second color",
"userIcon": "Pirate icon",
"finish": "Finish editing"
},
"devices": {
"title": "Shipmates",
"failed": "Failed to load sessions",
"deviceNameLabel": "Ship name",
"removeDevice": "Abandon ship"
},
"accountDetails": {
"editProfile": "Edit",
"deviceNameLabel": "Ship name",
"deviceNamePlaceholder": "Fremen tablet",
"logoutButton": "Abandon ship"
},
"actions": {
"title": "Actions",
"delete": {
"title": "Abandon Account",
"text": "This action be irreversible. All booty will be deleted and nothin' can be recovered.",
"button": "Abandon Account",
"confirmTitle": "Arrr ye sure?",
"confirmDescription": "Arrr ye sure ye want to abandon yer account? All yer booty will be lost!",
"confirmButton": "Abandon Account"
}
}
},
"locale": {
"title": "Locale",
"language": "Application language",
"languageDescription": "Language applied to the entire application."
},
"captions": {
"title": "Captions",
"previewQuote": "I must not fear. Fear be the mind-killer.",
"backgroundLabel": "Background opacity",
"textSizeLabel": "Text size",
"colorLabel": "Color"
},
"connections": {
"title": "Connections",
"workers": {
"label": "Use custom proxy sailors",
"description": "To make the application function, all traffic be routed through proxies. Enable this if ye want to bring yer own sailors.",
"urlLabel": "Sailor URLs",
"emptyState": "No sailors yet, add one below",
"urlPlaceholder": "https://",
"addButton": "Recruit new sailor"
},
"server": {
"label": "Custom ship",
"description": "To make the application function, all traffic be routed through proxies. Enable this if ye want to bring yer own sailors.",
"urlLabel": "Custom ship URL"
}
}
},
"faq": {
"title": "About us",
"q1": {
"title": "1",
"body": "Body of 1"
},
"how": {
"title": "1",
"body": "Body of 1"
}
},
"footer": {
"tagline": "Watch yer favorite shows and movies with this open source streaming ship.",
"links": {
"github": "GitHub",
"dmca": "DMCA",
"discord": "Discord"
},
"legal": {
"disclaimer": "Disclaimer",
"disclaimerText": "movie-web does not host any files, it merely links to 3rd party services. Legal issues should be taken up with the file hosts and providers. movie-web be not responsible for any media files shown by the video providers."
}
}
}

View File

@ -26,7 +26,7 @@ export function EditButton(props: EditButtonProps) {
<span ref={parent}> <span ref={parent}>
{props.editing ? ( {props.editing ? (
<span className="mx-4 whitespace-nowrap"> <span className="mx-4 whitespace-nowrap">
{t("media.stopEditing")} {t("home.mediaList.stopEditing")}
</span> </span>
) : ( ) : (
<Icon icon={Icons.EDIT} /> <Icon icon={Icons.EDIT} />

View File

@ -1,5 +1,5 @@
import c from "classnames"; import c from "classnames";
import { useState } from "react"; import { forwardRef, useState } from "react";
import { Flare } from "@/components/utils/Flare"; import { Flare } from "@/components/utils/Flare";
@ -13,50 +13,53 @@ export interface SearchBarProps {
value: string; value: string;
} }
export function SearchBarInput(props: SearchBarProps) { export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
const [focused, setFocused] = useState(false); (props, ref) => {
const [focused, setFocused] = useState(false);
function setSearch(value: string) { function setSearch(value: string) {
props.onChange(value, false); props.onChange(value, false);
} }
return ( return (
<Flare.Base <Flare.Base
className={c({ className={c({
"hover:flare-enabled group relative flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center": "hover:flare-enabled group relative flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center":
true, true,
"bg-search-background": !focused,
"bg-search-focused": focused,
})}
>
<Flare.Light
flareSize={400}
enabled={focused}
className="rounded-[28px]"
backgroundClass={c({
"transition-colors": true,
"bg-search-background": !focused, "bg-search-background": !focused,
"bg-search-focused": focused, "bg-search-focused": focused,
})} })}
/> >
<Flare.Light
<Flare.Child className="flex flex-1 flex-col"> flareSize={400}
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon"> enabled={focused}
<Icon icon={Icons.SEARCH} /> className="rounded-[28px]"
</div> backgroundClass={c({
"transition-colors": true,
<TextInputControl "bg-search-background": !focused,
onUnFocus={() => { "bg-search-focused": focused,
setFocused(false); })}
props.onUnFocus();
}}
onFocus={() => setFocused(true)}
onChange={(val) => setSearch(val)}
value={props.value}
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"
placeholder={props.placeholder}
/> />
</Flare.Child>
</Flare.Base> <Flare.Child className="flex flex-1 flex-col">
); <div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon">
} <Icon icon={Icons.SEARCH} />
</div>
<TextInputControl
ref={ref}
onUnFocus={() => {
setFocused(false);
props.onUnFocus();
}}
onFocus={() => setFocused(true)}
onChange={(val) => setSearch(val)}
value={props.value}
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"
placeholder={props.placeholder}
/>
</Flare.Child>
</Flare.Base>
);
}
);

View File

@ -50,7 +50,6 @@ export function OverlayPage(props: Props) {
className={classNames([props.className, ""])} className={classNames([props.className, ""])}
style={{ style={{
height: props.height ? `${props.height}px` : undefined, height: props.height ? `${props.height}px` : undefined,
maxHeight: "70vh",
width: props.width ? width : undefined, width: props.width ? width : undefined,
}} }}
> >

View File

@ -16,7 +16,7 @@ export function OverlayMobilePosition(props: MobilePositionProps) {
return ( return (
<div <div
className={classNames([ className={classNames([
"pointer-events-auto px-4 pb-6 z-10 bottom-0 block origin-top-left inset-x-0 absolute overflow-hidden", "pointer-events-auto px-4 pb-6 z-10 bottom-0 origin-top-left inset-x-0 absolute overflow-hidden max-h-[calc(100vh-1.5rem)] grid grid-rows-[minmax(0,1fr),auto]",
props.className, props.className,
])} ])}
> >

View File

@ -40,10 +40,14 @@ export function Time(props: { short?: boolean }) {
const timeFinished = new Date(Date.now() + secondsRemaining * 1e3); const timeFinished = new Date(Date.now() + secondsRemaining * 1e3);
const duration = formatSeconds(timeDuration, hasHours); const duration = formatSeconds(timeDuration, hasHours);
let localizationKey = "regular"; let localizationKey =
if (props.short) localizationKey = "short"; timeFormat === VideoPlayerTimeFormat.REGULAR ? "regular" : "remaining";
else if (timeFormat === VideoPlayerTimeFormat.REMAINING) if (props.short) {
localizationKey = "remaining"; localizationKey =
timeFormat === VideoPlayerTimeFormat.REGULAR
? "shortRegular"
: "shortRemaining";
}
return ( return (
<VideoPlayerButton onClick={() => toggleMode()}> <VideoPlayerButton onClick={() => toggleMode()}>

View File

@ -1,5 +1,6 @@
import Hls from "hls.js";
import { t } from "i18next"; import { t } from "i18next";
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { Toggle } from "@/components/buttons/Toggle"; import { Toggle } from "@/components/buttons/Toggle";
@ -13,6 +14,7 @@ import {
qualityToString, qualityToString,
} from "@/stores/player/utils/qualities"; } from "@/stores/player/utils/qualities";
import { useQualityStore } from "@/stores/quality"; import { useQualityStore } from "@/stores/quality";
import { canPlayHlsNatively } from "@/utils/detectFeatures";
const alwaysVisibleQualities: Record<SourceQuality, boolean> = { const alwaysVisibleQualities: Record<SourceQuality, boolean> = {
unknown: false, unknown: false,
@ -22,8 +24,21 @@ const alwaysVisibleQualities: Record<SourceQuality, boolean> = {
"1080": true, "1080": true,
}; };
function useIsIosHls() {
const sourceType = usePlayerStore((s) => s.source?.type);
const result = useMemo(() => {
const videoEl = document.createElement("video");
if (sourceType !== "hls") return false;
if (Hls.isSupported()) return false;
if (!canPlayHlsNatively(videoEl)) return false;
return true;
}, [sourceType]);
return result;
}
export function QualityView({ id }: { id: string }) { export function QualityView({ id }: { id: string }) {
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const isIosHls = useIsIosHls();
const availableQualities = usePlayerStore((s) => s.qualities); const availableQualities = usePlayerStore((s) => s.qualities);
const currentQuality = usePlayerStore((s) => s.currentQuality); const currentQuality = usePlayerStore((s) => s.currentQuality);
const switchQuality = usePlayerStore((s) => s.switchQuality); const switchQuality = usePlayerStore((s) => s.switchQuality);
@ -61,7 +76,7 @@ export function QualityView({ id }: { id: string }) {
<Menu.BackLink onClick={() => router.navigate("/")}> <Menu.BackLink onClick={() => router.navigate("/")}>
{t("player.menus.quality.title")} {t("player.menus.quality.title")}
</Menu.BackLink> </Menu.BackLink>
<Menu.Section> <Menu.Section className="flex flex-col pb-4">
{visibleQualities.map((v) => ( {visibleQualities.map((v) => (
<SelectableLink <SelectableLink
key={v} key={v}
@ -81,7 +96,13 @@ export function QualityView({ id }: { id: string }) {
{t("player.menus.quality.automaticLabel")} {t("player.menus.quality.automaticLabel")}
</Menu.Link> </Menu.Link>
<Menu.SmallText> <Menu.SmallText>
<Trans i18nKey="player.menus.quality.hint"> <Trans
i18nKey={
isIosHls
? "player.menus.quality.iosNoQuality"
: "player.menus.quality.hint"
}
>
<Menu.Anchor onClick={() => router.navigate("/source")}> <Menu.Anchor onClick={() => router.navigate("/source")}>
text text
</Menu.Anchor> </Menu.Anchor>

View File

@ -17,6 +17,7 @@ import {
canFullscreen, canFullscreen,
canFullscreenAnyElement, canFullscreenAnyElement,
canPictureInPicture, canPictureInPicture,
canPlayHlsNatively,
canWebkitFullscreen, canWebkitFullscreen,
canWebkitPictureInPicture, canWebkitPictureInPicture,
} from "@/utils/detectFeatures"; } from "@/utils/detectFeatures";
@ -69,6 +70,10 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
} }
function setupQualityForHls() { function setupQualityForHls() {
if (videoElement && canPlayHlsNatively(videoElement)) {
return; // nothing to change
}
if (!hls) return; if (!hls) return;
if (!automaticQuality) { if (!automaticQuality) {
const qualities = hlsLevelsToQualities(hls.levels); const qualities = hlsLevelsToQualities(hls.levels);
@ -95,8 +100,13 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
function setupSource(vid: HTMLVideoElement, src: LoadableSource) { function setupSource(vid: HTMLVideoElement, src: LoadableSource) {
if (src.type === "hls") { if (src.type === "hls") {
if (!Hls.isSupported()) throw new Error("HLS not supported"); if (canPlayHlsNatively(vid)) {
vid.src = src.url;
vid.currentTime = startAt;
return;
}
if (!Hls.isSupported()) throw new Error("HLS not supported");
if (!hls) { if (!hls) {
hls = new Hls({ hls = new Hls({
maxBufferSize: 500 * 1000 * 1000, // 500 mb of buffering, should load more fragments at once maxBufferSize: 500 * 1000 * 1000, // 500 mb of buffering, should load more fragments at once
@ -178,6 +188,14 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
emit("time", videoElement?.currentTime ?? 0) emit("time", videoElement?.currentTime ?? 0)
); );
videoElement.addEventListener("loadedmetadata", () => { videoElement.addEventListener("loadedmetadata", () => {
if (
source?.type === "hls" &&
videoElement &&
canPlayHlsNatively(videoElement)
) {
emit("qualities", ["unknown"]);
emit("changedquality", "unknown");
}
emit("duration", videoElement?.duration ?? 0); emit("duration", videoElement?.duration ?? 0);
}); });
videoElement.addEventListener("progress", () => { videoElement.addEventListener("progress", () => {

View File

@ -1,7 +1,7 @@
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities"; import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
import { Listener } from "@/utils/events"; import { Listener } from "@/utils/events";
export type DisplayErrorType = "hls" | "htmlvideo"; export type DisplayErrorType = "hls" | "htmlvideo" | "global";
export type DisplayError = { export type DisplayError = {
stackTrace?: string; stackTrace?: string;
message?: string; message?: string;

View File

@ -17,7 +17,7 @@ export function useShouldShowControls() {
// when using touch, pause screens can be dismissed by tapping // when using touch, pause screens can be dismissed by tapping
const showTargetsWithoutPause = const showTargetsWithoutPause =
isHovering || isHoveringControls || hasOpenOverlay; isHovering || (isHoveringControls && !isUsingTouch) || hasOpenOverlay;
const showTargetsIncludingPause = showTargetsWithoutPause || isPaused; const showTargetsIncludingPause = showTargetsWithoutPause || isPaused;
const showTargets = isUsingTouch const showTargets = isUsingTouch
? showTargetsWithoutPause ? showTargetsWithoutPause

View File

@ -0,0 +1,22 @@
import { useEffect } from "react";
export function useSlashFocus(ref: React.RefObject<HTMLInputElement>) {
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.key === "/") {
if (
document.activeElement &&
document.activeElement.tagName.toLowerCase() === "input"
)
return;
e.preventDefault();
ref.current?.focus();
}
};
window.addEventListener("keydown", listener);
return () => {
window.removeEventListener("keydown", listener);
};
}, [ref]);
}

View File

@ -1,7 +1,7 @@
export function Card(props: { children: React.ReactNode }) { export function Card(props: { children: React.ReactNode }) {
return ( return (
<div className="h-full grid grid-rows-[1fr]"> <div className="h-full grid grid-rows-[1fr]">
<div className="px-6 h-full overflow-y-auto overflow-x-hidden"> <div className="px-6 h-full flex flex-col justify-start overflow-y-auto overflow-x-hidden">
{props.children} {props.children}
</div> </div>
</div> </div>

View File

@ -0,0 +1,19 @@
export interface VideoTrack {
selected: boolean;
id: string;
kind: string;
label: string;
language: string;
}
export type VideoTrackList = Array<VideoTrack> & {
selectedIndex: number;
getTrackById(id: string): VideoTrack | null;
addEventListener(type: "change", listener: (ev: Event) => any): void;
};
export function getVideoTracks(video: HTMLVideoElement): VideoTrackList | null {
const videoAsAny = video as any;
if (!videoAsAny.videoTracks) return null;
return videoAsAny.videoTracks;
}

View File

@ -1,3 +1,5 @@
import { forwardRef } from "react";
export interface TextInputControlPropsNoLabel { export interface TextInputControlPropsNoLabel {
onChange?: (data: string) => void; onChange?: (data: string) => void;
onUnFocus?: () => void; onUnFocus?: () => void;
@ -13,39 +15,51 @@ export interface TextInputControlProps extends TextInputControlPropsNoLabel {
label?: string; label?: string;
} }
export function TextInputControl({ export const TextInputControl = forwardRef<
onChange, HTMLInputElement,
onUnFocus, TextInputControlProps
value, >(
label, (
name, {
autoComplete, onChange,
className, onUnFocus,
placeholder, value,
onFocus, label,
}: TextInputControlProps) { name,
const input = ( autoComplete,
<input className,
type="text" placeholder,
className={className} onFocus,
placeholder={placeholder} },
onChange={(e) => onChange && onChange(e.target.value)} ref
value={value} ) => {
name={name} const input = (
autoComplete={autoComplete} <input
onBlur={() => onUnFocus && onUnFocus()} type="text"
onFocus={() => onFocus?.()} ref={ref}
/> className={className}
); placeholder={placeholder}
onChange={(e) => onChange && onChange(e.target.value)}
if (label) { value={value}
return ( name={name}
<label> autoComplete={autoComplete}
<span>{label}</span> onBlur={() => onUnFocus && onUnFocus()}
{input} onFocus={() => onFocus?.()}
</label> onKeyDown={(e) =>
e.key === "Enter" ? (e.target as HTMLInputElement).blur() : null
}
/>
); );
}
return input; if (label) {
} return (
<label>
<span>{label}</span>
{input}
</label>
);
}
return input;
}
);

View File

@ -1,6 +1,5 @@
.lightbar, .lightbar-visual { .lightbar, .lightbar-visual {
position: absolute; position: absolute;
top: 0;
width: 500vw; width: 500vw;
height: 800px; height: 800px;
pointer-events: none; pointer-events: none;

View File

@ -1,4 +1,3 @@
import classNames from "classnames";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import "./Lightbar.css"; import "./Lightbar.css";
@ -162,15 +161,14 @@ function ParticlesCanvas() {
export function Lightbar(props: { className?: string }) { export function Lightbar(props: { className?: string }) {
return ( return (
<div <div className="absolute inset-0 w-full h-screen overflow-hidden pointer-events-none -mt-64">
className={classNames( <div className="max-w-screen w-full h-screen relative pt-64">
"grid grid-cols-[100%] w-full overflow-x-hidden", <div className={props.className}>
props.className <div className="lightbar">
)} <ParticlesCanvas />
> <div className="lightbar-visual" />
<div className="lightbar"> </div>
<ParticlesCanvas /> </div>
<div className="lightbar-visual" />
</div> </div>
</div> </div>
); );

View File

@ -24,7 +24,7 @@ export function Heading2(props: TextProps) {
return ( return (
<h2 <h2
className={[ className={[
"text-xl lg:text-3xl font-bold text-white mt-20 mb-9", "text-xl lg:text-2xl font-bold text-white mt-20 mb-9",
props.border ? borderClass : null, props.border ? borderClass : null,
props.className ?? "", props.className ?? "",
].join(" ")} ].join(" ")}

View File

@ -1,6 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { generatePath, useHistory, useParams } from "react-router-dom"; import { generatePath, useHistory, useParams } from "react-router-dom";
function decode(query: string | null | undefined) {
return query ? decodeURIComponent(query) : "";
}
export function useSearchQuery(): [ export function useSearchQuery(): [
string, string,
(inp: string, force?: boolean) => void, (inp: string, force?: boolean) => void,
@ -8,10 +12,10 @@ export function useSearchQuery(): [
] { ] {
const history = useHistory(); const history = useHistory();
const params = useParams<{ query: string }>(); const params = useParams<{ query: string }>();
const [search, setSearch] = useState(params.query ?? ""); const [search, setSearch] = useState(decode(params.query));
useEffect(() => { useEffect(() => {
setSearch(params.query ?? ""); setSearch(decode(params.query));
}, [params.query]); }, [params.query]);
const updateParams = (inp: string, commitToUrl = false) => { const updateParams = (inp: string, commitToUrl = false) => {

View File

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ThinContainer } from "@/components/layout/ThinContainer"; import { ThinContainer } from "@/components/layout/ThinContainer";
import { Ol } from "@/components/utils/Ol"; import { Ol } from "@/components/utils/Ol";
import { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; import { Heading1, Heading2, Paragraph } from "@/components/utils/Text";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { SubPageLayout } from "./layouts/SubPageLayout"; import { SubPageLayout } from "./layouts/SubPageLayout";
@ -19,6 +20,7 @@ export function AboutPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SubPageLayout> <SubPageLayout>
<PageTitle subpage k="global.pages.about" />
<ThinContainer> <ThinContainer>
<Heading1>{t("faq.title")}</Heading1> <Heading1>{t("faq.title")}</Heading1>
<Ol <Ol

View File

@ -1,9 +1,9 @@
/* eslint-disable react/no-unescaped-entities */
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { ThinContainer } from "@/components/layout/ThinContainer"; import { ThinContainer } from "@/components/layout/ThinContainer";
import { Heading1, Paragraph } from "@/components/utils/Text"; import { Heading1, Paragraph } from "@/components/utils/Text";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { SubPageLayout } from "./layouts/SubPageLayout"; import { SubPageLayout } from "./layouts/SubPageLayout";
@ -13,6 +13,7 @@ export function DmcaPage() {
return ( return (
<SubPageLayout> <SubPageLayout>
<PageTitle subpage k="global.pages.dmca" />
<ThinContainer> <ThinContainer>
<Heading1>{t("screens.dmca.title")}</Heading1> <Heading1>{t("screens.dmca.title")}</Heading1>
<Paragraph>{t("screens.dmca.text")}</Paragraph> <Paragraph>{t("screens.dmca.text")}</Paragraph>

View File

@ -2,12 +2,14 @@ import { useHistory } from "react-router-dom";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart"; import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart";
import { PageTitle } from "@/pages/parts/util/PageTitle";
export function LoginPage() { export function LoginPage() {
const history = useHistory(); const history = useHistory();
return ( return (
<SubPageLayout> <SubPageLayout>
<PageTitle subpage k="global.pages.login" />
<LoginFormPart <LoginFormPart
onLogin={() => { onLogin={() => {
history.push("/"); history.push("/");

View File

@ -11,6 +11,7 @@ import {
import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePart"; import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePart";
import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart"; import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart";
import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart"; import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart";
import { PageTitle } from "@/pages/parts/util/PageTitle";
function CaptchaProvider(props: { function CaptchaProvider(props: {
siteKey: string | null; siteKey: string | null;
@ -34,6 +35,7 @@ export function RegisterPage() {
return ( return (
<CaptchaProvider siteKey={siteKey}> <CaptchaProvider siteKey={siteKey}>
<SubPageLayout> <SubPageLayout>
<PageTitle subpage k="global.pages.register" />
{step === 0 ? ( {step === 0 ? (
<TrustBackendPart <TrustBackendPart
onNext={(meta: MetaResponse) => { onNext={(meta: MetaResponse) => {

View File

@ -15,6 +15,7 @@ import { Button } from "@/components/buttons/Button";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { UserIcons } from "@/components/UserIcon"; import { UserIcons } from "@/components/UserIcon";
import { Heading1 } from "@/components/utils/Text"; import { Heading1 } from "@/components/utils/Text";
import { Transition } from "@/components/utils/Transition";
import { useAuth } from "@/hooks/auth/useAuth"; import { useAuth } from "@/hooks/auth/useAuth";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
@ -27,6 +28,7 @@ import { DeviceListPart } from "@/pages/parts/settings/DeviceListPart";
import { RegisterCalloutPart } from "@/pages/parts/settings/RegisterCalloutPart"; import { RegisterCalloutPart } from "@/pages/parts/settings/RegisterCalloutPart";
import { SidebarPart } from "@/pages/parts/settings/SidebarPart"; import { SidebarPart } from "@/pages/parts/settings/SidebarPart";
import { ThemePart } from "@/pages/parts/settings/ThemePart"; import { ThemePart } from "@/pages/parts/settings/ThemePart";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { AccountWithToken, useAuthStore } from "@/stores/auth"; import { AccountWithToken, useAuthStore } from "@/stores/auth";
import { useLanguageStore } from "@/stores/language"; import { useLanguageStore } from "@/stores/language";
import { useSubtitleStore } from "@/stores/subtitles"; import { useSubtitleStore } from "@/stores/subtitles";
@ -190,6 +192,7 @@ export function SettingsPage() {
]); ]);
return ( return (
<SubPageLayout> <SubPageLayout>
<PageTitle subpage k="global.pages.settings" />
<SettingsLayout> <SettingsLayout>
<div id="settings-account"> <div id="settings-account">
<Heading1 border className="!mb-0"> <Heading1 border className="!mb-0">
@ -241,10 +244,10 @@ export function SettingsPage() {
/> />
</div> </div>
</SettingsLayout> </SettingsLayout>
<div <Transition
className={`bg-settings-saveBar-background border-t border-settings-card-border/50 py-4 transition-opacity w-full fixed bottom-0 flex justify-between flex-col md:flex-row px-8 items-start md:items-center gap-3 ${ animation="fade"
state.changed ? "opacity-100" : "opacity-0" show={state.changed}
}`} className="bg-settings-saveBar-background border-t border-settings-card-border/50 py-4 transition-opacity w-full fixed bottom-0 flex justify-between flex-col md:flex-row px-8 items-start md:items-center gap-3"
> >
<p className="text-type-danger">{t("settings.unsaved")}</p> <p className="text-type-danger">{t("settings.unsaved")}</p>
<div className="space-x-3 w-full md:w-auto flex"> <div className="space-x-3 w-full md:w-auto flex">
@ -263,7 +266,7 @@ export function SettingsPage() {
{t("settings.save")} {t("settings.save")}
</Button> </Button>
</div> </div>
</div> </Transition>
</SubPageLayout> </SubPageLayout>
); );
} }

View File

@ -3,25 +3,35 @@ import { useTranslation } from "react-i18next";
import { ButtonPlain } from "@/components/buttons/Button"; import { ButtonPlain } from "@/components/buttons/Button";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill"; import { IconPill } from "@/components/layout/IconPill";
import { DisplayError } from "@/components/player/display/displayInterface";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { Paragraph } from "@/components/utils/Text"; import { Paragraph } from "@/components/utils/Text";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout"; import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { ErrorCard } from "@/pages/parts/errors/ErrorCard";
export function ErrorPart(props: { error: any; errorInfo: any }) { export function ErrorPart(props: { error: any; errorInfo: any }) {
const data = JSON.stringify({
error: props.error,
errorInfo: props.errorInfo,
});
const { t } = useTranslation(); const { t } = useTranslation();
const maxLineCount = 5;
const errorLines = (props.errorInfo.componentStack || "")
.split("\n")
.slice(0, maxLineCount);
const error: DisplayError = {
errorName: "What does this do",
type: "global",
message: errorLines.join("\n"),
};
return ( return (
<div className="relative flex flex-1 flex-col"> <div className="relative flex flex-1 flex-col min-h-screen">
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center"> <div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center">
<ErrorLayout> <ErrorLayout>
<ErrorContainer> <ErrorContainer maxWidth="max-w-2xl">
<IconPill icon={Icons.EYE_SLASH}>{t("errors.badge")}</IconPill> <IconPill icon={Icons.EYE_SLASH}>{t("errors.badge")}</IconPill>
<Title>{t("errors.title")}</Title> <Title>{t("errors.title")}</Title>
<Paragraph>{data}</Paragraph> <Paragraph>{props.error.toString()}</Paragraph>
<ErrorCard error={error} />
<ButtonPlain <ButtonPlain
theme="purple" theme="purple"
className="mt-6 md:px-12 p-2.5" className="mt-6 md:px-12 p-2.5"

View File

@ -1,8 +1,9 @@
import { useCallback, useState } from "react"; import { useCallback, useRef, useState } from "react";
import Sticky from "react-sticky-el"; import Sticky from "react-sticky-el";
import { SearchBarInput } from "@/components/form/SearchBar"; import { SearchBarInput } from "@/components/form/SearchBar";
import { ThinContainer } from "@/components/layout/ThinContainer"; import { ThinContainer } from "@/components/layout/ThinContainer";
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
import { HeroTitle } from "@/components/text/HeroTitle"; import { HeroTitle } from "@/components/text/HeroTitle";
import { useRandomTranslation } from "@/hooks/useRandomTranslation"; import { useRandomTranslation } from "@/hooks/useRandomTranslation";
import { useSearchQuery } from "@/hooks/useSearchQuery"; import { useSearchQuery } from "@/hooks/useSearchQuery";
@ -33,6 +34,9 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
const title = t(`home.titles.${time}`); const title = t(`home.titles.${time}`);
const inputRef = useRef<HTMLInputElement>(null);
useSlashFocus(inputRef);
return ( return (
<ThinContainer> <ThinContainer>
<div className="mt-44 space-y-16 text-center"> <div className="mt-44 space-y-16 text-center">
@ -48,6 +52,7 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
onFixedToggle={stickStateChanged} onFixedToggle={stickStateChanged}
> >
<SearchBarInput <SearchBarInput
ref={inputRef}
onChange={setSearch} onChange={setSearch}
value={search} value={search}
onUnFocus={setSearchUnFocus} onUnFocus={setSearchUnFocus}

View File

@ -75,7 +75,7 @@ export function SidebarPart() {
function recheck() { function recheck() {
const windowHeight = const windowHeight =
window.innerHeight || document.documentElement.clientHeight; window.innerHeight || document.documentElement.clientHeight;
const middle = windowHeight / 2; const centerTarget = windowHeight / 4;
const viewList = settingLinks const viewList = settingLinks
.map((link) => { .map((link) => {
@ -83,15 +83,15 @@ export function SidebarPart() {
if (!el) return { distance: Infinity, link: link.id }; if (!el) return { distance: Infinity, link: link.id };
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const distanceTop = Math.abs(middle - rect.top); const distanceTop = Math.abs(centerTarget - rect.top);
const distanceBottom = Math.abs(middle - rect.bottom); const distanceBottom = Math.abs(centerTarget - rect.bottom);
const distance = Math.min(distanceBottom, distanceTop); const distance = Math.min(distanceBottom, distanceTop);
return { distance, link: link.id }; return { distance, link: link.id };
}) })
.sort((a, b) => a.distance - b.distance); .sort((a, b) => a.distance - b.distance);
// shortest distance to middle of screen is the active link // shortest distance to the part of the screen we want is the active link
setActiveLink(viewList[0]?.link ?? ""); setActiveLink(viewList[0]?.link ?? "");
} }
document.addEventListener("scroll", recheck); document.addEventListener("scroll", recheck);
@ -151,10 +151,10 @@ export function SidebarPart() {
{/* Backend URL */} {/* Backend URL */}
<div className="col-span-2 space-y-1"> <div className="col-span-2 space-y-1">
<p className="text-type-dimmed font-medium flex items-center"> <div className="text-type-dimmed font-medium flex items-center">
{t("settings.sidebar.info.backendUrl")} <p>{t("settings.sidebar.info.backendUrl")}</p>
<SecureBadge url={backendUrl} /> <SecureBadge url={backendUrl} />
</p> </div>
<p className="text-white"> <p className="text-white">
{backendUrl.replace(/https?:\/\//, "")} {backendUrl.replace(/https?:\/\//, "")}
</p> </p>
@ -193,7 +193,7 @@ export function SidebarPart() {
/> />
) : null} ) : null}
{backendMeta.loading ? ( {backendMeta.loading ? (
<div className="h-4 w-12 bg-type-dimmed/20 rounded" /> <span className="block h-4 w-12 bg-type-dimmed/20 rounded" />
) : ( ) : (
backendMeta?.value?.version || backendMeta?.value?.version ||
t("settings.sidebar.info.unknownVersion") t("settings.sidebar.info.unknownVersion")

View File

@ -0,0 +1,20 @@
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
export interface PageTitleProps {
k: string;
subpage?: boolean;
}
export function PageTitle(props: PageTitleProps) {
const { t } = useTranslation();
const title = t(props.k);
const subPageTitle = t("global.pages.pagetitle", { title });
return (
<Helmet>
<title>{props.subpage ? subPageTitle : title}</title>
</Helmet>
);
}

View File

@ -13,6 +13,7 @@ import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import { useOnlineListener } from "@/hooks/usePing"; import { useOnlineListener } from "@/hooks/usePing";
import { AboutPage } from "@/pages/About"; import { AboutPage } from "@/pages/About";
import { AdminPage } from "@/pages/admin/AdminPage"; import { AdminPage } from "@/pages/admin/AdminPage";
import VideoTesterView from "@/pages/developer/VideoTesterView";
import { DmcaPage } from "@/pages/Dmca"; import { DmcaPage } from "@/pages/Dmca";
import { NotFoundPage } from "@/pages/errors/NotFoundPage"; import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage"; import { HomePage } from "@/pages/HomePage";
@ -106,15 +107,12 @@ function App() {
path="/dev" path="/dev"
component={lazy(() => import("@/pages/DeveloperPage"))} component={lazy(() => import("@/pages/DeveloperPage"))}
/> />
<Route <Route path="/dev/video">
exact <VideoTesterView />
path="/dev/video" </Route>
component={lazy(() => import("@/pages/developer/VideoTesterView"))}
/>
{/* developer routes that can abuse workers are disabled in production */} {/* developer routes that can abuse workers are disabled in production */}
{process.env.NODE_ENV === "development" ? ( {process.env.NODE_ENV === "development" ? (
<Route <Route
exact
path="/dev/test" path="/dev/test"
component={lazy(() => import("@/pages/developer/TestView"))} component={lazy(() => import("@/pages/developer/TestView"))}
/> />

View File

@ -18,6 +18,13 @@ i18n.use(initReactI18next).init({
}); });
export const appLanguageOptions = langCodes.map((lang) => { export const appLanguageOptions = langCodes.map((lang) => {
if (lang === "pirate") {
return {
code: "pirate",
name: "Pirate",
nativeName: "Pirate Tongue",
};
}
const [langObj] = ISO6391.getLanguages([lang]); const [langObj] = ISO6391.getLanguages([lang]);
if (!langObj) if (!langObj)
throw new Error(`Language with code ${lang} cannot be found in database`); throw new Error(`Language with code ${lang} cannot be found in database`);

View File

@ -46,3 +46,7 @@ export function canPictureInPicture(): boolean {
export function canWebkitPictureInPicture(): boolean { export function canWebkitPictureInPicture(): boolean {
return "webkitSupportsPresentationMode" in document.createElement("video"); return "webkitSupportsPresentationMode" in document.createElement("video");
} }
export function canPlayHlsNatively(video: HTMLVideoElement): boolean {
return !!video.canPlayType("application/vnd.apple.mpegurl");
}