diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts
index fb5b98da..e5cc07b1 100644
--- a/src/backend/helpers/captions.ts
+++ b/src/backend/helpers/captions.ts
@@ -4,6 +4,10 @@ import DOMPurify from "dompurify";
import { parse, detect, list, convert } from "subsrt-ts";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
+export const customCaption = "external-custom";
+export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
+ return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
+}
export const subtitleTypeList = list().map((type) => `.${type}`);
export function isSupportedSubtitle(url: string): boolean {
return subtitleTypeList.some((type) => url.endsWith(type));
diff --git a/src/components/CaptionColorSelector.tsx b/src/components/CaptionColorSelector.tsx
new file mode 100644
index 00000000..3d286f78
--- /dev/null
+++ b/src/components/CaptionColorSelector.tsx
@@ -0,0 +1,29 @@
+import { useSettings } from "@/state/settings";
+import { Icon, Icons } from "./Icon";
+
+export const colors = ["#ffffff", "#00ffff", "#ffff00"];
+export default function CaptionColorSelector({ color }: { color: string }) {
+ const { captionSettings, setCaptionColor } = useSettings();
+ return (
+
setCaptionColor(color)}
+ >
+
+
+
+ );
+}
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx
index 84ab2957..9321e1fe 100644
--- a/src/components/Dropdown.tsx
+++ b/src/components/Dropdown.tsx
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
-
+
{props.options.map((opt) => (
diff --git a/src/components/layout/Modal.tsx b/src/components/layout/Modal.tsx
index 7a1c3b64..b3e7a22e 100644
--- a/src/components/layout/Modal.tsx
+++ b/src/components/layout/Modal.tsx
@@ -35,9 +35,14 @@ export function Modal(props: Props) {
);
}
-export function ModalCard(props: { children?: ReactNode }) {
+export function ModalCard(props: { className?: string; children?: ReactNode }) {
return (
-
+
{props.children}
);
diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx
index 4fe1864f..836c6206 100644
--- a/src/components/layout/Navigation.tsx
+++ b/src/components/layout/Navigation.tsx
@@ -1,9 +1,10 @@
-import { ReactNode } from "react";
+import { ReactNode, useState } from "react";
import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner";
+import SettingsModal from "@/views/SettingsModal";
import { BrandPill } from "./BrandPill";
export interface NavigationProps {
@@ -13,7 +14,7 @@ export interface NavigationProps {
export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
-
+ const [showModal, setShowModal] = useState(false);
return (
+
{
+ setShowModal(true);
+ }}
+ />
+ setShowModal(false)} />
);
}
diff --git a/src/setup/i18n.ts b/src/setup/i18n.ts
index d243c0f5..3b18e3f5 100644
--- a/src/setup/i18n.ts
+++ b/src/setup/i18n.ts
@@ -4,7 +4,13 @@ import LanguageDetector from "i18next-browser-languagedetector";
// Languages
import en from "./locales/en/translation.json";
+import { captionLanguages } from "./iso6391";
+const locales = {
+ en: {
+ translation: en,
+ },
+};
i18n
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
@@ -15,16 +21,14 @@ i18n
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: "en",
-
- resources: {
- en: {
- translation: en,
- },
- },
-
+ resources: locales,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
+export const appLanguageOptions = captionLanguages.filter((x) => {
+ return Object.keys(locales).includes(x.id);
+});
+
export default i18n;
diff --git a/src/setup/iso6391.ts b/src/setup/iso6391.ts
new file mode 100644
index 00000000..c20580b8
--- /dev/null
+++ b/src/setup/iso6391.ts
@@ -0,0 +1,1326 @@
+export type LangCode =
+ | "none"
+ | "aa"
+ | "ab"
+ | "ae"
+ | "af"
+ | "ak"
+ | "am"
+ | "an"
+ | "ar"
+ | "as"
+ | "av"
+ | "ay"
+ | "az"
+ | "ba"
+ | "be"
+ | "bg"
+ | "bh"
+ | "bi"
+ | "bm"
+ | "bn"
+ | "bo"
+ | "br"
+ | "bs"
+ | "ca"
+ | "ce"
+ | "ch"
+ | "co"
+ | "cr"
+ | "cs"
+ | "cu"
+ | "cv"
+ | "cy"
+ | "da"
+ | "de"
+ | "dv"
+ | "dz"
+ | "ee"
+ | "el"
+ | "en"
+ | "eo"
+ | "es"
+ | "et"
+ | "eu"
+ | "fa"
+ | "ff"
+ | "fi"
+ | "fj"
+ | "fo"
+ | "fr"
+ | "fy"
+ | "ga"
+ | "gd"
+ | "gl"
+ | "gn"
+ | "gu"
+ | "gv"
+ | "ha"
+ | "he"
+ | "hi"
+ | "ho"
+ | "hr"
+ | "ht"
+ | "hu"
+ | "hy"
+ | "hz"
+ | "ia"
+ | "id"
+ | "ie"
+ | "ig"
+ | "ii"
+ | "ik"
+ | "io"
+ | "is"
+ | "it"
+ | "iu"
+ | "ja"
+ | "jv"
+ | "ka"
+ | "kg"
+ | "ki"
+ | "kj"
+ | "kk"
+ | "kl"
+ | "km"
+ | "kn"
+ | "ko"
+ | "kr"
+ | "ks"
+ | "ku"
+ | "kv"
+ | "kw"
+ | "ky"
+ | "la"
+ | "lb"
+ | "lg"
+ | "li"
+ | "ln"
+ | "lo"
+ | "lt"
+ | "lu"
+ | "lv"
+ | "mg"
+ | "mh"
+ | "mi"
+ | "mk"
+ | "ml"
+ | "mn"
+ | "mr"
+ | "ms"
+ | "mt"
+ | "my"
+ | "na"
+ | "nb"
+ | "nd"
+ | "ne"
+ | "ng"
+ | "nl"
+ | "nn"
+ | "no"
+ | "nr"
+ | "nv"
+ | "ny"
+ | "oc"
+ | "oj"
+ | "om"
+ | "or"
+ | "os"
+ | "pa"
+ | "pi"
+ | "pl"
+ | "ps"
+ | "pt"
+ | "qu"
+ | "rm"
+ | "rn"
+ | "ro"
+ | "ru"
+ | "rw"
+ | "sa"
+ | "sc"
+ | "sd"
+ | "se"
+ | "sg"
+ | "si"
+ | "sk"
+ | "sl"
+ | "sm"
+ | "sn"
+ | "so"
+ | "sq"
+ | "sr"
+ | "ss"
+ | "st"
+ | "su"
+ | "sv"
+ | "sw"
+ | "ta"
+ | "te"
+ | "tg"
+ | "th"
+ | "ti"
+ | "tk"
+ | "tl"
+ | "tn"
+ | "to"
+ | "tr"
+ | "ts"
+ | "tt"
+ | "tw"
+ | "ty"
+ | "ug"
+ | "uk"
+ | "ur"
+ | "uz"
+ | "ve"
+ | "vi"
+ | "vo"
+ | "wa"
+ | "wo"
+ | "xh"
+ | "yi"
+ | "yo"
+ | "za"
+ | "zh"
+ | "zu";
+export type CaptionLanguageOption = {
+ id: LangCode;
+ name: string;
+ englishName: string;
+ nativeName: string;
+};
+// https://github.com/emvi/iso-639-1/blob/master/list.go
+// MIT License
+//
+// Copyright (c) 2019 Emvi
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+export const captionLanguages: CaptionLanguageOption[] = [
+ {
+ id: "none",
+ englishName: "None",
+ name: "None",
+ nativeName: "No caption language selected",
+ },
+ {
+ id: "aa",
+ englishName: "Afar",
+ name: "Afar - Afaraf",
+ nativeName: "Afaraf",
+ },
+ {
+ id: "ab",
+ englishName: "Abkhaz",
+ name: "Abkhaz - Аҧсуа бызшәа",
+ nativeName: "Аҧсуа бызшәа",
+ },
+ {
+ id: "ae",
+ englishName: "Avestan",
+ name: "Avestan - Avesta",
+ nativeName: "Avesta",
+ },
+ {
+ id: "af",
+ englishName: "Afrikaans",
+ name: "Afrikaans - Afrikaans",
+ nativeName: "Afrikaans",
+ },
+ {
+ id: "ak",
+ englishName: "Akan",
+ name: "Akan - Akan",
+ nativeName: "Akan",
+ },
+ {
+ id: "am",
+ englishName: "Amharic",
+ name: "Amharic - አማርኛ",
+ nativeName: "አማርኛ",
+ },
+ {
+ id: "an",
+ englishName: "Aragonese",
+ name: "Aragonese - Aragonés",
+ nativeName: "Aragonés",
+ },
+ {
+ id: "ar",
+ englishName: "Arabic",
+ name: "Arabic - اللغة العربية",
+ nativeName: "اللغة العربية",
+ },
+ {
+ id: "as",
+ englishName: "Assamese",
+ name: "Assamese - অসমীয়া",
+ nativeName: "অসমীয়া",
+ },
+ {
+ id: "av",
+ englishName: "Avaric",
+ name: "Avaric - Авар мацӀ",
+ nativeName: "Авар мацӀ",
+ },
+ {
+ id: "ay",
+ englishName: "Aymara",
+ name: "Aymara - Aymar aru",
+ nativeName: "Aymar aru",
+ },
+ {
+ id: "az",
+ englishName: "Azerbaijani",
+ name: "Azerbaijani - Azərbaycan dili",
+ nativeName: "Azərbaycan dili",
+ },
+ {
+ id: "ba",
+ englishName: "Bashkir",
+ name: "Bashkir - Башҡорт теле",
+ nativeName: "Башҡорт теле",
+ },
+ {
+ id: "be",
+ englishName: "Belarusian",
+ name: "Belarusian - Беларуская мова",
+ nativeName: "Беларуская мова",
+ },
+ {
+ id: "bg",
+ englishName: "Bulgarian",
+ name: "Bulgarian - Български език",
+ nativeName: "Български език",
+ },
+ {
+ id: "bh",
+ englishName: "Bihari",
+ name: "Bihari - भोजपुरी",
+ nativeName: "भोजपुरी",
+ },
+ {
+ id: "bi",
+ englishName: "Bislama",
+ name: "Bislama - Bislama",
+ nativeName: "Bislama",
+ },
+ {
+ id: "bm",
+ englishName: "Bambara",
+ name: "Bambara - Bamanankan",
+ nativeName: "Bamanankan",
+ },
+ {
+ id: "bn",
+ englishName: "Bengali",
+ name: "Bengali - বাংলা",
+ nativeName: "বাংলা",
+ },
+ {
+ id: "bo",
+ englishName: "Tibetan Standard",
+ name: "Tibetan Standard - བོད་ཡིག",
+ nativeName: "བོད་ཡིག",
+ },
+ {
+ id: "br",
+ englishName: "Breton",
+ name: "Breton - Brezhoneg",
+ nativeName: "Brezhoneg",
+ },
+ {
+ id: "bs",
+ englishName: "Bosnian",
+ name: "Bosnian - Bosanski jezik",
+ nativeName: "Bosanski jezik",
+ },
+ {
+ id: "ca",
+ englishName: "Catalan",
+ name: "Catalan - Català",
+ nativeName: "Català",
+ },
+ {
+ id: "ce",
+ englishName: "Chechen",
+ name: "Chechen - Нохчийн мотт",
+ nativeName: "Нохчийн мотт",
+ },
+ {
+ id: "ch",
+ englishName: "Chamorro",
+ name: "Chamorro - Chamoru",
+ nativeName: "Chamoru",
+ },
+ {
+ id: "co",
+ englishName: "Corsican",
+ name: "Corsican - Corsu",
+ nativeName: "Corsu",
+ },
+ {
+ id: "cr",
+ englishName: "Cree",
+ name: "Cree - ᓀᐦᐃᔭᐍᐏᐣ",
+ nativeName: "ᓀᐦᐃᔭᐍᐏᐣ",
+ },
+ {
+ id: "cs",
+ englishName: "Czech",
+ name: "Czech - Čeština",
+ nativeName: "Čeština",
+ },
+ {
+ id: "cu",
+ englishName: "Old Church Slavonic",
+ name: "Old Church Slavonic - Ѩзыкъ словѣньскъ",
+ nativeName: "Ѩзыкъ словѣньскъ",
+ },
+ {
+ id: "cv",
+ englishName: "Chuvash",
+ name: "Chuvash - Чӑваш чӗлхи",
+ nativeName: "Чӑваш чӗлхи",
+ },
+ {
+ id: "cy",
+ englishName: "Welsh",
+ name: "Welsh - Cymraeg",
+ nativeName: "Cymraeg",
+ },
+ {
+ id: "da",
+ englishName: "Danish",
+ name: "Danish - Dansk",
+ nativeName: "Dansk",
+ },
+ {
+ id: "de",
+ englishName: "German",
+ name: "German - Deutsch",
+ nativeName: "Deutsch",
+ },
+ {
+ id: "dv",
+ englishName: "Divehi",
+ name: "Divehi - Dhivehi",
+ nativeName: "Dhivehi",
+ },
+ {
+ id: "dz",
+ englishName: "Dzongkha",
+ name: "Dzongkha - རྫོང་ཁ",
+ nativeName: "རྫོང་ཁ",
+ },
+ {
+ id: "ee",
+ englishName: "Ewe",
+ name: "Ewe - Eʋegbe",
+ nativeName: "Eʋegbe",
+ },
+ {
+ id: "el",
+ englishName: "Greek",
+ name: "Greek - Ελληνικά",
+ nativeName: "Ελληνικά",
+ },
+ {
+ id: "en",
+ englishName: "English",
+ name: "English - English",
+ nativeName: "English",
+ },
+ {
+ id: "eo",
+ englishName: "Esperanto",
+ name: "Esperanto - Esperanto",
+ nativeName: "Esperanto",
+ },
+ {
+ id: "es",
+ englishName: "Spanish",
+ name: "Spanish - Español",
+ nativeName: "Español",
+ },
+ {
+ id: "et",
+ englishName: "Estonian",
+ name: "Estonian - Eesti",
+ nativeName: "Eesti",
+ },
+ {
+ id: "eu",
+ englishName: "Basque",
+ name: "Basque - Euskara",
+ nativeName: "Euskara",
+ },
+ {
+ id: "fa",
+ englishName: "Persian",
+ name: "Persian - فارسی",
+ nativeName: "فارسی",
+ },
+ {
+ id: "ff",
+ englishName: "Fula",
+ name: "Fula - Fulfulde",
+ nativeName: "Fulfulde",
+ },
+ {
+ id: "fi",
+ englishName: "Finnish",
+ name: "Finnish - Suomi",
+ nativeName: "Suomi",
+ },
+ {
+ id: "fj",
+ englishName: "Fijian",
+ name: "Fijian - Vakaviti",
+ nativeName: "Vakaviti",
+ },
+ {
+ id: "fo",
+ englishName: "Faroese",
+ name: "Faroese - Føroyskt",
+ nativeName: "Føroyskt",
+ },
+ {
+ id: "fr",
+ englishName: "French",
+ name: "French - Français",
+ nativeName: "Français",
+ },
+ {
+ id: "fy",
+ englishName: "Western Frisian",
+ name: "Western Frisian - Frysk",
+ nativeName: "Frysk",
+ },
+ {
+ id: "ga",
+ englishName: "Irish",
+ name: "Irish - Gaeilge",
+ nativeName: "Gaeilge",
+ },
+ {
+ id: "gd",
+ englishName: "Scottish Gaelic",
+ name: "Scottish Gaelic - Gàidhlig",
+ nativeName: "Gàidhlig",
+ },
+ {
+ id: "gl",
+ englishName: "Galician",
+ name: "Galician - Galego",
+ nativeName: "Galego",
+ },
+ {
+ id: "gn",
+ englishName: "Guaraní",
+ name: "Guaraní - Avañeẽ",
+ nativeName: "Avañeẽ",
+ },
+ {
+ id: "gu",
+ englishName: "Gujarati",
+ name: "Gujarati - ગુજરાતી",
+ nativeName: "ગુજરાતી",
+ },
+ {
+ id: "gv",
+ englishName: "Manx",
+ name: "Manx - Gaelg",
+ nativeName: "Gaelg",
+ },
+ {
+ id: "ha",
+ englishName: "Hausa",
+ name: "Hausa - هَوُسَ",
+ nativeName: "هَوُسَ",
+ },
+ {
+ id: "he",
+ englishName: "Hebrew",
+ name: "Hebrew - עברית",
+ nativeName: "עברית",
+ },
+ {
+ id: "hi",
+ englishName: "Hindi",
+ name: "Hindi - हिन्दी",
+ nativeName: "हिन्दी",
+ },
+ {
+ id: "ho",
+ englishName: "Hiri Motu",
+ name: "Hiri Motu - Hiri Motu",
+ nativeName: "Hiri Motu",
+ },
+ {
+ id: "hr",
+ englishName: "Croatian",
+ name: "Croatian - Hrvatski jezik",
+ nativeName: "Hrvatski jezik",
+ },
+ {
+ id: "ht",
+ englishName: "Haitian",
+ name: "Haitian - Kreyòl ayisyen",
+ nativeName: "Kreyòl ayisyen",
+ },
+ {
+ id: "hu",
+ englishName: "Hungarian",
+ name: "Hungarian - Magyar",
+ nativeName: "Magyar",
+ },
+ {
+ id: "hy",
+ englishName: "Armenian",
+ name: "Armenian - Հայերեն",
+ nativeName: "Հայերեն",
+ },
+ {
+ id: "hz",
+ englishName: "Herero",
+ name: "Herero - Otjiherero",
+ nativeName: "Otjiherero",
+ },
+ {
+ id: "ia",
+ englishName: "Interlingua",
+ name: "Interlingua - Interlingua",
+ nativeName: "Interlingua",
+ },
+ {
+ id: "id",
+ englishName: "Indonesian",
+ name: "Indonesian - Indonesian",
+ nativeName: "Indonesian",
+ },
+ {
+ id: "ie",
+ englishName: "Interlingue",
+ name: "Interlingue - Interlingue",
+ nativeName: "Interlingue",
+ },
+ {
+ id: "ig",
+ englishName: "Igbo",
+ name: "Igbo - Asụsụ Igbo",
+ nativeName: "Asụsụ Igbo",
+ },
+ {
+ id: "ii",
+ englishName: "Nuosu",
+ name: "Nuosu - ꆈꌠ꒿ Nuosuhxop",
+ nativeName: "ꆈꌠ꒿ Nuosuhxop",
+ },
+ {
+ id: "ik",
+ englishName: "Inupiaq",
+ name: "Inupiaq - Iñupiaq",
+ nativeName: "Iñupiaq",
+ },
+ {
+ id: "io",
+ englishName: "Ido",
+ name: "Ido - Ido",
+ nativeName: "Ido",
+ },
+ {
+ id: "is",
+ englishName: "Icelandic",
+ name: "Icelandic - Íslenska",
+ nativeName: "Íslenska",
+ },
+ {
+ id: "it",
+ englishName: "Italian",
+ name: "Italian - Italiano",
+ nativeName: "Italiano",
+ },
+ {
+ id: "iu",
+ englishName: "Inuktitut",
+ name: "Inuktitut - ᐃᓄᒃᑎᑐᑦ",
+ nativeName: "ᐃᓄᒃᑎᑐᑦ",
+ },
+ {
+ id: "ja",
+ englishName: "Japanese",
+ name: "Japanese - 日本語",
+ nativeName: "日本語",
+ },
+ {
+ id: "jv",
+ englishName: "Javanese",
+ name: "Javanese - Basa Jawa",
+ nativeName: "Basa Jawa",
+ },
+ {
+ id: "ka",
+ englishName: "Georgian",
+ name: "Georgian - Ქართული",
+ nativeName: "Ქართული",
+ },
+ {
+ id: "kg",
+ englishName: "Kongo",
+ name: "Kongo - Kikongo",
+ nativeName: "Kikongo",
+ },
+ {
+ id: "ki",
+ englishName: "Kikuyu",
+ name: "Kikuyu - Gĩkũyũ",
+ nativeName: "Gĩkũyũ",
+ },
+ {
+ id: "kj",
+ englishName: "Kwanyama",
+ name: "Kwanyama - Kuanyama",
+ nativeName: "Kuanyama",
+ },
+ {
+ id: "kk",
+ englishName: "Kazakh",
+ name: "Kazakh - Қазақ тілі",
+ nativeName: "Қазақ тілі",
+ },
+ {
+ id: "kl",
+ englishName: "Kalaallisut",
+ name: "Kalaallisut - Kalaallisut",
+ nativeName: "Kalaallisut",
+ },
+ {
+ id: "km",
+ englishName: "Khmer",
+ name: "Khmer - ខេមរភាសា",
+ nativeName: "ខេមរភាសា",
+ },
+ {
+ id: "kn",
+ englishName: "Kannada",
+ name: "Kannada - ಕನ್ನಡ",
+ nativeName: "ಕನ್ನಡ",
+ },
+ {
+ id: "ko",
+ englishName: "Korean",
+ name: "Korean - 한국어",
+ nativeName: "한국어",
+ },
+ {
+ id: "kr",
+ englishName: "Kanuri",
+ name: "Kanuri - Kanuri",
+ nativeName: "Kanuri",
+ },
+ {
+ id: "ks",
+ englishName: "Kashmiri",
+ name: "Kashmiri - कश्मीरी",
+ nativeName: "कश्मीरी",
+ },
+ {
+ id: "ku",
+ englishName: "Kurdish",
+ name: "Kurdish - Kurdî",
+ nativeName: "Kurdî",
+ },
+ {
+ id: "kv",
+ englishName: "Komi",
+ name: "Komi - Коми кыв",
+ nativeName: "Коми кыв",
+ },
+ {
+ id: "kw",
+ englishName: "Cornish",
+ name: "Cornish - Kernewek",
+ nativeName: "Kernewek",
+ },
+ {
+ id: "ky",
+ englishName: "Kyrgyz",
+ name: "Kyrgyz - Кыргызча",
+ nativeName: "Кыргызча",
+ },
+ {
+ id: "la",
+ englishName: "Latin",
+ name: "Latin - Latine",
+ nativeName: "Latine",
+ },
+ {
+ id: "lb",
+ englishName: "Luxembourgish",
+ name: "Luxembourgish - Lëtzebuergesch",
+ nativeName: "Lëtzebuergesch",
+ },
+ {
+ id: "lg",
+ englishName: "Ganda",
+ name: "Ganda - Luganda",
+ nativeName: "Luganda",
+ },
+ {
+ id: "li",
+ englishName: "Limburgish",
+ name: "Limburgish - Limburgs",
+ nativeName: "Limburgs",
+ },
+ {
+ id: "ln",
+ englishName: "Lingala",
+ name: "Lingala - Lingála",
+ nativeName: "Lingála",
+ },
+ {
+ id: "lo",
+ englishName: "Lao",
+ name: "Lao - ພາສາ",
+ nativeName: "ພາສາ",
+ },
+ {
+ id: "lt",
+ englishName: "Lithuanian",
+ name: "Lithuanian - Lietuvių kalba",
+ nativeName: "Lietuvių kalba",
+ },
+ {
+ id: "lu",
+ englishName: "Luba-Katanga",
+ name: "Luba-Katanga - Tshiluba",
+ nativeName: "Tshiluba",
+ },
+ {
+ id: "lv",
+ englishName: "Latvian",
+ name: "Latvian - Latviešu valoda",
+ nativeName: "Latviešu valoda",
+ },
+ {
+ id: "mg",
+ englishName: "Malagasy",
+ name: "Malagasy - Fiteny malagasy",
+ nativeName: "Fiteny malagasy",
+ },
+ {
+ id: "mh",
+ englishName: "Marshallese",
+ name: "Marshallese - Kajin M̧ajeļ",
+ nativeName: "Kajin M̧ajeļ",
+ },
+ {
+ id: "mi",
+ englishName: "Māori",
+ name: "Māori - Te reo Māori",
+ nativeName: "Te reo Māori",
+ },
+ {
+ id: "mk",
+ englishName: "Macedonian",
+ name: "Macedonian - Македонски јазик",
+ nativeName: "Македонски јазик",
+ },
+ {
+ id: "ml",
+ englishName: "Malayalam",
+ name: "Malayalam - മലയാളം",
+ nativeName: "മലയാളം",
+ },
+ {
+ id: "mn",
+ englishName: "Mongolian",
+ name: "Mongolian - Монгол хэл",
+ nativeName: "Монгол хэл",
+ },
+ {
+ id: "mr",
+ englishName: "Marathi",
+ name: "Marathi - मराठी",
+ nativeName: "मराठी",
+ },
+ {
+ id: "ms",
+ englishName: "Malay",
+ name: "Malay - هاس ملايو",
+ nativeName: "هاس ملايو",
+ },
+ {
+ id: "mt",
+ englishName: "Maltese",
+ name: "Maltese - Malti",
+ nativeName: "Malti",
+ },
+ {
+ id: "my",
+ englishName: "Burmese",
+ name: "Burmese - ဗမာစာ",
+ nativeName: "ဗမာစာ",
+ },
+ {
+ id: "na",
+ englishName: "Nauru",
+ name: "Nauru - Ekakairũ Naoero",
+ nativeName: "Ekakairũ Naoero",
+ },
+ {
+ id: "nb",
+ englishName: "Norwegian Bokmål",
+ name: "Norwegian Bokmål - Norsk bokmål",
+ nativeName: "Norsk bokmål",
+ },
+ {
+ id: "nd",
+ englishName: "Northern Ndebele",
+ name: "Northern Ndebele - IsiNdebele",
+ nativeName: "IsiNdebele",
+ },
+ {
+ id: "ne",
+ englishName: "Nepali",
+ name: "Nepali - नेपाली",
+ nativeName: "नेपाली",
+ },
+ {
+ id: "ng",
+ englishName: "Ndonga",
+ name: "Ndonga - Owambo",
+ nativeName: "Owambo",
+ },
+ {
+ id: "nl",
+ englishName: "Dutch",
+ name: "Dutch - Nederlands",
+ nativeName: "Nederlands",
+ },
+ {
+ id: "nn",
+ englishName: "Norwegian Nynorsk",
+ name: "Norwegian Nynorsk - Norsk nynorsk",
+ nativeName: "Norsk nynorsk",
+ },
+ {
+ id: "no",
+ englishName: "Norwegian",
+ name: "Norwegian - Norsk",
+ nativeName: "Norsk",
+ },
+ {
+ id: "nr",
+ englishName: "Southern Ndebele",
+ name: "Southern Ndebele - IsiNdebele",
+ nativeName: "IsiNdebele",
+ },
+ {
+ id: "nv",
+ englishName: "Navajo",
+ name: "Navajo - Diné bizaad",
+ nativeName: "Diné bizaad",
+ },
+ {
+ id: "ny",
+ englishName: "Chichewa",
+ name: "Chichewa - ChiCheŵa",
+ nativeName: "ChiCheŵa",
+ },
+ {
+ id: "oc",
+ englishName: "Occitan",
+ name: "Occitan - Occitan",
+ nativeName: "Occitan",
+ },
+ {
+ id: "oj",
+ englishName: "Ojibwe",
+ name: "Ojibwe - ᐊᓂᔑᓈᐯᒧᐎᓐ",
+ nativeName: "ᐊᓂᔑᓈᐯᒧᐎᓐ",
+ },
+ {
+ id: "om",
+ englishName: "Oromo",
+ name: "Oromo - Afaan Oromoo",
+ nativeName: "Afaan Oromoo",
+ },
+ {
+ id: "or",
+ englishName: "Oriya",
+ name: "Oriya - ଓଡ଼ିଆ",
+ nativeName: "ଓଡ଼ିଆ",
+ },
+ {
+ id: "os",
+ englishName: "Ossetian",
+ name: "Ossetian - Ирон æвзаг",
+ nativeName: "Ирон æвзаг",
+ },
+ {
+ id: "pa",
+ englishName: "Panjabi",
+ name: "Panjabi - ਪੰਜਾਬੀ",
+ nativeName: "ਪੰਜਾਬੀ",
+ },
+ {
+ id: "pi",
+ englishName: "Pāli",
+ name: "Pāli - पाऴि",
+ nativeName: "पाऴि",
+ },
+ {
+ id: "pl",
+ englishName: "Polish",
+ name: "Polish - Język polski",
+ nativeName: "Język polski",
+ },
+ {
+ id: "ps",
+ englishName: "Pashto",
+ name: "Pashto - پښتو",
+ nativeName: "پښتو",
+ },
+ {
+ id: "pt",
+ englishName: "Portuguese",
+ name: "Portuguese - Português",
+ nativeName: "Português",
+ },
+ {
+ id: "qu",
+ englishName: "Quechua",
+ name: "Quechua - Runa Simi",
+ nativeName: "Runa Simi",
+ },
+ {
+ id: "rm",
+ englishName: "Romansh",
+ name: "Romansh - Rumantsch grischun",
+ nativeName: "Rumantsch grischun",
+ },
+ {
+ id: "rn",
+ englishName: "Kirundi",
+ name: "Kirundi - Ikirundi",
+ nativeName: "Ikirundi",
+ },
+ {
+ id: "ro",
+ englishName: "Romanian",
+ name: "Romanian - Română",
+ nativeName: "Română",
+ },
+ {
+ id: "ru",
+ englishName: "Russian",
+ name: "Russian - Русский",
+ nativeName: "Русский",
+ },
+ {
+ id: "rw",
+ englishName: "Kinyarwanda",
+ name: "Kinyarwanda - Ikinyarwanda",
+ nativeName: "Ikinyarwanda",
+ },
+ {
+ id: "sa",
+ englishName: "Sanskrit",
+ name: "Sanskrit - संस्कृतम्",
+ nativeName: "संस्कृतम्",
+ },
+ {
+ id: "sc",
+ englishName: "Sardinian",
+ name: "Sardinian - Sardu",
+ nativeName: "Sardu",
+ },
+ {
+ id: "sd",
+ englishName: "Sindhi",
+ name: "Sindhi - सिन्धी",
+ nativeName: "सिन्धी",
+ },
+ {
+ id: "se",
+ englishName: "Northern Sami",
+ name: "Northern Sami - Davvisámegiella",
+ nativeName: "Davvisámegiella",
+ },
+ {
+ id: "sg",
+ englishName: "Sango",
+ name: "Sango - Yângâ tî sängö",
+ nativeName: "Yângâ tî sängö",
+ },
+ {
+ id: "si",
+ englishName: "Sinhala",
+ name: "Sinhala - සිංහල",
+ nativeName: "සිංහල",
+ },
+ {
+ id: "sk",
+ englishName: "Slovak",
+ name: "Slovak - Slovenčina",
+ nativeName: "Slovenčina",
+ },
+ {
+ id: "sl",
+ englishName: "Slovene",
+ name: "Slovene - Slovenski jezik",
+ nativeName: "Slovenski jezik",
+ },
+ {
+ id: "sm",
+ englishName: "Samoan",
+ name: "Samoan - Gagana faa Samoa",
+ nativeName: "Gagana faa Samoa",
+ },
+ {
+ id: "sn",
+ englishName: "Shona",
+ name: "Shona - ChiShona",
+ nativeName: "ChiShona",
+ },
+ {
+ id: "so",
+ englishName: "Somali",
+ name: "Somali - Soomaaliga",
+ nativeName: "Soomaaliga",
+ },
+ {
+ id: "sq",
+ englishName: "Albanian",
+ name: "Albanian - Shqip",
+ nativeName: "Shqip",
+ },
+ {
+ id: "sr",
+ englishName: "Serbian",
+ name: "Serbian - Српски језик",
+ nativeName: "Српски језик",
+ },
+ {
+ id: "ss",
+ englishName: "Swati",
+ name: "Swati - SiSwati",
+ nativeName: "SiSwati",
+ },
+ {
+ id: "st",
+ englishName: "Southern Sotho",
+ name: "Southern Sotho - Sesotho",
+ nativeName: "Sesotho",
+ },
+ {
+ id: "su",
+ englishName: "Sundanese",
+ name: "Sundanese - Basa Sunda",
+ nativeName: "Basa Sunda",
+ },
+ {
+ id: "sv",
+ englishName: "Swedish",
+ name: "Swedish - Svenska",
+ nativeName: "Svenska",
+ },
+ {
+ id: "sw",
+ englishName: "Swahili",
+ name: "Swahili - Kiswahili",
+ nativeName: "Kiswahili",
+ },
+ {
+ id: "ta",
+ englishName: "Tamil",
+ name: "Tamil - தமிழ்",
+ nativeName: "தமிழ்",
+ },
+ {
+ id: "te",
+ englishName: "Telugu",
+ name: "Telugu - తెలుగు",
+ nativeName: "తెలుగు",
+ },
+ {
+ id: "tg",
+ englishName: "Tajik",
+ name: "Tajik - Тоҷикӣ",
+ nativeName: "Тоҷикӣ",
+ },
+ {
+ id: "th",
+ englishName: "Thai",
+ name: "Thai - ไทย",
+ nativeName: "ไทย",
+ },
+ {
+ id: "ti",
+ englishName: "Tigrinya",
+ name: "Tigrinya - ትግርኛ",
+ nativeName: "ትግርኛ",
+ },
+ {
+ id: "tk",
+ englishName: "Turkmen",
+ name: "Turkmen - Türkmen",
+ nativeName: "Türkmen",
+ },
+ {
+ id: "tl",
+ englishName: "Tagalog",
+ name: "Tagalog - Wikang Tagalog",
+ nativeName: "Wikang Tagalog",
+ },
+ {
+ id: "tn",
+ englishName: "Tswana",
+ name: "Tswana - Setswana",
+ nativeName: "Setswana",
+ },
+ {
+ id: "to",
+ englishName: "Tonga",
+ name: "Tonga - Faka Tonga",
+ nativeName: "Faka Tonga",
+ },
+ {
+ id: "tr",
+ englishName: "Turkish",
+ name: "Turkish - Türkçe",
+ nativeName: "Türkçe",
+ },
+ {
+ id: "ts",
+ englishName: "Tsonga",
+ name: "Tsonga - Xitsonga",
+ nativeName: "Xitsonga",
+ },
+ {
+ id: "tt",
+ englishName: "Tatar",
+ name: "Tatar - Татар теле",
+ nativeName: "Татар теле",
+ },
+ {
+ id: "tw",
+ englishName: "Twi",
+ name: "Twi - Twi",
+ nativeName: "Twi",
+ },
+ {
+ id: "ty",
+ englishName: "Tahitian",
+ name: "Tahitian - Reo Tahiti",
+ nativeName: "Reo Tahiti",
+ },
+ {
+ id: "ug",
+ englishName: "Uyghur",
+ name: "Uyghur - ئۇيغۇرچە",
+ nativeName: "ئۇيغۇرچە",
+ },
+ {
+ id: "uk",
+ englishName: "Ukrainian",
+ name: "Ukrainian - Українська",
+ nativeName: "Українська",
+ },
+ {
+ id: "ur",
+ englishName: "Urdu",
+ name: "Urdu - اردو",
+ nativeName: "اردو",
+ },
+ {
+ id: "uz",
+ englishName: "Uzbek",
+ name: "Uzbek - Ўзбек",
+ nativeName: "Ўзбек",
+ },
+ {
+ id: "ve",
+ englishName: "Venda",
+ name: "Venda - Tshivenḓa",
+ nativeName: "Tshivenḓa",
+ },
+ {
+ id: "vi",
+ englishName: "Vietnamese",
+ name: "Vietnamese - Tiếng Việt",
+ nativeName: "Tiếng Việt",
+ },
+ {
+ id: "vo",
+ englishName: "Volapük",
+ name: "Volapük - Volapük",
+ nativeName: "Volapük",
+ },
+ {
+ id: "wa",
+ englishName: "Walloon",
+ name: "Walloon - Walon",
+ nativeName: "Walon",
+ },
+ {
+ id: "wo",
+ englishName: "Wolof",
+ name: "Wolof - Wollof",
+ nativeName: "Wollof",
+ },
+ {
+ id: "xh",
+ englishName: "Xhosa",
+ name: "Xhosa - IsiXhosa",
+ nativeName: "IsiXhosa",
+ },
+ {
+ id: "yi",
+ englishName: "Yiddish",
+ name: "Yiddish - ייִדיש",
+ nativeName: "ייִדיש",
+ },
+ {
+ id: "yo",
+ englishName: "Yoruba",
+ name: "Yoruba - Yorùbá",
+ nativeName: "Yorùbá",
+ },
+ {
+ id: "za",
+ englishName: "Zhuang",
+ name: "Zhuang - Saɯ cueŋƅ",
+ nativeName: "Saɯ cueŋƅ",
+ },
+ {
+ id: "zh",
+ englishName: "Chinese",
+ name: "Chinese - 中文",
+ nativeName: "中文",
+ },
+ {
+ id: "zu",
+ englishName: "Zulu",
+ name: "Zulu - IsiZulu",
+ nativeName: "IsiZulu",
+ },
+];
diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json
index ad479bb4..f0cf677c 100644
--- a/src/setup/locales/en/translation.json
+++ b/src/setup/locales/en/translation.json
@@ -57,6 +57,8 @@
"backToHome": "Back to home",
"backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}",
+ "timeLeft": "{{timeLeft}} left",
+ "finishAt": "Finish at {{timeFinished}}",
"buttons": {
"episodes": "Episodes",
"source": "Source",
@@ -104,6 +106,11 @@
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server0> or on <1>GitHub1>."
}
},
+ "settings": {
+ "title": "Settings",
+ "language":"Language",
+ "captionLanguage": "Caption Language"
+ },
"v3": {
"newSiteTitle": "New version now released!",
"newDomain": "https://movie-web.app",
diff --git a/src/setup/locales/fr/translation.json b/src/setup/locales/fr/translation.json
index e5c669ce..fe9d73eb 100644
--- a/src/setup/locales/fr/translation.json
+++ b/src/setup/locales/fr/translation.json
@@ -39,13 +39,16 @@
"backToHome": "Retour à la page d'accueil",
"backToHomeShort": "Retour",
"seasonAndEpisode": "S{{season}} E{{episode}}",
+ "timeLeft": "{{timeLeft}} restant",
+ "finishAt": "Terminer à {{timeFinished}}",
"buttons": {
"episodes": "Épisodes",
"source": "Source",
"captions": "Sous-titres",
"download": "Télécharger",
"settings": "Paramètres",
- "pictureInPicture": "Image dans l'image"
+ "pictureInPicture": "Image dans l'image",
+ "playbackSpeed": "Vitesse"
},
"popouts": {
"sources": "Sources",
diff --git a/src/state/settings/context.tsx b/src/state/settings/context.tsx
index 030833cc..8efba361 100644
--- a/src/state/settings/context.tsx
+++ b/src/state/settings/context.tsx
@@ -1,14 +1,16 @@
import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react";
+import { LangCode } from "@/setup/iso6391";
import { SettingsStore } from "./store";
import { MWSettingsData } from "./types";
interface MWSettingsDataSetters {
- setLanguage(language: string): void;
+ setLanguage(language: LangCode): void;
+ setCaptionLanguage(language: LangCode): void;
setCaptionDelay(delay: number): void;
setCaptionColor(color: string): void;
setCaptionFontSize(size: number): void;
- setCaptionBackgroundColor(backgroundColor: string): void;
+ setCaptionBackgroundColor(backgroundColor: number): void;
}
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
const SettingsContext = createContext(null as any);
@@ -17,7 +19,6 @@ export function SettingsProvider(props: { children: ReactNode }) {
return Math.max(min, Math.min(value, max));
}
const [settings, setSettings] = useStore(SettingsStore);
-
const context: MWSettingsDataWrapper = useMemo(() => {
const settingsContext: MWSettingsDataWrapper = {
...settings,
@@ -29,6 +30,14 @@ export function SettingsProvider(props: { children: ReactNode }) {
};
});
},
+ setCaptionLanguage(language) {
+ setSettings((oldSettings) => {
+ const captionSettings = oldSettings.captionSettings;
+ captionSettings.language = language;
+ const newSettings = oldSettings;
+ return newSettings;
+ });
+ },
setCaptionDelay(delay: number) {
setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings;
@@ -56,7 +65,10 @@ export function SettingsProvider(props: { children: ReactNode }) {
setCaptionBackgroundColor(backgroundColor) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
- style.backgroundColor = backgroundColor;
+ style.backgroundColor = `${style.backgroundColor.substring(
+ 0,
+ 7
+ )}${backgroundColor.toString(16).padStart(2, "0")}`;
const newSettings = oldSettings;
return newSettings;
});
diff --git a/src/state/settings/store.ts b/src/state/settings/store.ts
index c854dc04..55ede71e 100644
--- a/src/state/settings/store.ts
+++ b/src/state/settings/store.ts
@@ -1,11 +1,11 @@
import { createVersionedStore } from "@/utils/storage";
-import { MWSettingsData } from "./types";
+import { MWSettingsData, MWSettingsDataV1 } from "./types";
export const SettingsStore = createVersionedStore()
.setKey("mw-settings")
.addVersion({
version: 0,
- create(): MWSettingsData {
+ create(): MWSettingsDataV1 {
return {
language: "en",
captionSettings: {
@@ -18,5 +18,31 @@ export const SettingsStore = createVersionedStore()
},
};
},
+ migrate(data: MWSettingsDataV1): MWSettingsData {
+ return {
+ language: data.language,
+ captionSettings: {
+ language: "none",
+ ...data.captionSettings,
+ },
+ };
+ },
+ })
+ .addVersion({
+ version: 1,
+ create(): MWSettingsData {
+ return {
+ language: "en",
+ captionSettings: {
+ delay: 0,
+ language: "none",
+ style: {
+ color: "#ffffff",
+ fontSize: 25,
+ backgroundColor: "#00000096",
+ },
+ },
+ };
+ },
})
.build();
diff --git a/src/state/settings/types.ts b/src/state/settings/types.ts
index b793308d..c894be9c 100644
--- a/src/state/settings/types.ts
+++ b/src/state/settings/types.ts
@@ -1,3 +1,5 @@
+import { LangCode } from "@/setup/iso6391";
+
export interface CaptionStyleSettings {
color: string;
/**
@@ -7,7 +9,7 @@ export interface CaptionStyleSettings {
backgroundColor: string;
}
-export interface CaptionSettings {
+export interface CaptionSettingsV1 {
/**
* Range is [-10, 10]s
*/
@@ -15,7 +17,20 @@ export interface CaptionSettings {
style: CaptionStyleSettings;
}
+export interface CaptionSettings {
+ language: LangCode;
+ /**
+ * Range is [-10, 10]s
+ */
+ delay: number;
+ style: CaptionStyleSettings;
+}
+export interface MWSettingsDataV1 {
+ language: LangCode;
+ captionSettings: CaptionSettingsV1;
+}
+
export interface MWSettingsData {
- language: string;
+ language: LangCode;
captionSettings: CaptionSettings;
}
diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx
index 22d96502..8e5888a1 100644
--- a/src/video/components/VideoPlayer.tsx
+++ b/src/video/components/VideoPlayer.tsx
@@ -31,6 +31,7 @@ import { PictureInPictureAction } from "@/video/components/actions/PictureInPict
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction";
+import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
type Props = VideoPlayerBaseProps;
@@ -91,6 +92,7 @@ export function VideoPlayer(props: Props) {
<>
+
diff --git a/src/video/components/actions/BackdropAction.tsx b/src/video/components/actions/BackdropAction.tsx
index d7e75605..e0a1f9eb 100644
--- a/src/video/components/actions/BackdropAction.tsx
+++ b/src/video/components/actions/BackdropAction.tsx
@@ -24,18 +24,16 @@ export function BackdropAction(props: BackdropActionProps) {
const handleMouseMove = useCallback(() => {
if (!moved) {
setTimeout(() => {
+ // If NOT a touch, set moved to true
const isTouch = Date.now() - lastTouchEnd.current < 200;
- if (!isTouch) {
- setMoved(true);
- }
+ if (!isTouch) setMoved(true);
}, 20);
- return;
}
// remove after all
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
- if (moved) setMoved(false);
+ setMoved(false);
timeout.current = null;
}, 3000);
}, [setMoved, moved]);
diff --git a/src/video/components/actions/CaptionRendererAction.tsx b/src/video/components/actions/CaptionRendererAction.tsx
index 0901725c..d5374dae 100644
--- a/src/video/components/actions/CaptionRendererAction.tsx
+++ b/src/video/components/actions/CaptionRendererAction.tsx
@@ -8,7 +8,7 @@ import { useVideoPlayerDescriptor } from "../../state/hooks";
import { useProgress } from "../../state/logic/progress";
import { useSource } from "../../state/logic/source";
-function CaptionCue({ text }: { text?: string }) {
+export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
const { captionSettings } = useSettings();
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "
");
@@ -25,6 +25,7 @@ function CaptionCue({ text }: { text?: string }) {
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
style={{
...captionSettings.style,
+ fontSize: captionSettings.style.fontSize * (scale ?? 1),
}}
>
60 * 60;
@@ -37,19 +42,71 @@ export function TimeAction(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const videoTime = useProgress(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
+ const { setTimeFormat } = useControls(descriptor);
+ const { timeFormat } = useInterface(descriptor);
+ const { isMobile } = useIsMobile();
+ const { t } = useTranslation();
const hasHours = durationExceedsHour(videoTime.duration);
- const time = formatSeconds(
+
+ const currentTime = formatSeconds(
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
hasHours
);
const duration = formatSeconds(videoTime.duration, hasHours);
+ const timeLeft = formatSeconds(
+ (videoTime.duration - videoTime.time) / mediaPlaying.playbackSpeed,
+ hasHours
+ );
+ const timeFinished = new Date(
+ new Date().getTime() +
+ (videoTime.duration * 1000) / mediaPlaying.playbackSpeed
+ ).toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "numeric",
+ hour12: true,
+ });
+ const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
+ timeFinished,
+ })}`;
+
+ let formattedTime: string;
+
+ if (timeFormat === VideoPlayerTimeFormat.REGULAR) {
+ formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
+ } else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
+ formattedTime = `${t("videoPlayer.timeLeft", {
+ timeLeft,
+ })}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
+ } else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
+ formattedTime = `-${timeLeft}`;
+ } else {
+ formattedTime = "";
+ }
return (
-
-
- {time} {props.noDuration ? "" : `/ ${duration}`}
-
-
+
);
}
diff --git a/src/video/components/actions/VolumeAdjustedAction.tsx b/src/video/components/actions/VolumeAdjustedAction.tsx
new file mode 100644
index 00000000..a5c547c5
--- /dev/null
+++ b/src/video/components/actions/VolumeAdjustedAction.tsx
@@ -0,0 +1,32 @@
+import { Icon, Icons } from "@/components/Icon";
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useInterface } from "@/video/state/logic/interface";
+import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
+
+export function VolumeAdjustedAction() {
+ const descriptor = useVideoPlayerDescriptor();
+ const videoInterface = useInterface(descriptor);
+ const mediaPlaying = useMediaPlaying(descriptor);
+
+ return (
+
+
0 ? Icons.VOLUME : Icons.VOLUME_X}
+ className="text-xl text-white"
+ />
+
+
+ );
+}
diff --git a/src/video/components/controllers/SourceController.tsx b/src/video/components/controllers/SourceController.tsx
index 14d5ad93..fe98a685 100644
--- a/src/video/components/controllers/SourceController.tsx
+++ b/src/video/components/controllers/SourceController.tsx
@@ -1,4 +1,11 @@
-import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
+import { getCaptionUrl, makeCaptionId } from "@/backend/helpers/captions";
+import {
+ MWCaption,
+ MWStreamQuality,
+ MWStreamType,
+} from "@/backend/helpers/streams";
+import { captionLanguages } from "@/setup/iso6391";
+import { useSettings } from "@/state/settings";
import { useInitialized } from "@/video/components/hooks/useInitialized";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
@@ -10,6 +17,19 @@ interface SourceControllerProps {
quality: MWStreamQuality;
providerId?: string;
embedId?: string;
+ captions: MWCaption[];
+}
+async function tryFetch(captions: MWCaption[]) {
+ for (let i = 0; i < captions.length; i += 1) {
+ const caption = captions[i];
+ try {
+ const blobUrl = await getCaptionUrl(caption);
+ return { caption, blobUrl };
+ } catch (error) {
+ continue;
+ }
+ }
+ return null;
}
export function SourceController(props: SourceControllerProps) {
@@ -17,13 +37,35 @@ export function SourceController(props: SourceControllerProps) {
const controls = useControls(descriptor);
const { initialized } = useInitialized(descriptor);
const didInitialize = useRef(false);
-
+ const { captionSettings } = useSettings();
useEffect(() => {
if (didInitialize.current) return;
if (!initialized) return;
controls.setSource(props);
+ // get preferred language
+ const preferredLanguage = captionLanguages.find(
+ (v) => v.id === captionSettings.language
+ );
+ if (!preferredLanguage) return;
+ const captions = props.captions.filter(
+ (v) =>
+ // langIso may contain the English name or the native name of the language
+ v.langIso.indexOf(preferredLanguage.englishName) !== -1 ||
+ v.langIso.indexOf(preferredLanguage.nativeName) !== -1
+ );
+ if (!captions) return;
+ // caption url can return a response other than 200
+ // that's why we fetch until we get a 200 response
+ tryFetch(captions).then((response) => {
+ // none of them were successful
+ if (!response) return;
+ // set the preferred language
+ const id = makeCaptionId(response.caption, true);
+ controls.setCaption(id, response.blobUrl);
+ });
+
didInitialize.current = true;
- }, [props, controls, initialized]);
+ }, [props, controls, initialized, captionSettings.language]);
return null;
}
diff --git a/src/video/components/popouts/CaptionSelectionPopout.tsx b/src/video/components/popouts/CaptionSelectionPopout.tsx
index 2ae9fced..3f595757 100644
--- a/src/video/components/popouts/CaptionSelectionPopout.tsx
+++ b/src/video/components/popouts/CaptionSelectionPopout.tsx
@@ -1,5 +1,7 @@
import {
+ customCaption,
getCaptionUrl,
+ makeCaptionId,
parseSubtitles,
subtitleTypeList,
} from "@/backend/helpers/captions";
@@ -17,11 +19,6 @@ import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
-const customCaption = "external-custom";
-function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
- return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
-}
-
export function CaptionSelectionPopout(props: {
router: ReturnType;
prefix: string;
diff --git a/src/video/components/popouts/CaptionSettingsPopout.tsx b/src/video/components/popouts/CaptionSettingsPopout.tsx
index 35bf8010..3e5a0cd1 100644
--- a/src/video/components/popouts/CaptionSettingsPopout.tsx
+++ b/src/video/components/popouts/CaptionSettingsPopout.tsx
@@ -4,8 +4,10 @@ import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useSettings } from "@/state/settings";
import { useTranslation } from "react-i18next";
-import { Icon, Icons } from "@/components/Icon";
import { Slider } from "@/components/Slider";
+import CaptionColorSelector, {
+ colors,
+} from "@/components/CaptionColorSelector";
export function CaptionSettingsPopout(props: {
router: ReturnType;
@@ -16,11 +18,9 @@ export function CaptionSettingsPopout(props: {
const {
captionSettings,
setCaptionBackgroundColor,
- setCaptionColor,
setCaptionDelay,
setCaptionFontSize,
} = useSettings();
- const colors = ["#ffffff", "#00ffff", "#ffff00"];
return (
setCaptionDelay(e.target.valueAsNumber)}
/>
- setCaptionBackgroundColor(
- `${captionSettings.style.backgroundColor.substring(
- 0,
- 7
- )}${e.target.valueAsNumber.toString(16)}`
- )
- }
+ onChange={(e) => setCaptionBackgroundColor(e.target.valueAsNumber)}
/>
{colors.map((color) => (
-
setCaptionColor(color)}
- >
-
-
-
+
))}
diff --git a/src/video/state/init.ts b/src/video/state/init.ts
index bd4037fe..559c4ca4 100644
--- a/src/video/state/init.ts
+++ b/src/video/state/init.ts
@@ -32,6 +32,9 @@ function initPlayer(): VideoPlayerState {
isFocused: false,
leftControlHovering: false,
popoutBounds: null,
+ volumeChangedWithKeybind: false,
+ volumeChangedWithKeybindDebounce: null,
+ timeFormat: 0,
},
mediaPlaying: {
diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts
index e6d33369..e87a5255 100644
--- a/src/video/state/logic/controls.ts
+++ b/src/video/state/logic/controls.ts
@@ -1,7 +1,7 @@
import { updateInterface } from "@/video/state/logic/interface";
import { updateMeta } from "@/video/state/logic/meta";
import { updateProgress } from "@/video/state/logic/progress";
-import { VideoPlayerMeta } from "@/video/state/types";
+import { VideoPlayerMeta, VideoPlayerTimeFormat } from "@/video/state/types";
import { getPlayerState } from "../cache";
import { VideoPlayerStateController } from "../providers/providerTypes";
@@ -15,6 +15,7 @@ export type ControlMethods = {
setDraggingTime(num: number): void;
togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void;
+ setTimeFormat(num: VideoPlayerTimeFormat): void;
};
export function useControls(
@@ -48,8 +49,20 @@ export function useControls(
enterFullscreen() {
state.stateProvider?.enterFullscreen();
},
- setVolume(volume) {
- state.stateProvider?.setVolume(volume);
+ setVolume(volume, isKeyboardEvent = false) {
+ if (isKeyboardEvent) {
+ if (state.interface.volumeChangedWithKeybindDebounce)
+ clearTimeout(state.interface.volumeChangedWithKeybindDebounce);
+
+ state.interface.volumeChangedWithKeybind = true;
+ updateInterface(descriptor, state);
+
+ state.interface.volumeChangedWithKeybindDebounce = setTimeout(() => {
+ state.interface.volumeChangedWithKeybind = false;
+ updateInterface(descriptor, state);
+ }, 3e3);
+ }
+ state.stateProvider?.setVolume(volume, isKeyboardEvent);
},
startAirplay() {
state.stateProvider?.startAirplay();
@@ -110,5 +123,9 @@ export function useControls(
state.stateProvider?.setPlaybackSpeed(num);
updateInterface(descriptor, state);
},
+ setTimeFormat(format) {
+ state.interface.timeFormat = format;
+ updateInterface(descriptor, state);
+ },
};
}
diff --git a/src/video/state/logic/interface.ts b/src/video/state/logic/interface.ts
index 2f22823f..35ab51c6 100644
--- a/src/video/state/logic/interface.ts
+++ b/src/video/state/logic/interface.ts
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { getPlayerState } from "../cache";
import { listenEvent, sendEvent, unlistenEvent } from "../events";
-import { VideoPlayerState } from "../types";
+import { VideoPlayerState, VideoPlayerTimeFormat } from "../types";
export type VideoInterfaceEvent = {
popout: string | null;
@@ -9,6 +9,8 @@ export type VideoInterfaceEvent = {
isFocused: boolean;
isFullscreen: boolean;
popoutBounds: null | DOMRect;
+ volumeChangedWithKeybind: boolean;
+ timeFormat: VideoPlayerTimeFormat;
};
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
@@ -18,6 +20,8 @@ function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
isFocused: state.interface.isFocused,
isFullscreen: state.interface.isFullscreen,
popoutBounds: state.interface.popoutBounds,
+ volumeChangedWithKeybind: state.interface.volumeChangedWithKeybind,
+ timeFormat: state.interface.timeFormat,
};
}
diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts
index ad09e812..acc73dc5 100644
--- a/src/video/state/providers/providerTypes.ts
+++ b/src/video/state/providers/providerTypes.ts
@@ -16,7 +16,7 @@ export type VideoPlayerStateController = {
setSeeking(active: boolean): void;
exitFullscreen(): void;
enterFullscreen(): void;
- setVolume(volume: number): void;
+ setVolume(volume: number, isKeyboardEvent?: boolean): void;
startAirplay(): void;
setCaption(id: string, url: string): void;
clearCaption(): void;
diff --git a/src/video/state/types.ts b/src/video/state/types.ts
index 1ba9ef7a..5782d7c1 100644
--- a/src/video/state/types.ts
+++ b/src/video/state/types.ts
@@ -22,14 +22,22 @@ export type VideoPlayerMeta = {
}[];
};
+export enum VideoPlayerTimeFormat {
+ REGULAR = 0,
+ REMAINING = 1,
+}
+
export type VideoPlayerState = {
// state related to the user interface
interface: {
isFullscreen: boolean;
popout: string | null; // id of current popout (eg source select, episode select)
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
+ volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
+ volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
popoutBounds: null | DOMRect; // bounding box of current popout
+ timeFormat: VideoPlayerTimeFormat; // Time format of the video player
};
// state related to the playing state of the media
diff --git a/src/views/SettingsModal.tsx b/src/views/SettingsModal.tsx
new file mode 100644
index 00000000..b3ba74ed
--- /dev/null
+++ b/src/views/SettingsModal.tsx
@@ -0,0 +1,147 @@
+import { Dropdown } from "@/components/Dropdown";
+import { Icon, Icons } from "@/components/Icon";
+import { Modal, ModalCard } from "@/components/layout/Modal";
+import { useSettings } from "@/state/settings";
+import { useTranslation } from "react-i18next";
+import { CaptionCue } from "@/video/components/actions/CaptionRendererAction";
+import {
+ CaptionLanguageOption,
+ LangCode,
+ captionLanguages,
+} from "@/setup/iso6391";
+import { useMemo } from "react";
+import { appLanguageOptions } from "@/setup/i18n";
+import CaptionColorSelector, {
+ colors,
+} from "@/components/CaptionColorSelector";
+import { Slider } from "@/components/Slider";
+import { conf } from "@/setup/config";
+
+export default function SettingsModal(props: {
+ onClose: () => void;
+ show: boolean;
+}) {
+ const {
+ captionSettings,
+ language,
+ setLanguage,
+ setCaptionLanguage,
+ setCaptionBackgroundColor,
+ setCaptionFontSize,
+ } = useSettings();
+ const { t, i18n } = useTranslation();
+
+ const selectedCaptionLanguage = useMemo(
+ () => captionLanguages.find((l) => l.id === captionSettings.language),
+ [captionSettings.language]
+ ) as CaptionLanguageOption;
+ const appLanguage = useMemo(
+ () => appLanguageOptions.find((l) => l.id === language),
+ [language]
+ ) as CaptionLanguageOption;
+ const captionBackgroundOpacity = (
+ (parseInt(captionSettings.style.backgroundColor.substring(7, 9), 16) /
+ 255) *
+ 100
+ ).toFixed(0);
+ return (
+
+
+
+
+
{t("settings.title")}
+
props.onClose()}
+ className="hover:cursor-pointer"
+ >
+
+
+
+
+
+
+
+ {
+ i18n.changeLanguage(val.id);
+ setLanguage(val.id as LangCode);
+ }}
+ options={appLanguageOptions}
+ />
+
+
+
+ {
+ setCaptionLanguage(val.id as LangCode);
+ }}
+ options={captionLanguages}
+ />
+
+
+
setCaptionFontSize(e.target.valueAsNumber)}
+ />
+
+ setCaptionBackgroundColor(e.target.valueAsNumber)
+ }
+ />
+
+
+
+ {colors.map((color) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ v{conf().APP_VERSION}
+
+
+ );
+}
diff --git a/src/views/developer/VideoTesterView.tsx b/src/views/developer/VideoTesterView.tsx
index 7e3a409f..2291470e 100644
--- a/src/views/developer/VideoTesterView.tsx
+++ b/src/views/developer/VideoTesterView.tsx
@@ -66,6 +66,7 @@ export default function VideoTesterView() {
source={video.streamUrl}
type={videoType}
quality={MWStreamQuality.QUNKNOWN}
+ captions={[]}
/>
diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx
index b674fb9f..c2ee94e7 100644
--- a/src/views/media/MediaView.tsx
+++ b/src/views/media/MediaView.tsx
@@ -148,6 +148,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
quality={props.stream.quality}
embedId={props.stream.embedId}
providerId={props.stream.providerId}
+ captions={props.stream.captions}
/>