diff --git a/package.json b/package.json index 097149c6..2223761f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "movie-web", - "version": "3.0.10", + "version": "3.0.11", "private": true, "homepage": "https://movie.squeezebox.dev", "dependencies": { diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts index 83edaa84..b5e40108 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 } 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 const sanitize = DOMPurify.sanitize; export async function getCaptionUrl(caption: MWCaption): Promise { diff --git a/src/backend/index.ts b/src/backend/index.ts index 7a13a445..3d5a33c1 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,11 +1,12 @@ import { initializeScraperStore } from "./helpers/register"; // providers -import "./providers/gdriveplayer"; +// import "./providers/gdriveplayer"; import "./providers/flixhq"; import "./providers/superstream"; import "./providers/netfilm"; import "./providers/m4ufree"; +import "./providers/hdwatched"; // embeds import "./embeds/streamm4u"; diff --git a/src/backend/providers/hdwatched.ts b/src/backend/providers/hdwatched.ts new file mode 100644 index 00000000..cacaec41 --- /dev/null +++ b/src/backend/providers/hdwatched.ts @@ -0,0 +1,196 @@ +import { proxiedFetch } from "../helpers/fetch"; +import { MWProviderContext } from "../helpers/provider"; +import { registerProvider } from "../helpers/register"; +import { MWStreamQuality, MWStreamType } from "../helpers/streams"; +import { MWMediaType } from "../metadata/types"; + +const hdwatchedBase = "https://www.hdwatched.xyz"; + +const qualityMap: Record = { + 360: MWStreamQuality.Q360P, + 540: MWStreamQuality.Q540P, + 480: MWStreamQuality.Q480P, + 720: MWStreamQuality.Q720P, + 1080: MWStreamQuality.Q1080P, +}; + +interface SearchRes { + title: string; + year?: number; + href: string; + id: string; +} + +function getStreamFromEmbed(stream: string) { + const embedPage = new DOMParser().parseFromString(stream, "text/html"); + const source = embedPage.querySelector("#vjsplayer > source"); + if (!source) { + throw new Error("Unable to fetch stream"); + } + + const streamSrc = source.getAttribute("src"); + const streamRes = source.getAttribute("res"); + + if (!streamSrc || !streamRes) throw new Error("Unable to find stream"); + + return { + streamUrl: streamSrc, + quality: + streamRes && typeof +streamRes === "number" + ? qualityMap[+streamRes] + : MWStreamQuality.QUNKNOWN, + }; +} + +async function fetchMovie(targetSource: SearchRes) { + const stream = await proxiedFetch(`/embed/${targetSource.id}`, { + baseURL: hdwatchedBase, + }); + + const embedPage = new DOMParser().parseFromString(stream, "text/html"); + const source = embedPage.querySelector("#vjsplayer > source"); + if (!source) { + throw new Error("Unable to fetch movie stream"); + } + + return getStreamFromEmbed(stream); +} + +async function fetchSeries( + targetSource: SearchRes, + { media, episode, progress }: MWProviderContext +) { + if (media.meta.type !== MWMediaType.SERIES) + throw new Error("Media type mismatch"); + + const seasonNumber = media.meta.seasonData.number; + const episodeNumber = media.meta.seasonData.episodes.find( + (e) => e.id === episode + )?.number; + + if (!seasonNumber || !episodeNumber) + throw new Error("Unable to get season or episode number"); + + const seriesPage = await proxiedFetch( + `${targetSource.href}?season=${media.meta.seasonData.number}`, + { + baseURL: hdwatchedBase, + } + ); + + const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html"); + const pageElements = seasonPage.querySelectorAll("div.i-container"); + + const seriesList: SearchRes[] = []; + pageElements.forEach((pageElement) => { + const href = pageElement.querySelector("a")?.getAttribute("href") || ""; + const title = + pageElement?.querySelector("span.content-title")?.textContent || ""; + + seriesList.push({ + title, + href, + id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number} + }); + }); + + const targetEpisode = seriesList.find( + (episodeEl) => + episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}` + ); + + if (!targetEpisode) throw new Error("Unable to find episode"); + + progress(70); + + const stream = await proxiedFetch(`/embed/${targetEpisode.id}`, { + baseURL: hdwatchedBase, + }); + + const embedPage = new DOMParser().parseFromString(stream, "text/html"); + const source = embedPage.querySelector("#vjsplayer > source"); + if (!source) { + throw new Error("Unable to fetch movie stream"); + } + + return getStreamFromEmbed(stream); +} + +registerProvider({ + id: "hdwatched", + displayName: "HDwatched", + rank: 150, + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + async scrape(options) { + const { media, progress } = options; + if (!this.type.includes(media.meta.type)) { + throw new Error("Unsupported type"); + } + + const search = await proxiedFetch(`/search/${media.imdbId}`, { + baseURL: hdwatchedBase, + }); + + const searchPage = new DOMParser().parseFromString(search, "text/html"); + const pageElements = searchPage.querySelectorAll("div.i-container"); + + const searchList: SearchRes[] = []; + pageElements.forEach((pageElement) => { + const href = pageElement.querySelector("a")?.getAttribute("href") || ""; + const title = + pageElement?.querySelector("span.content-title")?.textContent || ""; + const year = + parseInt( + pageElement + ?.querySelector("div.duration") + ?.textContent?.trim() + ?.split(" ") + ?.pop() || "", + 10 + ) || 0; + + searchList.push({ + title, + year, + href, + id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug} + }); + }); + + progress(20); + + const targetSource = searchList.find( + (source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust + ); + + if (!targetSource) { + throw new Error("Could not find stream"); + } + + progress(40); + + if (media.meta.type === MWMediaType.SERIES) { + const series = await fetchSeries(targetSource, options); + return { + embeds: [], + stream: { + streamUrl: series.streamUrl, + quality: series.quality, + type: MWStreamType.MP4, + captions: [], + }, + }; + } + + const movie = await fetchMovie(targetSource); + return { + embeds: [], + stream: { + streamUrl: movie.streamUrl, + quality: movie.quality, + type: MWStreamType.MP4, + captions: [], + }, + }; + }, +}); 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 server or on <1>GitHub." } }, + "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 ab7edba0..664cffef 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} />