Merge pull request #707 from movie-web/dev

Version 4.2.3
This commit is contained in:
mrjvs 2024-01-04 00:21:38 +01:00 committed by GitHub
commit 89f97fe849
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1370 additions and 161 deletions

View File

@ -1,6 +1,6 @@
{
"name": "movie-web",
"version": "4.2.2",
"version": "4.2.3",
"private": true,
"homepage": "https://movie-web.app",
"scripts": {
@ -28,6 +28,7 @@
"dependencies": {
"@formkit/auto-animate": "^0.8.1",
"@headlessui/react": "^1.7.17",
"@ladjs/country-language": "^1.0.3",
"@movie-web/providers": "^2.0.2",
"@noble/hashes": "^1.3.3",
"@react-spring/web": "^9.7.3",
@ -44,7 +45,6 @@
"hls.js": "^1.4.14",
"i18next": "^23.7.11",
"immer": "^10.0.3",
"iso-639-1": "^3.1.0",
"jwt-decode": "^4.0.0",
"lodash.isequal": "^4.5.0",
"million": "^2.6.4",

11
pnpm-lock.yaml generated
View File

@ -17,6 +17,9 @@ dependencies:
'@headlessui/react':
specifier: ^1.7.17
version: 1.7.17(react-dom@18.2.0)(react@18.2.0)
'@ladjs/country-language':
specifier: ^1.0.3
version: 1.0.3
'@movie-web/providers':
specifier: ^2.0.2
version: 2.0.3
@ -65,9 +68,6 @@ dependencies:
immer:
specifier: ^10.0.3
version: 10.0.3
iso-639-1:
specifier: ^3.1.0
version: 3.1.0
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
@ -1912,6 +1912,11 @@ packages:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.4.15
/@ladjs/country-language@1.0.3:
resolution: {integrity: sha512-FJROu9/hh4eqVAGDyfL8vpv6Vb0qKHX1ozYLRZ+beUzD5xFf+3r0J+SVIWKviEa7W524Qvqou+ta1WrsRgzxGw==}
engines: {node: '>= 14'}
dev: false
/@movie-web/providers@2.0.3:
resolution: {integrity: sha512-6UNk5EebiNjGoFTuyHuu0eZZTreRYv0cdsn52CVYjm6CXG63w4dMbx8ybxcvMUrDF3o8bWlqnlovG142sdOmNw==}
dependencies:

21
src/@types/country-language.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
declare module "@ladjs/country-language" {
export interface LanguageObj {
countries: Array<{
code_2: string;
code_3: string;
numCode: string;
}>;
direction: "RTL" | "LTR";
name: string[];
nativeName: string[];
iso639_1: string;
}
type Callback<T> = (err: null | string, result: null | T) => void;
declare namespace lib {
function getLanguage(locale: string, cb: Callback<LanguageObj>): void;
}
export = lib;
}

9
src/assets/README.md Normal file
View File

@ -0,0 +1,9 @@
# About the languages
Locales are difficult, here is some guidance.
## Process on adding new languages
1. Use weblate to add translations, see contributing guidelines.
2. Add your language to `@/assets/languages.ts`. Must be in ISO format (ISO-639 for language and ISO-3166 for country/region). For joke languages, use any format.
3. If your language doesn't have a region specified (Such as in `pt-BR`, `BR` being the region). Add a default region in `@/utils/language.ts` at `defaultLanguageCodes`
4. If the flag in the language dropdown doesn't match the correct one. Add a default country in `@/utils/language.ts` at `countryPriority`.

View File

@ -1,26 +1,41 @@
import ar from "@/assets/locales/ar.json";
import bg from "@/assets/locales/bg.json";
import bn from "@/assets/locales/bn.json";
import cs from "@/assets/locales/cs.json";
import de from "@/assets/locales/de.json";
import el from "@/assets/locales/el.json";
import en from "@/assets/locales/en.json";
import es from "@/assets/locales/es.json";
import et from "@/assets/locales/et.json";
import fa from "@/assets/locales/fa.json";
import fr from "@/assets/locales/fr.json";
import gl from "@/assets/locales/gl.json";
import gu from "@/assets/locales/gu.json";
import he from "@/assets/locales/he.json";
import hi from "@/assets/locales/hi.json";
import id from "@/assets/locales/id.json";
import it from "@/assets/locales/it.json";
import ja from "@/assets/locales/ja.json";
import ko from "@/assets/locales/ko.json";
import lv from "@/assets/locales/lv.json";
import minion from "@/assets/locales/minion.json";
import ne from "@/assets/locales/ne.json";
import nl from "@/assets/locales/nl.json";
import pa from "@/assets/locales/pa.json";
import pirate from "@/assets/locales/pirate.json";
import pl from "@/assets/locales/pl.json";
import ptbr from "@/assets/locales/pt-BR.json";
import ro from "@/assets/locales/ro.json";
import ru from "@/assets/locales/ru.json";
import sl from "@/assets/locales/sl.json";
import sv from "@/assets/locales/sv.json";
import ta from "@/assets/locales/ta.json";
import th from "@/assets/locales/th.json";
import tok from "@/assets/locales/tok.json";
import tr from "@/assets/locales/tr.json";
import uk from "@/assets/locales/uk.json";
import vi from "@/assets/locales/vi.json";
import zhhant from "@/assets/locales/zh-Hant.json";
import zh from "@/assets/locales/zh.json";
export const locales = {
@ -46,9 +61,22 @@ export const locales = {
et,
tok,
hi,
pt: ptbr,
"pt-BR": ptbr,
uk,
bg,
bn,
el,
fa,
gu,
id,
ja,
ko,
sl,
ta,
"zh-HANT": zhhant,
ru,
gl,
pa,
ro,
};
export type Locales = keyof typeof locales;
export const rtlLocales: Locales[] = ["he", "ar"];

View File

@ -206,7 +206,8 @@
"episodeBadge": "E{{episode}}",
"loadingError": "خطأ في تحميل الموسم",
"loadingList": "تحميل...",
"loadingTitle": "تحميل..."
"loadingTitle": "تحميل...",
"unairedEpisodes": "تم تعطيل حلقة واحدة أو أكثر من هذا الموسم لأنه لم يتم بثها بعد."
},
"playback": {
"speedLabel": "سرعة التشغيل",

View File

@ -206,7 +206,8 @@
"episodeBadge": "E{{episode}}",
"loadingError": "Chyba při načítání sezóny",
"loadingList": "Načítání...",
"loadingTitle": "Načítání..."
"loadingTitle": "Načítání...",
"unairedEpisodes": "Jedna nebo více epizod v této sezóně nejsou dostupné, protože ještě nebyly odvysílány."
},
"playback": {
"speedLabel": "Rychlost přehrávání",

View File

@ -206,7 +206,8 @@
"episodeBadge": "E{{episode}}",
"loadingError": "Fehler beim Laden der Sitzung",
"loadingList": "Wird geladen...",
"loadingTitle": "Wird geladen..."
"loadingTitle": "Wird geladen...",
"unairedEpisodes": "Eine oder mehrere Episoden dieser Staffel wurden deaktiviert, weil sie noch nicht ausgestrahlt wurden."
},
"playback": {
"speedLabel": "Wiedergabegeschwindigkeit",

View File

@ -165,6 +165,12 @@
"close": "Close"
},
"player": {
"turnstile": {
"verifyingHumanity": "Verifying your humanity...",
"title": "We need to verify that you're human.",
"description": "Please verify that you are human by completing the Captcha on the right. This is to keep movie-web safe!",
"error": "Failed to verify your humanity. Please try again."
},
"back": {
"default": "Back to home",
"short": "Back"
@ -261,6 +267,10 @@
"text": "Could not load the media's metadata from TMDB. Please check whether TMDB is down or blocked on your internet connection.",
"title": "Failed to load metadata"
},
"api": {
"text": "Could not load API metadata, please check your internet connection.",
"title": "Failed to load API metadata"
},
"notFound": {
"badge": "Not found",
"homeButton": "Back to home",

View File

@ -206,7 +206,8 @@
"episodeBadge": "E{{episode}}",
"loadingError": "Error al cargar la temporada",
"loadingList": "Cargando...",
"loadingTitle": "Cargando..."
"loadingTitle": "Cargando...",
"unairedEpisodes": "Uno o más episodios de esta temporada se han desactivado porque aún no se han emitido."
},
"playback": {
"speedLabel": "Velocidad de reproducción",
@ -258,6 +259,10 @@
}
},
"metadata": {
"api": {
"text": "No ha sido posible cargar la metadata de la API, por favor, comprueba tu conexión a internet.",
"title": "No ha sido posible cargar los metadatos de la API"
},
"failed": {
"badge": "Error",
"homeButton": "Ir al inicio",
@ -307,6 +312,12 @@
"remaining": "{{timeLeft}} restante • Finaliza a las {{timeFinished, datetime}}",
"shortRegular": "{{timeWatched}}",
"shortRemaining": "-{{timeLeft}}"
},
"turnstile": {
"description": "Por favor, confirma que eres humano completando el Captcha. Esto es para mantener movie-web seguro!",
"error": "Ha habido un error al verificar tu humanidad. Por favor, prueba de nuevo.",
"title": "Necesitamos verificar que eres humano.",
"verifyingHumanity": "Verificando tu hunanidad…"
}
},
"screens": {

View File

@ -206,7 +206,8 @@
"episodeBadge": "E{{episode}}",
"loadingError": "Hooaja laadimine ebaōnnestus",
"loadingList": "Laadimine...",
"loadingTitle": "Laadimine..."
"loadingTitle": "Laadimine...",
"unairedEpisodes": "Üks või mitu selle hooaja episoodi on välja lülitatud, sest neid ei ole veel eetris olnud."
},
"playback": {
"speedLabel": "Taasesituse kiirus",
@ -258,6 +259,10 @@
}
},
"metadata": {
"api": {
"text": "API metaandmete laadimine ebaõnnestus, palun kontrollige oma internetiühendust.",
"title": "API metaandmete laadimine ebaõnnestus"
},
"failed": {
"badge": "Ebaōnnestus",
"homeButton": "Mine koju",
@ -307,6 +312,12 @@
"remaining": "{{timeLeft}} alles • Lõppeb {{timeFinished, datetime}}",
"shortRegular": "{{timeWatched}}",
"shortRemaining": "-{{timeLeft}}"
},
"turnstile": {
"description": "Palun kinnitage, et olete inimene, täites paremal asuva Captcha. See on selleks, et hoida movie-web turvalisena!",
"error": "Ei õnnestunud kontrollida teie inimlikkust. Palun proovige uuesti.",
"title": "Me peame kontrollima, et te olete inimene.",
"verifyingHumanity": "Kontrollime kas olete robot..."
}
},
"screens": {

View File

@ -206,7 +206,8 @@
"episodeBadge": "E{{episode}}",
"loadingError": "Erreur de chargement de la saison",
"loadingList": "Chargement...",
"loadingTitle": "Chargement..."
"loadingTitle": "Chargement...",
"unairedEpisodes": "Un ou plusieurs épisodes de cette saison ont été désactivés car ils n'ont pas encore été diffusés."
},
"playback": {
"speedLabel": "Vitesse de lecture",

437
src/assets/locales/gl.json Normal file
View File

@ -0,0 +1,437 @@
{
"about": {
"description": "movie-web é unha aplicación web que busca transmisións na rede. O equipo ten como obxectivo manter un enfoque principalmente minimalista para consumir os contidos.",
"faqTitle": "Preguntas frecuentes",
"q1": {
"body": "movie-web non aloxa ningún contido. Cando premes en algo para ver o contenido, búscase en internet o medio seleccionado. (Na pantalla de carga e na lapela 'fontes de video' podes ver que fonte se está a empregar. O contido nunca se carga en movie-web, todo realízase a través deste método de busca.",
"title": "De onde proveñen os contidos?"
},
"q2": {
"body": "Non é posible solicitar unha película. movie-web non xestiona ningún contido. Todo o contido é xestionado a través de fontes na rede.",
"title": "Onde poido solicitar unha película a engadir?"
},
"q3": {
"body": "Os nosos resultados de busqueda proveñen de The Movie Database (TMDB) e se mostran independentemente de se as nosas fontes multimedia teñen realmente o contido.",
"title": "Os resultados da busca mostran a serie ou película... Por qué non poido reproducila?"
},
"title": "Acerca de movie-web"
},
"actions": {
"copied": "Copiado",
"copy": "Copiar"
},
"auth": {
"createAccount": "Non tes unha conta aínda? <0>Crea unha conta.</0>",
"deviceNameLabel": "Nome do dispositivo",
"deviceNamePlaceholder": "Teléfono persoal",
"generate": {
"description": "A túa contraseña actua como o teu nome de usuario e contraseña. Asegúrate de mantelas seguras, xa que as necesitarás para iniciar sesión na túa conta",
"next": "Gardei a contraseña exitosamente",
"passphraseFrameLabel": "Contraseña",
"title": "A túa contraseña"
},
"hasAccount": "Tes xa unha conta? <0>Inicia sesión aquí.</0>",
"login": {
"description": "Por favor, ingresa a túa contraseña para iniciar sesión na túa conta",
"deviceLengthError": "Por favor, ingresa un nome de dispositivo",
"passphraseLabel": "Contraseña de 12 caracteres",
"passphrasePlaceholder": "Contraseña",
"submit": "Iniciar sesión",
"title": "Inicia sesión na túa conta",
"validationError": "Contraseña incorrecta ou incompleta"
},
"register": {
"information": {
"color1": "Cór de perfil un",
"color2": "Cór de perfil dous",
"header": "Ingresa un nome para o teu dispositivo, elixe cores, e un icono de usuario",
"icon": "Ícono de usuario",
"next": "Seguinte",
"title": "Información da conta"
}
},
"trust": {
"failed": {
"text": "Configurachelo correctamente?",
"title": "Non se puido conectar ao servidor"
},
"host": "Estaste a conectar a <0>{{hostname}}</0> - por favor, confirma se confías antes de crear a conta",
"no": "Regresar",
"title": "Confías neste servidor?",
"yes": "Si, si que confío neste servidor"
},
"verify": {
"description": "Por favor, ingresa a túa contraseña para confirmar que está gardada para crear a túa conta",
"invalidData": "Os datos non son válidos",
"noMatch": "A contraseña non coincide",
"passphraseLabel": "A contraseña debe de ser de 12 caracteres",
"recaptchaFailed": "A validación ReCaptcha fallou",
"register": "Crear conta",
"title": "Confirma a túa contraseña"
}
},
"errors": {
"badge": "Rompeu",
"details": "Detalles do erro",
"reloadPage": "Recargar a páxina",
"showError": "Mostrar detalles do erro",
"title": "Atopamos un erro!"
},
"footer": {
"legal": {
"disclaimer": "Descargo de responsabilidade",
"disclaimerText": "movie-web non aloxa ningún arquivo, simplemente enlaza con servizos de terceiros. Os problemas legais deben ser tratados cós proovedores de arquivos e servizos. movie-web non se fai responsable dos arquivos multimedia mostrados polos provedores de video."
},
"links": {
"discord": "Discord",
"dmca": "DMCA",
"github": "GitHub"
},
"tagline": "Disfruta das túas series e películas favoritas con esta aplicación de transmisión de código aberto."
},
"global": {
"name": "movie-web",
"pages": {
"about": "Acerca de",
"dmca": "DMCA",
"login": "Iniciar sesión",
"pagetitle": "{{title}} - movie-web",
"register": "Rexistrarse",
"settings": "Configuración"
}
},
"home": {
"bookmarks": {
"sectionTitle": "Marcadores"
},
"continueWatching": {
"sectionTitle": "Continuar vendo"
},
"mediaList": {
"stopEditing": "Deter edición"
},
"search": {
"allResults": "Esto é todo o que temos!",
"failed": "Error ao encontrar contido... intentao de novo!",
"loading": "Cargando...",
"noResults": "Non atopamos nada!",
"placeholder": "Que che gustaría ver?",
"sectionTitle": "Resultados da busca"
},
"titles": {
"day": {
"default": "Que che gustaría ver esta tarde?",
"extra": [
"Sínteste aventureiro? Jurassic Park podería ser a elección perfecta."
]
},
"morning": {
"default": "Que che gustaría ver esta mañá?",
"extra": [
"Escoitei que “Antes del amanecer” é boa"
]
},
"night": {
"default": "Que che gustaría ver esta noite?",
"extra": [
"Canso? Escoitei que “El Exorcista” é boa."
]
}
}
},
"media": {
"episodeDisplay": "T{{season}} E{{episode}}",
"types": {
"movie": "Película",
"show": "Serie"
}
},
"navigation": {
"banner": {
"offline": "Verifica a túa conexión a internet"
},
"menu": {
"about": "Acerca de nós",
"donation": "Doar",
"logout": "Cerrar sesión",
"register": "Sincronizar coa nube",
"settings": "Configuración",
"support": "Soporte"
}
},
"notFound": {
"badge": "Non atopado",
"goHome": "Volver ao inicio",
"message": "Prometocho, buscamos en todas partes: debaixo dos contenedores, no armario, detrás do proxy, pero ao final non puidemos atopar a páxina que estabas buscando.",
"title": "Non atopei a páxona que estabas a buscar"
},
"overlays": {
"close": "Cerrar"
},
"player": {
"back": {
"default": "Volver ao inicio",
"short": "Volver"
},
"casting": {
"enabled": "Transmitiendo ao dispositivo..."
},
"menus": {
"downloads": {
"disclaimer": "As descargas proveñen do provedor. movie-web non ten control sobre as descargas e a súa procedencia.",
"downloadPlaylist": "Descargar lista",
"downloadSubtitle": "Descargar subtítulos actuais",
"downloadVideo": "Descargar video",
"hlsDisclaimer": "As descargas realizanse directamente dende o proovedor. movie-web non ten control sobre como se xestionan as descargas. Ten en conta que estás a descargar unha lista de reproducción HLS, dirixidos a usuarios familiarizados coa transmisión multimedia avanzada.",
"onAndroid": {
"1": "Para descargar en Android, fai click no botón de descarga e despois, na nova páxina, <bold>mantén presionado</bold>o vídeo e selecciona <bold>gardar</bold>.",
"shortTitle": "Descargar / Android",
"title": "Descargando en Android"
},
"onIos": {
"1": "Para descargar en iOS, fai clic no botón de descarga e despois, na nova páxina, fai click en <bold><ios_share /></bold>, e despois <bold>Gardar en archivos <ios_files /></bold>.",
"shortTitle": "Descargar / iOS",
"title": "Descargando en iOS"
},
"onPc": {
"1": "Nunha PC, fai click no botón de descargas e despois, na nova páxina, fai click dereito no video e selecciona <bold>Gardar vídeo como...</bold>",
"shortTitle": "Descargar / PC",
"title": "Descargando en PC"
},
"title": "Descargar"
},
"episodes": {
"button": "Episodios",
"emptyState": "Non hai episodios nesta temporada, Intentao máis tarde!",
"episodeBadge": "E{{episode}}",
"loadingError": "Error cargando a sesión",
"loadingList": "Cargando...",
"loadingTitle": "Cargando...",
"unairedEpisodes": "Un ou máis episodios nesta temporada foron desactivados porque non sairon aínda."
},
"playback": {
"speedLabel": "Velocidade de reproducción",
"title": "Configuración de reproducción"
},
"quality": {
"automaticLabel": "Calidade automática",
"hint": "Podes intentar <0>cambiar de fonte</0> para obter diferentes opcións de calidade.",
"iosNoQuality": "Debido a limitacións definidas por Apple, a selección de calidade no está disponible en iOS para esta fonte. Podes intentar <0>cambiar a outra fonte</0> para obter diferentes opcións de calidade.",
"title": "Calidade"
},
"settings": {
"downloadItem": "Descargar",
"enableSubtitles": "Activar subtítulos",
"experienceSection": "Configuración de experiencia",
"playbackItem": "Configuración do playback",
"qualityItem": "Calidade",
"sourceItem": "Fonte do video",
"subtitleItem": "Configuración dos subtítulos",
"videoSection": "Configuración de video"
},
"sources": {
"failed": {
"text": "Acaba de producirse un erro ao intentar atopar videos, por favor, intenta cunha fonte distinta.",
"title": "Erro ao retirar"
},
"noEmbeds": {
"text": "Non puidemos atopar ningún embed, por favor, intenta cunha fonte diferente.",
"title": "No se atoparon embeds"
},
"noStream": {
"text": "Nesta fonte non hai contidos sobre esta película ou episodio.",
"title": "Sin fonte"
},
"title": "Fontes",
"unknownOption": "Descoñecido"
},
"subtitles": {
"customChoice": "Seleccionar subtítulos dende o arquivo",
"customizeLabel": "Personalizar",
"offChoice": "Apagar",
"settings": {
"backlink": "Subtítulos personalizados",
"delay": "Retardo dos subtítulos",
"fixCapitals": "Arreglar capitalización"
},
"title": "Subtítulos",
"unknownLanguage": "Descoñecido"
}
},
"metadata": {
"api": {
"text": "Non puiden cargar os metadatos da API, por favor, comproba a túa conexión a internet.",
"title": "Non foi posible cargar os metadatos da API"
},
"failed": {
"badge": "Erro",
"homeButton": "Ir ao inicio",
"text": "Non se puideron cargar os metadatos do contido de TMDB. Por favor, verifica se TMDB está caído ou bloqueado na túa conexión a internet.",
"title": "Error ao cargar os metadatos"
},
"notFound": {
"badge": "Non atopado",
"homeButton": "Volver ao inicio",
"text": "Non puidemos encontrar o contenido que solicitache. Xa seña que se eliminara ou modificara a URL.",
"title": "No se pudo atopar ese contenido."
}
},
"nextEpisode": {
"cancel": "Cancelar",
"next": "Seguinte episodio"
},
"playbackError": {
"badge": "Error de reproducción",
"errors": {
"errorAborted": "A obtención do contido foi cancelada pola solicitude do usuario.",
"errorDecode": "A pesar de ser determinado previamente como utilizable, produciuse un erro ao intentar decodificar o recurso do contido, o que resultou nun erro.",
"errorGenericMedia": "Produxose un erro descoñecido no contido.",
"errorNetwork": "Produxose un erro de rede que impidideu obter o contido de maneira exitosa, a pesar de estar disponible anteriormente.",
"errorNotSupported": "O contido ou o proovedor do contido non é compatible."
},
"homeButton": "Ir ao inicio",
"text": "Produxose un erro ao intentar reproducir o contenido. Por favor, inténtao de novo.",
"title": "Non se puido reproducir o video!"
},
"scraping": {
"items": {
"failure": "Ocurreu un erro",
"notFound": "Non ten o video",
"pending": "Verificando vídeos..."
},
"notFound": {
"badge": "Non atopado",
"detailsButton": "Mostrar detalles",
"homeButton": "Ir ao inicio",
"text": "Buscamos nos nosos proovedores e non puidemos atopar o contido que estás a buscar. Nós, non aloxamos o contido e non temos control sobre o que está dispoñible. Fai click en 'Mostrar detalles' a continuación para obter máis información.",
"title": "Non puidemos atopar eso"
}
},
"time": {
"regular": "{{timeWatched}} / {{duration}}",
"remaining": "{{timeLeft}} restante • Finaliza ás {{timeFinished, datetime}}",
"shortRegular": "{{timeWatched}}",
"shortRemaining": "-{{timeLeft}}"
},
"turnstile": {
"description": "Por favor, verifica que eres un humán completando o Captcha. Isto é para mantee movie-web seguro!",
"error": "Houbo un erro ao verificar a túa humanidade. Por favor, volve a intentalo.",
"title": "Necesitamos verificar que realmente eres un humán.",
"verifyingHumanity": "Verificando a túa humanidade…"
}
},
"screens": {
"dmca": {
"text": "Benvido/a á páxona de contacto DMA de movie-web! Respetamos os dereitos de propiedade intelectual e queremos abordar calqueiro problema de dereitos de autor de maneira más rápida. Se crees que o teu traballo con dereitos de autor está sendo empregado incorrectamente na nosa plataforma, envñia un aviso DMCA detallado ao correo electrónico que se mostra a continuación. Inclue unha descripción do material con dereitos de autor, os seus datos de contacto e unha declaración de boa fé. Estamos comprometidos a resolver estos asuntos o máis rápido posible e agradecemos a túa cooperación para manter a movie-web como un lugar que respeta a creatividade e os dereitos de autor.",
"title": "DMCA"
},
"loadingApp": "Cargando aplicación",
"loadingUser": "Cargando o teu perfil",
"loadingUserError": {
"logout": "Pechar sesión",
"reset": "Reiniciar servidor personalizado",
"text": "Erro ao cargar o teu perfil",
"textWithReset": "Erro ao cargar o teu perfil dende o teu servidor personalizado, queres reiniciar e volver ao servidor por defecto?"
},
"migration": {
"failed": "Erro ao migrar os teus datos.",
"inProgress": "Porfavor, espera mientras migramos tus datos. Esto no debería llevar mucho."
}
},
"settings": {
"account": {
"accountDetails": {
"deviceNameLabel": "Nome do dispositivo",
"deviceNamePlaceholder": "Teléfono persoal",
"editProfile": "Editar",
"logoutButton": "Pechar sesión"
},
"actions": {
"delete": {
"button": "Eliminar conta",
"confirmButton": "Eliminar conta",
"confirmDescription": "Estas seguro/a que queres eliminar a túa conta? Todos os datos serán eliminados!",
"confirmTitle": "Estás seguro/a?",
"text": "Esta acción é irreversible. Todos os datos serán eliminados e nada poderá ser recuperado.",
"title": "Eliminar conta"
},
"title": "Accións"
},
"devices": {
"deviceNameLabel": "Nome do dispositivo",
"failed": "Erro ao cargar sesións",
"removeDevice": "Quitar",
"title": "Dispositivos"
},
"profile": {
"finish": "Acabar de editar",
"firstColor": "Cór de perfil un",
"secondColor": "Cór de perfil dous",
"title": "Editar foto de perfil",
"userIcon": "Icono de usuario"
},
"register": {
"cta": "Empezar",
"text": "Compartir o teu progreso entre dispositivos e mantelos sincronizados.",
"title": "Sincronizar á nube"
},
"title": "Conta"
},
"appearance": {
"activeTheme": "Activo",
"themes": {
"blue": "Azul",
"default": "Por defecto",
"gray": "Gris",
"red": "Vermello",
"teal": "Turquesa"
},
"title": "Apariencia"
},
"connections": {
"server": {
"description": "Se che gustaría conectar un servidor personalizado de backend para almacenar os teus datos, activa esto e indica a URL.",
"label": "Servidor personalizado",
"urlLabel": "Servidor personalizado URL"
},
"title": "Conexións",
"workers": {
"addButton": "Añadir novo",
"description": "Para facer que a aplicación funcione, todo o tráfico é organizado en proxies. Activa esta opción se queres empregar os teus propios workers.",
"emptyState": "Non hai workers aínda, engade un abaixo",
"label": "Usar proxy workers personalizados",
"urlLabel": "URLs dos workers",
"urlPlaceholder": "https://"
}
},
"locale": {
"language": "Lingua da aplicación",
"languageDescription": "Lingua empregada en toda aplicación.",
"title": "Local"
},
"reset": "Reinicio",
"save": "Gardar",
"sidebar": {
"info": {
"appVersion": "Versión da aplicación",
"backendUrl": "URL do Backend",
"backendVersion": "Versión do Backend",
"hostname": "Nome do Host (Hostname)",
"insecure": "Non seguro",
"notLoggedIn": "Non iniciache sesión",
"secure": "Seguro",
"title": "Información da aplicación",
"unknownVersion": "Descoñecido",
"userId": "ID do usuario"
}
},
"subtitles": {
"backgroundLabel": "Opacidade do fondo",
"colorLabel": "Cór",
"previewQuote": "Non debo temer. O medo é o asasino da mente.",
"textSizeLabel": "Tamaño da fonte",
"title": "Subtítulos"
},
"unsaved": "Tes cambios sen gardar"
}
}

View File

@ -206,7 +206,8 @@
"episodeBadge": "એપિસોડ{{episode}}",
"loadingError": "સીઝન લોડ કરવામાં ભૂલ",
"loadingList": "લોડ થાય છે...",
"loadingTitle": "લોડ થાય છે..."
"loadingTitle": "લોડ થાય છે...",
"unairedEpisodes": "આ સિઝનમાં એક અથવા વધુ એપિસોડ અક્ષમ કરવામાં આવ્યા છે કારણ કે તે હજુ સુધી પ્રસારિત થયા નથી."
},
"playback": {
"speedLabel": "પ્લેબેક ઝડપ",

View File

@ -155,7 +155,7 @@
"donation": "दान करें",
"logout": "लॉग आउट",
"register": "क्लाउड से सिंक करें",
"settings": "चेड चाड करे",
"settings": "सेटिंग्स",
"support": "सहायता"
}
},
@ -206,7 +206,8 @@
"episodeBadge": "E{{episode}}",
"loadingError": "सीज़न लोड करने में त्रुटि",
"loadingList": "लोड हो रहा है..।",
"loadingTitle": "लोड हो रहा है..।"
"loadingTitle": "लोड हो रहा है..।",
"unairedEpisodes": "इस सीज़न में एक या अधिक एपिसोड अक्षम कर दिए गए हैं क्योंकि वे अभी तक प्रसारित नहीं हुए हैं।"
},
"playback": {
"speedLabel": "प्लेबैक गति",

View File

@ -206,7 +206,8 @@
"episodeBadge": "A{{episode}}",
"loadingError": "Er ging iets mis bij het laden van dit seizoen",
"loadingList": "Aan het laden...",
"loadingTitle": "Aan het zoeken..."
"loadingTitle": "Aan het zoeken...",
"unairedEpisodes": "Een of meer afleveringen van dit seizoen zijn uitgeschakeld omdat ze nog niet zijn uitgezonden."
},
"playback": {
"speedLabel": "Afspeelsnelheid",
@ -361,8 +362,66 @@
},
"register": {
"cta": "Aan de slag",
"text": "Deel de voortgang van je film tussen apparaten en houd ze gesynchroniseerd."
"text": "Deel uw kijkvoortgang tussen apparaten en houd ze gesynchroniseerd.",
"title": "Synchroniseren met de cloud"
},
"title": "Account"
},
"appearance": {
"activeTheme": "Actief",
"themes": {
"blue": "Blauw",
"default": "Standaard",
"gray": "Grijs",
"red": "Rood",
"teal": "Groenblauw"
},
"title": "Uiterlijk"
},
"connections": {
"server": {
"description": "Als je verbinding wilt maken met een eigen backend om je gegevens op te slaan, schakel dan deze optie in en geef de URL op.",
"label": "Eigen server",
"urlLabel": "Eigen server URL"
},
"title": "Verbindingen",
"workers": {
"addButton": "Nieuwe worker toevoegen",
"description": "Om de applicatie te laten werken, wordt al het verkeer omgeleid via proxies. Schakel dit in als je je eigen workers wilt gebruiken.",
"emptyState": "Nog geen workers, voeg er hieronder een toe",
"label": "Eigen proxy werker gebruiken",
"urlLabel": "Worker URLs",
"urlPlaceholder": "https://"
}
}
},
"locale": {
"language": "Applicatietaal",
"languageDescription": "Taal wordt toegepast op de hele applicatie.",
"title": "Lokaal"
},
"reset": "Resetten",
"save": "Wijzigingen opslaan",
"sidebar": {
"info": {
"appVersion": "App versie",
"backendUrl": "Backend URL",
"backendVersion": "Backend versie",
"hostname": "hostnaam",
"insecure": "Onveilig",
"notLoggedIn": "U bent niet ingelogd",
"secure": "Veilig",
"title": "App informatie",
"unknownVersion": "Onbekend",
"userId": "Gebruiker ID"
}
},
"subtitles": {
"backgroundLabel": "Achtergrond dekking",
"colorLabel": "Kleur",
"previewQuote": "Ik mag niet bang zijn. Angst doodt de geest.",
"textSizeLabel": "Tekengrootte",
"title": "Ondertiteling"
},
"unsaved": "U heeft niet-opgeslagen wijzigingen"
}
}

View File

@ -0,0 +1,18 @@
{
"actions": {
"copy": "ਕਾਪੀ"
},
"auth": {
"login": {
"submit": "ਲੌਗ-ਇਨ"
},
"register": {
"information": {
"next": "ਅਗਲਾ"
}
},
"trust": {
"no": "ਵਾਪਸ ਜਾਓ"
}
}
}

216
src/assets/locales/ro.json Normal file
View File

@ -0,0 +1,216 @@
{
"about": {
"description": "movie-web este o aplicație web care caută fluxuri pe internet. Echipa urmărește o abordare mai ales minimalistă a consumului de conținut.",
"faqTitle": "Intrebari obisnuite",
"q1": {
"body": "movie-web nu găzduiește niciun conținut. Când faceți clic pe ceva pentru a viziona, pe Internet este căutat media selectată (Pe ecranul de încărcare și în fila „Surse video”, puteți vedea ce sursă utilizați). Media nu este niciodată încărcată de movie-web, totul se face prin acest mecanism de căutare.",
"title": "De unde vine conținutul?"
},
"q2": {
"body": "Nu este posibil să solicitați o emisiune sau un film, movie-web nu gestionează niciun conținut. Tot conținutul este vizualizat prin surse de internet.",
"title": "Unde pot solicita o emisiune sau un film?"
},
"q3": {
"body": "Rezultatele căutării noastre sunt furnizate de The Movie Database (TMDB) și afișați indiferent dacă sursele noastre au de fapt conținutul.",
"title": "Rezultatele căutării afișează emisiunea sau filmul, de ce nu îl pot reda?"
},
"title": "Despre movie-web"
},
"actions": {
"copied": "Copiat",
"copy": "Copie"
},
"auth": {
"createAccount": "Nu aveți încă un cont? <0>Creați un cont.</0>",
"deviceNameLabel": "Nume dispozitiv",
"deviceNamePlaceholder": "Telefon personal",
"generate": {
"description": "Fraza de acces acționează ca nume de utilizator și parolă. Asigurați-vă că îl păstrați în siguranță, deoarece va trebui să îl introduceți pentru a vă conecta la contul dvs",
"next": "Mi-am salvat expresia de acces",
"passphraseFrameLabel": "Fraza de acces",
"title": "Fraza dvs. de acces"
},
"hasAccount": "ai deja un cont? <0>Autentificați-vă aici.</0>",
"login": {
"description": "Vă rugăm să introduceți fraza de acces pentru a vă conecta la contul dvs",
"deviceLengthError": "Introduceți un nume de dispozitiv",
"passphraseLabel": "Expresie de acces din 12 cuvinte",
"passphrasePlaceholder": "Fraza de acces",
"submit": "Log in",
"title": "conecteaza-te la contul tau",
"validationError": "Fraza de acces incorectă sau incompletă"
},
"register": {
"information": {
"color1": "Culoarea profilului unu",
"color2": "Culoarea profilului doi",
"header": "Introduceți un nume pentru dispozitivul dvs. și alegeți culorile și o pictogramă de utilizator la alegerea dvs",
"icon": "Pictograma utilizator",
"next": "Următorul",
"title": "Informatii despre cont"
}
},
"trust": {
"failed": {
"text": "L-ai configurat corect?",
"title": "Nu s-a putut ajunge la server"
},
"host": "Vă conectați la <0>{{hostname}}</0> - vă rugăm să confirmați că aveți încredere înainte de a vă crea un cont",
"no": "Întoarce-te",
"title": "Ai încredere în acest server?",
"yes": "Am încredere în acest server"
},
"verify": {
"description": "Introduceți expresia de acces de mai devreme pentru a confirma că ați salvat-o și pentru a vă crea contul",
"invalidData": "Datele nu sunt valide",
"noMatch": "Fraza de acces nu se potrivește",
"passphraseLabel": "Fraza dvs. de acces de 12 cuvinte",
"recaptchaFailed": "Validarea ReCaptcha a eșuat",
"register": "Creează cont",
"title": "Confirmați-vă fraza de acces"
}
},
"errors": {
"badge": "S-a spart",
"details": "Detalii despre eroare",
"reloadPage": "Reîncărcați pagina",
"showError": "Afișați detalii despre eroare",
"title": "Am intampinat o eroare!"
},
"footer": {
"legal": {
"disclaimer": "Disclaimer",
"disclaimerText": "movie-web nu găzduiește niciun fișier, ci doar trimite la servicii terțe. Problemele juridice ar trebui abordate cu gazdele și furnizorii de fișiere. movie-web nu este responsabil pentru niciun fișier media afișat de furnizorii de video."
},
"links": {
"discord": "Discord",
"dmca": "DMCA",
"github": "GitHub"
},
"tagline": "Urmăriți emisiunile și filmele preferate cu această aplicație de streaming open source."
},
"global": {
"name": "movie-web",
"pages": {
"about": "Despre",
"dmca": "DMCA",
"login": "Log in",
"pagetitle": "{{title}} - movie-web",
"register": "Inregistreaza-te",
"settings": "Setări"
}
},
"home": {
"bookmarks": {
"sectionTitle": "Marcaje"
},
"continueWatching": {
"sectionTitle": "Continuați vizionarea"
},
"mediaList": {
"stopEditing": "Opriți editarea"
},
"search": {
"allResults": "Asta e tot ce avem!",
"failed": "Găsire media eșuată, încearcă din nou!",
"loading": "Se încarcă...",
"noResults": "Nu am putut găsi nimic!",
"placeholder": "La ce dorești să te uiți?",
"sectionTitle": "Rezultate de căutare"
},
"titles": {
"day": {
"default": "La ce vrei să te uiți după-amiaza asta?",
"extra": [
"Te simți aventuros? Jurassic Park ar putea fi o alegere perfectă."
]
},
"morning": {
"default": "La ce dorești să te in uiți dimineață aceasta?",
"extra": [
"Aud că Before Sunrise este bun"
]
},
"night": {
"default": "La ce dorești să te uiți în astă seară?",
"extra": [
"Obosit? Aud că The Exorcist is good."
]
}
}
},
"media": {
"episodeDisplay": "S{{season}} E{{episode}}",
"types": {
"movie": "Film",
"show": "Spectacol"
}
},
"navigation": {
"banner": {
"offline": "Verificați-vă conexiunea de internet"
},
"menu": {
"about": "Despre noi",
"donation": "Donează",
"logout": "Deconectați-vă",
"register": "Sincronizare în cloud",
"settings": "Setări",
"support": "Ajutor"
}
},
"notFound": {
"badge": "Nu a fost găsit",
"goHome": "Înapoi acasă",
"message": "Ne-am uitat peste tot: sub pubele, în dulap, În spatele proxy-ului dar din păcate nu am găsit pagina pe care dumneavoastră o căutați.",
"title": "N-am putut găsi pagina"
},
"overlays": {
"close": "Închide"
},
"player": {
"back": {
"default": "Înapoi acasă",
"short": "Înapoi"
},
"casting": {
"enabled": "Casting pe dispozitiv..."
},
"menus": {
"episodes": {
"button": "Episoade",
"emptyState": "Nu sunt episoade in sezonul acesta, reveniți mai târziu!",
"episodeBadge": "E{{episode}}",
"loadingError": "Eroare la încărcarea sezonul",
"loadingList": "Se încarcă...",
"loadingTitle": "Se încarcă...",
"unairedEpisodes": "Unul sau mai multe episoade din sezonul acesta sunt indisponibile deoarece incă nu au venit încă."
},
"settings": {
"downloadItem": "Descarcă",
"enableSubtitles": "Activează subtitlurile",
"experienceSection": "Experiență de vizionare",
"playbackItem": "Setări de redare",
"qualityItem": "Calitate",
"sourceItem": "Surse video",
"subtitleItem": "Setările subtitlului",
"videoSection": "Setări video"
},
"sources": {
"noEmbeds": {
"text": "Nu am putut găsi nicio incorporare, vă rog să încercați o altă sursă.",
"title": "Nu a fost găsită nicio încorporare"
},
"noStream": {
"text": "Sursa asta nu are nicio sursă de streaming pentru filmul său spectacolul.",
"title": "Niciun stream"
},
"title": "Surse",
"unknownOption": "Necunoscut"
}
}
},
"settings": {
"unsaved": "Aveți modificări nesalvate"
}
}

View File

@ -1,6 +1,19 @@
{
"about": {
"description": "movie-web - это веб-приложение, которое ищет в интернете потоки. Команда стремится к минималистичному подходу к потреблению контента.",
"faqTitle": "Общие вопросы",
"q1": {
"body": "movie-web не размещает у себя никакого контента. Когда вы нажимаете на что-то для просмотра, в интернете происходит поиск выбранного медиа файла (на экране загрузки и во вкладке \"Видео источники\" вы можете увидеть, какой источник вы используете). Медиа файлы никогда не загружается на movie-web, все происходит через этот механизм поиска.",
"title": "Откуда берётся контент?"
},
"q2": {
"body": "Невозможно запросить сериал или фильм, movie-web не управляет никаким контентом. Весь контент просматривается через источники в интернете.",
"title": "Где я могу запросить показ сериала или фильма?"
},
"q3": {
"body": "Наши результаты поиска основаны на базе данных The Movie Database (TMDB) и отображаются вне зависимости от того, есть ли в наших источниках соответствующий контент.",
"title": "В результатах поиска отображается сериал или фильм, но почему я не могу воспроизвести его?"
},
"title": "О movie-web"
},
"actions": {
@ -12,6 +25,23 @@
"deviceNameLabel": "Имя устройства",
"deviceNamePlaceholder": "Личный телефон",
"hasAccount": "У вас уже есть аккаунт? <0>Войдите здесь.</0>",
"login": {
"deviceLengthError": "Введите имя устройства",
"passphraseLabel": "12-словная парольная фраза",
"submit": "Авторизоваться",
"title": "Войдите в свой аккаунт",
"validationError": "Неверная или неполная парольная фраза"
},
"register": {
"information": {
"color1": "Цвет профиля один",
"color2": "Цвет профиля два",
"header": "Введите название устройства, выберите цвета и значок пользователя по своему усмотрению",
"icon": "Значок пользователя",
"next": "Далее",
"title": "Информация об аккаунте"
}
},
"trust": {
"host": "Вы подключаетесь к <0>{{hostname}}</0> - пожалуйста, подтвердите, что вы доверяете ему, прежде чем создавать учётную запись",
"no": "Вернуться назад",
@ -22,16 +52,28 @@
"register": "Создать учётную запись"
}
},
"errors": {
"details": "Подробности ошибки",
"showError": "Показать сведения об ошибке"
},
"footer": {
"legal": {
"disclaimer": "Отказ от ответственности",
"disclaimerText": "movie-web не размещает никаких файлов, а лишь предоставляет ссылки на сторонние сервисы. Юридические вопросы следует решать с владельцами файлов и поставщиками услуг. movie-web не несёт ответственности за любые медиа файлы, показанные поставщиками видео."
},
"links": {
"discord": "Дискорд",
"discord": "Discord",
"dmca": "DMCA",
"github": "GitHub"
}
},
"tagline": "Смотрите любимые сериалы и фильмы с помощью этого приложения для потокового вещания с открытым исходным кодом."
},
"global": {
"name": "movie-web",
"pages": {
"about": "О",
"dmca": "DMCA",
"pagetitle": "{{title}} - movie-web",
"settings": "Настройки"
}
},
@ -43,13 +85,25 @@
"sectionTitle": "Продолжить просмотр"
},
"search": {
"allResults": "Это все, что у нас есть!",
"loading": "Загрузка..."
}
},
"media": {
"episodeDisplay": "С{{season}} Э{{episode}}",
"types": {
"movie": "Фильм",
"show": "Сериал"
}
},
"navigation": {
"banner": {
"offline": "Проверьте подключение к Интернету"
},
"menu": {
"about": "О нас",
"donation": "Пожертвовать",
"logout": "Выйти",
"settings": "Настройки",
"support": "Поддержка"
}
@ -63,16 +117,68 @@
"downloadSubtitle": "Скачать текущие субтитры",
"title": "Скачать"
},
"episodes": {
"button": "Эпизоды",
"loadingError": "Ошибка при загрузке сезона",
"loadingList": "Загрузка...",
"loadingTitle": "Загрузка..."
},
"playback": {
"speedLabel": "Скорость воспроизведения",
"title": "Настройки воспроизведения"
},
"quality": {
"automaticLabel": "Автоматическое качество"
},
"settings": {
"downloadItem": "Скачать",
"playbackItem": "Настройки воспроизведения",
"qualityItem": "Качество",
"sourceItem": "Видео источники",
"subtitleItem": "Настройки субтитров",
"videoSection": "Настройки видео"
},
"subtitles": {
"title": "Субтитры"
}
},
"playbackError": {
"badge": "Ошибка воспроизведения",
"errors": {
"errorDecode": "Несмотря на то, что ранее этот медиаресурс был пригодным для использования, при попытке его декодирования произошла ошибка."
},
"text": "При попытке воспроизвести медиа файл произошла ошибка. Пожалуйста, попробуйте ещё раз.",
"title": "Не удалось воспроизвести видео!"
}
},
"settings": {
"account": {
"accountDetails": {
"logoutButton": "Выйти"
},
"actions": {
"delete": {
"button": "Удалить аккаунт",
"confirmButton": "Удалить аккаунт",
"confirmDescription": "Вы уверены, что хотите удалить свой аккаунт? Все ваши данные будут потеряны!",
"confirmTitle": "Вы уверены?",
"text": "Это действие необратимо. Все данные будут удалены, и восстановить их будет невозможно.",
"title": "Удалить аккаунт"
},
"title": "Действия"
},
"devices": {
"deviceNameLabel": "Имя устройства",
"removeDevice": "Удалить",
"title": "Устройства"
},
"profile": {
"finish": "Завершить редактирование",
"firstColor": "Цвет профиля один",
"secondColor": "Цвет профиля два",
"title": "Редактирование изображения профиля",
"userIcon": "Значок пользователя"
},
"register": {
"title": "Синхронизировать с облаком"
},
@ -95,6 +201,7 @@
"label": "Пользовательский сервер",
"urlLabel": "URL-адрес пользовательского сервера"
},
"title": "Соединения",
"workers": {
"addButton": "Добавить новый прокси-сервер",
"description": "Для работы приложения весь трафик маршрутизируется через прокси. Включите это, если вы хотите использовать свои собственных прокси-серверы.",

View File

@ -206,7 +206,8 @@
"episodeBadge": "第{{episode}}集",
"loadingError": "加载分季时发生错误",
"loadingList": "载入中……",
"loadingTitle": "载入中……"
"loadingTitle": "载入中……",
"unairedEpisodes": "本季中的一集或多集已因尚未播出而被禁用。"
},
"playback": {
"speedLabel": "播放速度",

View File

@ -1,53 +1,32 @@
import classNames from "classnames";
import { getCountryCodeForLocale } from "@/utils/language";
import "flag-icons/css/flag-icons.min.css";
export interface FlagIconProps {
countryCode?: string;
country?: string;
langCode?: string;
}
// Country code overrides
const countryOverrides: Record<string, string> = {
en: "gb",
cs: "cz",
el: "gr",
fa: "ir",
ko: "kr",
he: "il",
ze: "cn",
ar: "sa",
ja: "jp",
bs: "ba",
vi: "vn",
zh: "cn",
sl: "si",
sv: "se",
et: "ee",
ne: "np",
uk: "ua",
hi: "in",
};
export function FlagIcon(props: FlagIconProps) {
let countryCode =
(props.countryCode || "")?.split("-").pop()?.toLowerCase() || "";
if (countryOverrides[countryCode])
countryCode = countryOverrides[countryCode];
let countryCode: string | null = props.country ?? null;
if (props.langCode) countryCode = getCountryCodeForLocale(props.langCode);
if (countryCode === "tok")
if (props.langCode === "tok")
return (
<div className="w-8 h-6 rounded bg-[#c8e1ed] flex justify-center items-center">
<img src="/tokiPona.svg" className="w-7 h-5" />
</div>
);
if (countryCode === "pirate")
if (props.langCode === "pirate")
return (
<div className="w-8 h-6 rounded bg-[#2E3439] flex justify-center items-center">
<img src="/skull.svg" className="w-4 h-4" />
</div>
);
if (countryCode === "minion")
if (props.langCode === "minion")
return (
<div className="w-8 h-6 rounded bg-[#ffff1a] flex justify-center items-center">
<div className="w-4 h-4 border-2 border-gray-500 rounded-full bg-white flex justify-center items-center">
@ -64,9 +43,9 @@ export function FlagIcon(props: FlagIconProps) {
return (
<span
className={classNames(
"!w-8 h-6 rounded overflow-hidden bg-cover bg-center block fi",
"!w-8 min-w-8 h-6 rounded overflow-hidden bg-cover bg-center block fi",
backgroundClass,
props.countryCode ? `fi-${countryCode}` : undefined,
countryCode ? `fi-${countryCode}` : undefined,
)}
/>
);

View File

@ -17,7 +17,7 @@ interface DropdownProps {
export function Dropdown(props: DropdownProps) {
return (
<div className="relative my-4 max-w-[18rem]">
<div className="relative my-4 max-w-[25rem]">
<Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
{() => (
<>

View File

@ -2,12 +2,14 @@ import classNames from "classnames";
import FocusTrap from "focus-trap-react";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Transition } from "@/components/utils/Transition";
import {
useInternalOverlayRouter,
useRouterAnchorUpdate,
} from "@/hooks/useOverlayRouter";
import { TurnstileProvider } from "@/stores/turnstile";
export interface OverlayProps {
id: string;
@ -15,6 +17,34 @@ export interface OverlayProps {
darken?: boolean;
}
function TurnstileInteractive() {
const { t } = useTranslation();
const [show, setShow] = useState(false);
// this may not rerender with different dom structure, must be exactly the same always
return (
<div
className={classNames(
"absolute w-10/12 max-w-[800px] bg-background-main p-20 rounded-lg select-none z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform",
show ? "" : "hidden",
)}
>
<div className="w-full grid lg:grid-cols-[1fr,auto] gap-12 items-center">
<div className="text-left">
<h2 className="text-type-emphasis font-bold text-xl mb-6">
{t("player.turnstile.title")}
</h2>
<p>{t("player.turnstile.description")}</p>
</div>
<TurnstileProvider
isInPopout
onUpdateShow={(shouldShow) => setShow(shouldShow)}
/>
</div>
</div>
);
}
export function OverlayDisplay(props: { children: ReactNode }) {
const router = useInternalOverlayRouter("hello world :)");
const refRouter = useRef(router);
@ -27,7 +57,12 @@ export function OverlayDisplay(props: { children: ReactNode }) {
r.close();
};
}, []);
return <div className="popout-location">{props.children}</div>;
return (
<div className="popout-location">
<TurnstileInteractive />
{props.children}
</div>
);
}
export function OverlayPortal(props: {

View File

@ -39,7 +39,7 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) {
if (!props.show || !currentThumbnail) return null;
return (
<div className="flex flex-col items-center -translate-x-1/2">
<div className="flex flex-col items-center -translate-x-1/2 pointer-events-none">
<div className="w-screen flex justify-center">
<div ref={ref}>
<div

View File

@ -10,12 +10,14 @@ import { useCaptions } from "@/components/player/hooks/useCaptions";
import { Menu } from "@/components/player/internals/ContextMenu";
import { Input } from "@/components/player/internals/ContextMenu/Input";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { getLanguageFromIETF } from "@/components/player/utils/language";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { useSubtitleStore } from "@/stores/subtitles";
import { sortLangCodes } from "@/utils/sortLangCodes";
import {
getPrettyLanguageNameFromLocale,
sortLangCodes,
} from "@/utils/language";
export function CaptionOption(props: {
countryCode?: string;
@ -37,7 +39,7 @@ export function CaptionOption(props: {
className="flex items-center"
>
<span data-code={props.countryCode} className="mr-3 inline-flex">
<FlagIcon countryCode={props.countryCode} />
<FlagIcon langCode={props.countryCode} />
</span>
<span>{props.children}</span>
</span>
@ -89,7 +91,8 @@ function useSubtitleList(subs: CaptionListItem[], searchQuery: string) {
return useMemo(() => {
const input = subs.map((t) => ({
...t,
languageName: getLanguageFromIETF(t.language) ?? unknownChoice,
languageName:
getPrettyLanguageNameFromLocale(t.language) ?? unknownChoice,
}));
const sorted = sortLangCodes(input.map((t) => t.language));
let results = input.sort((a, b) => {

View File

@ -6,11 +6,11 @@ import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon";
import { useCaptions } from "@/components/player/hooks/useCaptions";
import { Menu } from "@/components/player/internals/ContextMenu";
import { getLanguageFromIETF } from "@/components/player/utils/language";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import { qualityToString } from "@/stores/player/utils/qualities";
import { useSubtitleStore } from "@/stores/subtitles";
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
export function SettingsMenu({ id }: { id: string }) {
const { t } = useTranslation();
@ -31,7 +31,7 @@ export function SettingsMenu({ id }: { id: string }) {
const { toggleLastUsed } = useCaptions();
const selectedLanguagePretty = selectedCaptionLanguage
? getLanguageFromIETF(selectedCaptionLanguage) ??
? getPrettyLanguageNameFromLocale(selectedCaptionLanguage) ??
t("player.menus.subtitles.unknownLanguage")
: undefined;

View File

@ -1,14 +0,0 @@
import { getTag } from "@sozialhelden/ietf-language-tags";
export function getLanguageFromIETF(ietf: string): string | null {
const tag = getTag(ietf, true);
const lang = tag?.language?.Description?.[0] ?? null;
if (!lang) return null;
const region = tag?.region?.Description?.[0] ?? null;
let regionText = "";
if (region) regionText = ` (${region})`;
return `${lang}${regionText}`;
}

View File

@ -23,10 +23,9 @@ import { MigrationPart } from "@/pages/parts/migrations/MigrationPart";
import { LargeTextPart } from "@/pages/parts/util/LargeTextPart";
import App from "@/setup/App";
import { conf } from "@/setup/config";
import i18n from "@/setup/i18n";
import { useAuthStore } from "@/stores/auth";
import { BookmarkSyncer } from "@/stores/bookmarks/BookmarkSyncer";
import { useLanguageStore } from "@/stores/language";
import { changeAppLanguage, useLanguageStore } from "@/stores/language";
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
import { ThemeProvider } from "@/stores/theme";
@ -123,7 +122,7 @@ function AuthWrapper() {
function MigrationRunner() {
const status = useAsync(async () => {
i18n.changeLanguage(useLanguageStore.getState().language);
changeAppLanguage(useLanguageStore.getState().language);
await initializeOldStores();
}, []);
const { t } = useTranslation();

View File

@ -43,7 +43,11 @@ export function MetaPart(props: MetaPartProps) {
const { error, value, loading } = useAsync(async () => {
const providerApiUrl = getLoadbalancedProviderApiUrl();
if (providerApiUrl) {
await fetchMetadata(providerApiUrl);
try {
await fetchMetadata(providerApiUrl);
} catch (err) {
throw new Error("failed-api-metadata");
}
} else {
setCachedMetadata([
...providers.listSources(),
@ -117,6 +121,28 @@ export function MetaPart(props: MetaPartProps) {
);
}
if (error && error.message === "failed-api-metadata") {
return (
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.WAND}>
{t("player.metadata.failed.badge")}
</IconPill>
<Title>{t("player.metadata.api.text")}</Title>
<Paragraph>{t("player.metadata.api.title")}</Paragraph>
<Button
href="/"
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.failed.homeButton")}
</Button>
</ErrorContainer>
</ErrorLayout>
);
}
if (error) {
return (
<ErrorLayout>

View File

@ -1,6 +1,7 @@
import { ProviderControls, ScrapeMedia } from "@movie-web/providers";
import classNames from "classnames";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMountedState } from "react-use";
import type { AsyncReturnType } from "type-fest";
@ -8,6 +9,8 @@ import {
scrapePartsToProviderMetric,
useReportProviders,
} from "@/backend/helpers/report";
import { Icon, Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
import {
ScrapeCard,
ScrapeItem,
@ -18,6 +21,7 @@ import {
useListCenter,
useScrape,
} from "@/hooks/useProviderScrape";
import { LargeTextPart } from "@/pages/parts/util/LargeTextPart";
export interface ScrapingProps {
media: ScrapeMedia;
@ -32,9 +36,11 @@ export function ScrapingPart(props: ScrapingProps) {
const { report } = useReportProviders();
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
const isMounted = useMountedState();
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const [failedStartScrape, setFailedStartScrape] = useState<boolean>(false);
const renderedOnce = useListCenter(
containerRef,
listRef,
@ -72,7 +78,7 @@ export function ScrapingPart(props: ScrapingProps) {
),
);
props.onGetStream?.(output);
})();
})().catch(() => setFailedStartScrape(true));
}, [startScraping, props, report, isMounted]);
let currentProviderIndex = sourceOrder.findIndex(
@ -81,11 +87,28 @@ export function ScrapingPart(props: ScrapingProps) {
if (currentProviderIndex === -1)
currentProviderIndex = sourceOrder.length - 1;
if (failedStartScrape)
return (
<LargeTextPart
iconSlot={
<Icon className="text-type-danger text-2xl" icon={Icons.WARNING} />
}
>
{t("player.turnstile.error")}
</LargeTextPart>
);
return (
<div
className="h-full w-full relative dir-neutral:origin-top-left flex"
ref={containerRef}
>
{!sourceOrder || sourceOrder.length === 0 ? (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center flex flex-col justify-center z-0">
<Loading className="mb-8" />
<p>{t("player.turnstile.verifyingHumanity")}</p>
</div>
) : null}
<div
className={classNames({
"absolute transition-[transform,opacity] opacity-0 dir-neutral:left-0":
@ -97,7 +120,7 @@ export function ScrapingPart(props: ScrapingProps) {
{sourceOrder.map((order) => {
const source = sources[order.id];
const distance = Math.abs(
sourceOrder.findIndex((t) => t.id === order.id) -
sourceOrder.findIndex((o) => o.id === order.id) -
currentProviderIndex,
);
return (

View File

@ -4,7 +4,7 @@ import { FlagIcon } from "@/components/FlagIcon";
import { Dropdown } from "@/components/form/Dropdown";
import { Heading1 } from "@/components/utils/Text";
import { appLanguageOptions } from "@/setup/i18n";
import { sortLangCodes } from "@/utils/sortLangCodes";
import { getLocaleInfo, sortLangCodes } from "@/utils/language";
export function LocalePart(props: {
language: string;
@ -17,11 +17,13 @@ export function LocalePart(props: {
.sort((a, b) => sorted.indexOf(a.code) - sorted.indexOf(b.code))
.map((opt) => ({
id: opt.code,
name: `${opt.name}${opt.nativeName}`,
leftIcon: <FlagIcon countryCode={opt.code} />,
name: `${opt.name}${opt.nativeName ? `${opt.nativeName}` : ""}`,
leftIcon: <FlagIcon langCode={opt.code} />,
}));
const selected = options.find((item) => item.id === props.language);
const selected = options.find(
(item) => item.id === getLocaleInfo(props.language)?.code,
);
return (
<div>

View File

@ -1,8 +1,8 @@
import i18n from "i18next";
import ISO6391 from "iso-639-1";
import { initReactI18next } from "react-i18next";
import { locales } from "@/assets/languages";
import { getLocaleInfo } from "@/utils/language";
// Languages
const langCodes = Object.keys(locales);
@ -10,43 +10,15 @@ const resources = Object.fromEntries(
Object.entries(locales).map((entry) => [entry[0], { translation: entry[1] }]),
);
i18n.use(initReactI18next).init({
fallbackLng: "en",
fallbackLng: "en-US",
resources,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
const extraLanguages: Record<
string,
{
code: string;
name: string;
nativeName: string;
}
> = {
pirate: {
code: "pirate",
name: "Pirate",
nativeName: "Pirate Tongue",
},
minion: {
code: "minion",
name: "Minion",
nativeName: "Minionese",
},
tok: {
code: "tok",
name: "Toki pona",
nativeName: "Toki pona",
},
};
export const appLanguageOptions = langCodes.map((lang) => {
const extraLang = extraLanguages[lang];
if (extraLang) return extraLang;
const [langObj] = ISO6391.getLanguages([lang]);
const langObj = getLocaleInfo(lang);
if (!langObj)
throw new Error(`Language with code ${lang} cannot be found in database`);
return langObj;

View File

@ -11,24 +11,32 @@ interface BannerInstance {
interface BannerStore {
banners: BannerInstance[];
isOnline: boolean;
isTurnstile: boolean;
location: string | null;
updateHeight(id: string, height: number): void;
showBanner(id: string): void;
hideBanner(id: string): void;
setLocation(loc: string | null): void;
updateOnline(isOnline: boolean): void;
updateTurnstile(isTurnstile: boolean): void;
}
export const useBannerStore = create(
immer<BannerStore>((set) => ({
banners: [],
isOnline: true,
isTurnstile: false,
location: null,
updateOnline(isOnline) {
set((s) => {
s.isOnline = isOnline;
});
},
updateTurnstile(isTurnstile) {
set((s) => {
s.isTurnstile = isTurnstile;
});
},
setLocation(loc) {
set((s) => {
s.location = loc;

View File

@ -4,8 +4,8 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { rtlLocales } from "@/assets/languages";
import i18n from "@/setup/i18n";
import { getLocaleInfo } from "@/utils/language";
export interface LanguageStore {
language: string;
@ -26,14 +26,25 @@ export const useLanguageStore = create(
),
);
export function changeAppLanguage(language: string) {
const lang = getLocaleInfo(language);
if (lang) i18n.changeLanguage(lang.code);
}
export function isRightToLeft(language: string) {
const lang = getLocaleInfo(language);
if (!lang) return false;
return lang.isRtl;
}
export function LanguageProvider() {
const language = useLanguageStore((s) => s.language);
useEffect(() => {
i18n.changeLanguage(language);
changeAppLanguage(language);
}, [language]);
const isRtl = rtlLocales.includes(language as any);
const isRtl = isRightToLeft(language);
return (
<Helmet>

View File

@ -1,3 +1,5 @@
import classNames from "classnames";
import { useRef } from "react";
import Turnstile, { BoundTurnstileObject } from "react-turnstile";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
@ -6,19 +8,31 @@ import { reportCaptchaSolve } from "@/backend/helpers/report";
import { conf } from "@/setup/config";
export interface TurnstileStore {
turnstile: BoundTurnstileObject | null;
isInWidget: boolean;
turnstiles: {
controls: BoundTurnstileObject;
isInPopout: boolean;
id: string;
}[];
cbs: ((token: string | null) => void)[];
setTurnstile(v: BoundTurnstileObject | null): void;
setTurnstile(
id: string,
v: BoundTurnstileObject | null,
isInPopout: boolean,
): void;
getToken(): Promise<string>;
processToken(token: string | null): void;
processToken(token: string | null, widgetId: string): void;
}
export const useTurnstileStore = create(
immer<TurnstileStore>((set, get) => ({
turnstile: null,
isInWidget: false,
turnstiles: [],
cbs: [],
processToken(token) {
processToken(token, widgetId) {
const cbs = get().cbs;
const turnstile = get().turnstiles.find((v) => v.id === widgetId);
if (turnstile?.id !== widgetId) return;
cbs.forEach((fn) => fn(token));
set((s) => {
s.cbs = [];
@ -37,16 +51,26 @@ export const useTurnstileStore = create(
});
});
},
setTurnstile(v) {
setTurnstile(id, controls, isInPopout) {
set((s) => {
s.turnstile = v;
s.turnstiles = s.turnstiles.filter((v) => v.id !== id);
if (controls) {
s.turnstiles.push({
controls,
isInPopout,
id,
});
}
});
},
})),
);
export function getTurnstile() {
return useTurnstileStore.getState().turnstile;
const turnstiles = useTurnstileStore.getState().turnstiles;
const inPopout = turnstiles.find((v) => v.isInPopout);
if (inPopout) return inPopout;
return turnstiles[0];
}
export function isTurnstileInitialized() {
@ -55,9 +79,12 @@ export function isTurnstileInitialized() {
export async function getTurnstileToken() {
const turnstile = getTurnstile();
turnstile?.reset();
turnstile?.execute();
try {
// I hate turnstile
(window as any).turnstile.execute(
document.querySelector(`#${turnstile.id}`),
{},
);
const token = await useTurnstileStore.getState().getToken();
reportCaptchaSolve(true);
return token;
@ -67,23 +94,44 @@ export async function getTurnstileToken() {
}
}
export function TurnstileProvider() {
export function TurnstileProvider(props: {
isInPopout?: boolean;
onUpdateShow?: (show: boolean) => void;
}) {
const siteKey = conf().TURNSTILE_KEY;
const idRef = useRef<string | null>(null);
const setTurnstile = useTurnstileStore((s) => s.setTurnstile);
const processToken = useTurnstileStore((s) => s.processToken);
if (!siteKey) return null;
return (
<Turnstile
sitekey={siteKey}
onLoad={(_widgetId, bound) => {
setTurnstile(bound);
}}
onError={() => {
processToken(null);
}}
onVerify={(token) => {
processToken(token);
}}
/>
<div
className={classNames({
hidden: !props.isInPopout,
})}
>
<Turnstile
sitekey={siteKey}
onLoad={(widgetId, bound) => {
idRef.current = widgetId;
setTurnstile(widgetId, bound, !!props.isInPopout);
}}
onError={() => {
const id = idRef.current;
if (!id) return;
processToken(null, id);
}}
onVerify={(token) => {
const id = idRef.current;
if (!id) return;
processToken(token, id);
props.onUpdateShow?.(false);
}}
onBeforeInteractive={() => {
props.onUpdateShow?.(true);
}}
refreshExpired="never"
execution="render"
/>
</div>
);
}

190
src/utils/language.ts Normal file
View File

@ -0,0 +1,190 @@
import countryLanguages, { LanguageObj } from "@ladjs/country-language";
import { getTag } from "@sozialhelden/ietf-language-tags";
const languageOrder = ["en", "hi", "fr", "de", "nl", "pt"];
// mapping of language code to country code.
// multiple mappings can exist, since languages are spoken in multiple countries.
// This mapping purely exists to prioritize a country over another in languages.
// iso639_1 -> iso3166 Alpha-2
const countryPriority: Record<string, string> = {
en: "gb",
nl: "nl",
fr: "fr",
de: "de",
pt: "pt",
ar: "sa",
es: "es",
zh: "cn",
ko: "kr",
ta: "lk",
gl: "es",
};
// list of iso639_1 Alpha-2 codes used as default languages
const defaultLanguageCodes: string[] = [
"en-US",
"cs-CZ",
"de-DE",
"fr-FR",
"pt-BR",
"it-IT",
"nl-NL",
"pl-PL",
"tr-TR",
"vi-VN",
"zh-CN",
"he-IL",
"sv-SE",
"lv-LV",
"th-TH",
"ne-NP",
"ar-SA",
"es-ES",
"et-EE",
"bg-BG",
"bn-BD",
"el-GR",
"fa-IR",
"gu-IN",
"id-ID",
"ja-JP",
"ko-KR",
"sl-SI",
"ta-LK",
"ru-RU",
"gl-ES",
];
export interface LocaleInfo {
name: string;
nativeName?: string;
code: string;
isRtl?: boolean;
}
const extraLanguages: Record<string, LocaleInfo> = {
pirate: {
code: "pirate",
name: "Pirate",
nativeName: "Pirate Tongue",
},
minion: {
code: "minion",
name: "Minion",
nativeName: "Minionese",
},
tok: {
code: "tok",
name: "Toki pona",
nativeName: "Toki pona",
},
};
function populateLanguageCode(language: string): string {
if (language.includes("-")) return language;
if (language.length !== 2) return language;
return (
defaultLanguageCodes.find((v) => v.startsWith(`${language}-`)) ?? language
);
}
/**
* @param locale idk what kinda code this takes, anytihhng in ietf format I guess
* @returns pretty format for language, null if it no info can be found for language
*/
export function getPrettyLanguageNameFromLocale(locale: string): string | null {
const tag = getTag(populateLanguageCode(locale), true);
const lang = tag?.language?.Description?.[0] ?? null;
if (!lang) return null;
const region = tag?.region?.Description?.[0] ?? null;
let regionText = "";
if (region) regionText = ` (${region})`;
return `${lang}${regionText}`;
}
/**
* Sort locale codes by occurance, rest on alphabetical order
* @param langCodes list language codes to sort
* @returns sorted version of inputted list
*/
export function sortLangCodes(langCodes: string[]) {
const languagesOrder = [...languageOrder].reverse(); // Reverse is neccesary, not sure why
const results = langCodes.sort((a, b) => {
const langOrderA = languagesOrder.findIndex(
(v) => a.startsWith(`${v}-`) || a === v,
);
const langOrderB = languagesOrder.findIndex(
(v) => b.startsWith(`${v}-`) || b === v,
);
if (langOrderA !== -1 || langOrderB !== -1) return langOrderB - langOrderA;
return a.localeCompare(b);
});
return results;
}
/**
* Get country code for locale
* @param locale input locale
* @returns country code or null
*/
export function getCountryCodeForLocale(locale: string): string | null {
let output: LanguageObj | null = null as any as LanguageObj;
const tag = getTag(locale, true);
if (!tag?.language?.Subtag) return null;
// this function isnt async, so its garuanteed to work like this
countryLanguages.getLanguage(tag.language.Subtag, (_err, lang) => {
if (lang) output = lang;
});
if (!output) return null;
const priority = countryPriority[output.iso639_1.toLowerCase()];
if (output.countries.length === 0) {
return priority ?? null;
}
if (priority) {
const priotizedCountry = output.countries.find(
(v) => v.code_2.toLowerCase() === priority,
);
if (priotizedCountry) return priotizedCountry.code_2.toLowerCase();
}
return output.countries[0].code_2.toLowerCase();
}
/**
* Get information for a specific local
* @param locale local code
* @returns locale object
*/
export function getLocaleInfo(locale: string): LocaleInfo | null {
const realLocale = populateLanguageCode(locale);
const extraLang = extraLanguages[realLocale];
if (extraLang) return extraLang;
const tag = getTag(realLocale, true);
if (!tag?.language?.Subtag) return null;
let output: LanguageObj | null = null as any as LanguageObj;
// this function isnt async, so its garuanteed to work like this
countryLanguages.getLanguage(tag.language.Subtag, (_err, lang) => {
if (lang) output = lang;
});
if (!output) return null;
const extras = [];
if (tag.region?.Description) extras.push(tag.region.Description[0]);
if (tag.script?.Description) extras.push(tag.script.Description[0]);
const extraStringified = extras.map((v) => `(${v})`).join(" ");
return {
code: tag.parts.langtag ?? realLocale,
isRtl: output.direction === "RTL",
name: output.name[0] + (extraStringified ? ` ${extraStringified}` : ""),
nativeName: output.nativeName[0] ?? undefined,
};
}

View File

@ -1,12 +0,0 @@
export function sortLangCodes(langCodes: string[]) {
const languagesOrder = ["en", "hi", "fr", "de", "nl", "pt"].reverse(); // Reverse is neccesary, not sure why
const results = langCodes.sort((a, b) => {
if (languagesOrder.indexOf(b) !== -1 || languagesOrder.indexOf(a) !== -1)
return languagesOrder.indexOf(b) - languagesOrder.indexOf(a);
return a.localeCompare(b);
});
return results;
}

View File

@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [
million.vite({ auto: true }),
million.vite({ auto: true, mute: true }),
handlebars({
vars: {
opensearchEnabled: env.VITE_OPENSEARCH_ENABLED === "true",
@ -124,8 +124,8 @@ export default defineConfig(({ mode }) => {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes("@sozialhelden+ietf-language-tags")) {
return "ietf-language-tags";
if (id.includes("@sozialhelden+ietf-language-tags") || id.includes("country-language")) {
return "language-db";
}
if (id.includes("hls.js")) {
return "hls";