captions + translation fix

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
This commit is contained in:
Jelle van Snik 2023-02-08 22:51:52 +01:00
parent c4712044a9
commit f97b84516b
25 changed files with 283 additions and 124 deletions

View File

@ -13,7 +13,6 @@
"hls.js": "^1.0.7", "hls.js": "^1.0.7",
"i18next": "^22.4.5", "i18next": "^22.4.5",
"i18next-browser-languagedetector": "^7.0.1", "i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.1.0",
"json5": "^2.2.0", "json5": "^2.2.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",

View File

@ -0,0 +1,34 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt";
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) {
let captionBlob: Blob;
if (caption.needsProxy) {
captionBlob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
} else {
captionBlob = await mwFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
}
return toWebVTT(captionBlob);
}
if (caption.type === MWCaptionType.VTT) {
if (caption.needsProxy) {
const blob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
return URL.createObjectURL(blob);
}
return caption.url;
}
throw new Error("invalid type");
}

View File

@ -40,6 +40,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
Object.entries(ops?.params ?? {}).forEach(([k, v]) => { Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
parsedUrl.searchParams.set(k, v); parsedUrl.searchParams.set(k, v);
}); });
return baseFetch<T>(conf().BASE_PROXY_URL, { return baseFetch<T>(conf().BASE_PROXY_URL, {
...ops, ...ops,
baseURL: undefined, baseURL: undefined,

View File

@ -3,6 +3,11 @@ export enum MWStreamType {
HLS = "hls", HLS = "hls",
} }
export enum MWCaptionType {
VTT = "vtt",
SRT = "srt",
}
export enum MWStreamQuality { export enum MWStreamQuality {
Q360P = "360p", Q360P = "360p",
Q480P = "480p", Q480P = "480p",
@ -11,8 +16,16 @@ export enum MWStreamQuality {
QUNKNOWN = "unknown", QUNKNOWN = "unknown",
} }
export type MWCaption = {
needsProxy?: boolean;
url: string;
type: MWCaptionType;
langIso: string;
};
export type MWStream = { export type MWStream = {
streamUrl: string; streamUrl: string;
type: MWStreamType; type: MWStreamType;
quality: MWStreamQuality; quality: MWStreamQuality;
captions: MWCaption[];
}; };

View File

@ -96,6 +96,7 @@ registerProvider({
streamUrl: `https:${source.file}`, streamUrl: `https:${source.file}`,
type: source.type, type: source.type,
quality, quality,
captions: [],
}, },
embeds: [], embeds: [],
}; };

View File

@ -60,7 +60,7 @@ registerProvider({
streamUrl: source.url, streamUrl: source.url,
quality: qualityMap[source.quality as QualityInMap], quality: qualityMap[source.quality as QualityInMap],
type: MWStreamType.HLS, type: MWStreamType.HLS,
// captions: [], captions: [],
}, },
}; };
} }
@ -121,7 +121,7 @@ registerProvider({
streamUrl: source.url, streamUrl: source.url,
quality: qualityMap[source.quality as QualityInMap], quality: qualityMap[source.quality as QualityInMap],
type: MWStreamType.HLS, type: MWStreamType.HLS,
// captions: [], captions: [],
}, },
}; };
}, },

View File

@ -2,10 +2,14 @@ import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types"; import { MWMediaType } from "@/backend/metadata/types";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
// import toWebVTT from "srt-webvtt";
import CryptoJS from "crypto-js"; import CryptoJS from "crypto-js";
import { proxiedFetch } from "@/backend/helpers/fetch"; import { proxiedFetch } from "@/backend/helpers/fetch";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; import {
MWCaption,
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
const nanoid = customAlphabet("0123456789abcdef", 32); const nanoid = customAlphabet("0123456789abcdef", 32);
@ -150,28 +154,27 @@ registerProvider({
if (!hdQuality) throw new Error("No quality could be found."); if (!hdQuality) throw new Error("No quality could be found.");
// const subtitleApiQuery = { const subtitleApiQuery = {
// fid: hdQuality.fid, fid: hdQuality.fid,
// uid: "", uid: "",
// module: "Movie_srt_list_v2", module: "Movie_srt_list_v2",
// mid: tmdbId, mid: superstreamId,
// }; };
// const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) const subtitleRes = (await get(subtitleApiQuery)).data;
// .data;
// const mappedCaptions = await Promise.all( console.log(subtitleRes);
// subtitleRes.list.map(async (subtitle: any) => {
// const captionBlob = await fetch( const mappedCaptions = subtitleRes.list.map(
// `${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}` (subtitle: any): MWCaption => {
// ).then((captionRes) => captionRes.blob()); // cross-origin bypass return {
// const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable needsProxy: true,
// return { langIso: subtitle.language,
// id: subtitle.language, url: subtitle.subtitles[0].file_path,
// url: captionUrl, type: MWCaptionType.SRT,
// label: subtitle.language, };
// }; }
// }) );
// );
return { return {
embeds: [], embeds: [],
@ -179,6 +182,7 @@ registerProvider({
streamUrl: hdQuality.path, streamUrl: hdQuality.path,
quality: qualityMap[hdQuality.quality as QualityInMap], quality: qualityMap[hdQuality.quality as QualityInMap],
type: MWStreamType.MP4, type: MWStreamType.MP4,
captions: mappedCaptions,
}, },
}; };
} }
@ -208,29 +212,28 @@ registerProvider({
if (!hdQuality) throw new Error("No quality could be found."); if (!hdQuality) throw new Error("No quality could be found.");
// const subtitleApiQuery = { const subtitleApiQuery = {
// fid: hdQuality.fid, fid: hdQuality.fid,
// uid: "", uid: "",
// module: "TV_srt_list_v2", module: "TV_srt_list_v2",
// episode: media.episodeId, episode:
// tid: media.mediaId, media.meta.seasonData.episodes.find(
// season: media.seasonId, (episodeInfo) => episodeInfo.id === episode
// }; )?.number ?? 1,
// const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) tid: superstreamId,
// .data; season: media.meta.seasonData.number.toString(),
// const mappedCaptions = await Promise.all( };
// subtitleRes.list.map(async (subtitle: any) => {
// const captionBlob = await fetch( const subtitleRes = (await get(subtitleApiQuery)).data;
// `${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}`
// ).then((captionRes) => captionRes.blob()); // cross-origin bypass const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
// const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable return {
// return { needsProxy: true,
// id: subtitle.language, langIso: subtitle.language,
// url: captionUrl, url: subtitle.subtitles[0].file_path,
// label: subtitle.language, type: MWCaptionType.SRT,
// }; };
// }) });
// );
return { return {
embeds: [], embeds: [],
@ -240,6 +243,7 @@ registerProvider({
] as MWStreamQuality, ] as MWStreamQuality,
streamUrl: hdQuality.path, streamUrl: hdQuality.path,
type: MWStreamType.MP4, type: MWStreamType.MP4,
captions: mappedCaptions,
}, },
}; };
}, },

View File

@ -32,6 +32,7 @@ export enum Icons {
SKIP_BACKWARD = "skip_backward", SKIP_BACKWARD = "skip_backward",
FILE = "file", FILE = "file",
CAPTIONS = "captions", CAPTIONS = "captions",
LINK = "link",
} }
export interface IconProps { export interface IconProps {
@ -71,6 +72,7 @@ const iconList: Record<Icons, string> = {
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`, skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`, file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`, captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
}; };
export const Icon = memo((props: IconProps) => { export const Icon = memo((props: IconProps) => {

View File

@ -1,7 +1,8 @@
.spinner { .spinner {
width: 48px; font-size: 48px;
height: 48px; width: 1em;
border: 5px solid white; height: 1em;
border: 0.12em solid var(--color,white);
border-bottom-color: transparent; border-bottom-color: transparent;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;

View File

@ -1,5 +1,9 @@
import "./Spinner.css"; import "./Spinner.css";
export function Spinner() { interface SpinnerProps {
return <div className="spinner" />; className: string;
}
export function Spinner(props: SpinnerProps) {
return <div className={["spinner", props.className].join(" ")} />;
} }

View File

@ -40,6 +40,7 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
.catch((err) => { .catch((err) => {
if (isMounted) { if (isMounted) {
setError(err); setError(err);
console.error("USELOADING ERROR", err);
setSuccess(false); setSuccess(false);
} }
resolve(undefined); resolve(undefined);

View File

@ -19,7 +19,7 @@ if (key) {
initializeChromecast(); initializeChromecast();
// TODO video todos: // TODO video todos:
// - captions // - finish captions
// - chrome cast support // - chrome cast support
// - bug: mobile controls start showing when resizing // - bug: mobile controls start showing when resizing
// - bug: popouts sometimes stop working when selecting different episode // - bug: popouts sometimes stop working when selecting different episode
@ -36,12 +36,11 @@ initializeChromecast();
// - video player error handling // - video player error handling
// TODO backend system: // TODO backend system:
// - caption support
// - implement jons providers/embedscrapers // - implement jons providers/embedscrapers
// - AFTER all that: rank providers/embedscrapers // - AFTER all that: rank providers/embedscrapers
// TODO general todos: // TODO general todos:
// - localize everything (fix loading screen text (series vs movies)) (and have EN file instead of en-gb) // - localize everything (fix loading screen text (series vs movies))
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>

View File

@ -1,14 +1,11 @@
import i18n from "i18next"; import i18n from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
// Languages
import en from "./locales/en/translation.json";
i18n i18n
// load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales)
// learn more: https://github.com/i18next/i18next-http-backend
// want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn
.use(Backend)
// detect user language // detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector // learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector) .use(LanguageDetector)
@ -17,7 +14,13 @@ i18n
// init i18next // init i18next
// for all options read: https://www.i18next.com/overview/configuration-options // for all options read: https://www.i18next.com/overview/configuration-options
.init({ .init({
fallbackLng: "en-GB", fallbackLng: "en",
resources: {
en: {
translation: en,
},
},
interpolation: { interpolation: {
escapeValue: false, // not needed for react as it escapes by default escapeValue: false, // not needed for react as it escapes by default

View File

@ -1,3 +1,4 @@
import { MWCaption } from "@/backend/helpers/streams";
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
@ -7,6 +8,7 @@ import { useEffect } from "react";
interface MetaControllerProps { interface MetaControllerProps {
data?: VideoPlayerMeta; data?: VideoPlayerMeta;
seasonData?: MWSeasonWithEpisodeMeta; seasonData?: MWSeasonWithEpisodeMeta;
linkedCaptions?: MWCaption[];
} }
function formatMetadata( function formatMetadata(
@ -27,6 +29,7 @@ function formatMetadata(
meta: props.data.meta, meta: props.data.meta,
episode: props.data.episode, episode: props.data.episode,
seasons: seasonsWithEpisodes, seasons: seasonsWithEpisodes,
captions: props.linkedCaptions ?? [],
}; };
} }

View File

@ -1,6 +1,7 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useMisc } from "@/video/state/logic/misc"; import { useMisc } from "@/video/state/logic/misc";
import { useSource } from "@/video/state/logic/source";
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils"; import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider"; import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
@ -12,6 +13,7 @@ interface Props {
export function VideoElementInternal(props: Props) { export function VideoElementInternal(props: Props) {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const mediaPlaying = useMediaPlaying(descriptor); const mediaPlaying = useMediaPlaying(descriptor);
const source = useSource(descriptor);
const misc = useMisc(descriptor); const misc = useMisc(descriptor);
const ref = useRef<HTMLVideoElement>(null); const ref = useRef<HTMLVideoElement>(null);
@ -37,6 +39,10 @@ export function VideoElementInternal(props: Props) {
muted={mediaPlaying.volume === 0} muted={mediaPlaying.volume === 0}
playsInline playsInline
className="h-full w-full" className="h-full w-full"
/> >
{source.source?.caption ? (
<track default kind="captions" src={source.source.caption.url} />
) : null}
</video>
); );
} }

View File

@ -1,14 +1,70 @@
import { PopoutSection } from "./PopoutUtils"; import { getCaptionUrl } from "@/backend/helpers/captions";
import { MWCaption } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMeta } from "@/video/state/logic/meta";
import { useSource } from "@/video/state/logic/source";
import { useMemo, useRef } from "react";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
}
// TODO add option to clear captions
export function CaptionSelectionPopout() { export function CaptionSelectionPopout() {
const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor);
const source = useSource(descriptor);
const controls = useControls(descriptor);
const linkedCaptions = useMemo(
() =>
meta?.captions.map((v) => ({ ...v, id: makeCaptionId(v, true) })) ?? [],
[meta]
);
const loadingId = useRef<string>("");
const [setCaption, loading, error] = useLoading(
async (caption: MWCaption, isLinked: boolean) => {
const id = makeCaptionId(caption, isLinked);
loadingId.current = id;
controls.setCaption(id, await getCaptionUrl(caption));
controls.closePopout();
}
);
const currentCaption = source.source?.caption?.id;
return ( return (
<> <>
<PopoutSection className="bg-ash-100 font-bold text-white"> <PopoutSection className="bg-ash-100 font-bold text-white">
<div>Captions</div> <div>Captions</div>
</PopoutSection> </PopoutSection>
<PopoutSection> <div className="relative overflow-y-auto">
<div>Hi Jeebies</div> <p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase">
</PopoutSection> <Icon className="text-base" icon={Icons.LINK} />
<span>Linked captions</span>
</p>
<PopoutSection className="pt-0">
<div>
{linkedCaptions.map((link) => (
<PopoutListEntry
key={link.langIso}
active={link.id === currentCaption}
loading={loading && link.id === loadingId.current}
errored={error && link.id === loadingId.current}
onClick={() => {
loadingId.current = link.id;
setCaption(link, true);
}}
>
{link.langIso}
</PopoutListEntry>
))}
</div>
</PopoutSection>
</div>
</> </>
); );
} }

View File

@ -1,5 +1,7 @@
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { Spinner } from "@/components/layout/Spinner";
import { ProgressRing } from "@/components/layout/ProgressRing"; import { ProgressRing } from "@/components/layout/ProgressRing";
import { createRef, useEffect, useRef } from "react";
interface PopoutListEntryTypes { interface PopoutListEntryTypes {
active?: boolean; active?: boolean;
@ -7,14 +9,37 @@ interface PopoutListEntryTypes {
onClick?: () => void; onClick?: () => void;
isOnDarkBackground?: boolean; isOnDarkBackground?: boolean;
percentageCompleted?: number; percentageCompleted?: number;
loading?: boolean;
errored?: boolean;
} }
export function PopoutSection(props: { export function PopoutSection(props: {
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
}) { }) {
const ref = createRef<HTMLDivElement>();
const inited = useRef<boolean>(false);
// Scroll to "active" child on first load (AKA mount except React dumb)
useEffect(() => {
if (inited.current) return;
if (!ref.current) return;
const el = ref.current as HTMLDivElement;
const active: HTMLDivElement | null = el.querySelector(".active");
if (active) {
active?.scrollIntoView({
block: "nearest",
inline: "nearest",
});
el.scrollTo({
top: el.scrollTop + el.offsetHeight / 2 - active.offsetHeight / 2,
});
}
inited.current = true;
}, [ref]);
return ( return (
<div className={["p-5", props.className || ""].join(" ")}> <div className={["p-5", props.className || ""].join(" ")} ref={ref}>
{props.children} {props.children}
</div> </div>
); );
@ -32,7 +57,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150", "group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
hover, hover,
props.active props.active
? `${bg} text-white outline-denim-700` ? `${bg} active text-white outline-denim-700`
: "text-denim-700 hover:text-white", : "text-denim-700 hover:text-white",
].join(" ")} ].join(" ")}
onClick={props.onClick} onClick={props.onClick}
@ -42,11 +67,22 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
)} )}
<span className="truncate">{props.children}</span> <span className="truncate">{props.children}</span>
<div className="relative h-4 w-4 min-w-[1rem]"> <div className="relative h-4 w-4 min-w-[1rem]">
<Icon {props.errored && (
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100" <Icon
icon={Icons.CHEVRON_RIGHT} icon={Icons.WARNING}
/> className="absolute inset-0 text-rose-400"
{props.percentageCompleted ? ( />
)}
{props.loading && !props.errored && (
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
)}
{!props.loading && !props.errored && (
<Icon
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
icon={Icons.CHEVRON_RIGHT}
/>
)}
{props.percentageCompleted && !props.loading && !props.errored ? (
<ProgressRing <ProgressRing
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0" className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
backingRingClassname="stroke-ash-500" backingRingClassname="stroke-ash-500"

View File

@ -49,6 +49,12 @@ export function useControls(
startAirplay() { startAirplay() {
state.stateProvider?.startAirplay(); state.stateProvider?.startAirplay();
}, },
setCaption(id, url) {
state.stateProvider?.setCaption(id, url);
},
clearCaption() {
state.stateProvider?.clearCaption();
},
// other controls // other controls
setLeftControlsHover(hovering) { setLeftControlsHover(hovering) {

View File

@ -9,6 +9,10 @@ export type VideoSourceEvent = {
quality: MWStreamQuality; quality: MWStreamQuality;
url: string; url: string;
type: MWStreamType; type: MWStreamType;
caption: null | {
id: string;
url: string;
};
}; };
}; };

View File

@ -16,6 +16,8 @@ export type VideoPlayerStateController = {
enterFullscreen(): void; enterFullscreen(): void;
setVolume(volume: number): void; setVolume(volume: number): void;
startAirplay(): void; startAirplay(): void;
setCaption(id: string, url: string): void;
clearCaption(): void;
}; };
export type VideoPlayerStateProvider = VideoPlayerStateController & { export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

@ -173,9 +173,25 @@ export function createVideoStateProvider(
quality: source.quality, quality: source.quality,
type: source.type, type: source.type,
url: source.source, url: source.source,
caption: null,
}; };
updateSource(descriptor, state); updateSource(descriptor, state);
}, },
setCaption(id, url) {
if (state.source) {
state.source.caption = {
id,
url,
};
updateSource(descriptor, state);
}
},
clearCaption() {
if (state.source) {
state.source.caption = null;
updateSource(descriptor, state);
}
},
providerStart() { providerStart() {
this.setVolume(getStoredVolume()); this.setVolume(getStoredVolume());

View File

@ -1,9 +1,14 @@
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; import {
MWCaption,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
import { MWMediaMeta } from "@/backend/metadata/types"; import { MWMediaMeta } from "@/backend/metadata/types";
import { VideoPlayerStateProvider } from "./providers/providerTypes"; import { VideoPlayerStateProvider } from "./providers/providerTypes";
export type VideoPlayerMeta = { export type VideoPlayerMeta = {
meta: MWMediaMeta; meta: MWMediaMeta;
captions: MWCaption[];
episode?: { episode?: {
episodeId: string; episodeId: string;
seasonId: string; seasonId: string;
@ -52,6 +57,10 @@ export type VideoPlayerState = {
quality: MWStreamQuality; quality: MWStreamQuality;
url: string; url: string;
type: MWStreamType; type: MWStreamType;
caption: null | {
url: string;
id: string;
};
}; };
// misc // misc

View File

@ -112,6 +112,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
const metaProps: VideoPlayerMeta = { const metaProps: VideoPlayerMeta = {
meta: props.meta.meta, meta: props.meta.meta,
captions: [],
}; };
let metaSeasonData: MWSeasonWithEpisodeMeta | undefined; let metaSeasonData: MWSeasonWithEpisodeMeta | undefined;
if ( if (
@ -132,7 +133,11 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
<html data-full="true" /> <html data-full="true" />
</Helmet> </Helmet>
<VideoPlayer includeSafeArea autoPlay onGoBack={goBack}> <VideoPlayer includeSafeArea autoPlay onGoBack={goBack}>
<MetaController data={metaProps} seasonData={metaSeasonData} /> <MetaController
data={metaProps}
seasonData={metaSeasonData}
linkedCaptions={props.stream.captions}
/>
<SourceController <SourceController
source={props.stream.streamUrl} source={props.stream.streamUrl}
type={props.stream.type} type={props.stream.type}

View File

@ -927,13 +927,6 @@
"resolved" "https://registry.npmjs.org/core-js/-/core-js-3.27.1.tgz" "resolved" "https://registry.npmjs.org/core-js/-/core-js-3.27.1.tgz"
"version" "3.27.1" "version" "3.27.1"
"cross-fetch@3.1.5":
"integrity" "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw=="
"resolved" "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz"
"version" "3.1.5"
dependencies:
"node-fetch" "2.6.7"
"cross-spawn@^7.0.2": "cross-spawn@^7.0.2":
"integrity" "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==" "integrity" "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w=="
"resolved" "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" "resolved" "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
@ -1087,13 +1080,6 @@
"resolved" "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" "resolved" "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
"version" "9.2.2" "version" "9.2.2"
"encoding@^0.1.0":
"integrity" "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="
"resolved" "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz"
"version" "0.1.13"
dependencies:
"iconv-lite" "^0.6.2"
"encoding@^0.1.13": "encoding@^0.1.13":
"version" "0.1.13" "version" "0.1.13"
dependencies: dependencies:
@ -1793,13 +1779,6 @@
dependencies: dependencies:
"@babel/runtime" "^7.19.4" "@babel/runtime" "^7.19.4"
"i18next-http-backend@^2.1.0":
"integrity" "sha512-rTVhhFrpnZJnNvCCdC6RjhFPk0S6mJ2VAix93vbDD19ixlrSJtoNqkk49wvR10PImBSsuGJf35gMQwn2mjer6A=="
"resolved" "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.1.0.tgz"
"version" "2.1.0"
dependencies:
"cross-fetch" "3.1.5"
"i18next@^22.4.5", "i18next@>= 19.0.0": "i18next@^22.4.5", "i18next@>= 19.0.0":
"integrity" "sha512-Kc+Ow0guRetUq+kv02tj0Yof9zveROPBAmJ8UxxNODLVBRSwsM4iD0Gw3BEieOmkWemF6clU3K1fbnCuTqiN2Q==" "integrity" "sha512-Kc+Ow0guRetUq+kv02tj0Yof9zveROPBAmJ8UxxNODLVBRSwsM4iD0Gw3BEieOmkWemF6clU3K1fbnCuTqiN2Q=="
"resolved" "https://registry.npmjs.org/i18next/-/i18next-22.4.5.tgz" "resolved" "https://registry.npmjs.org/i18next/-/i18next-22.4.5.tgz"
@ -2417,13 +2396,6 @@
"resolved" "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz" "resolved" "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz"
"version" "1.0.1" "version" "1.0.1"
"node-fetch@2.6.7":
"integrity" "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="
"resolved" "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
"version" "2.6.7"
dependencies:
"whatwg-url" "^5.0.0"
"node-gyp@^9.0.0", "node-gyp@^9.3.0": "node-gyp@^9.0.0", "node-gyp@^9.3.0":
"version" "9.3.0" "version" "9.3.0"
dependencies: dependencies:
@ -3466,11 +3438,6 @@
dependencies: dependencies:
"is-number" "^7.0.0" "is-number" "^7.0.0"
"tr46@~0.0.3":
"integrity" "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
"resolved" "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz"
"version" "0.0.3"
"treeverse@^3.0.0": "treeverse@^3.0.0":
"version" "3.0.0" "version" "3.0.0"
@ -3619,19 +3586,6 @@
dependencies: dependencies:
"defaults" "^1.0.3" "defaults" "^1.0.3"
"webidl-conversions@^3.0.0":
"integrity" "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
"resolved" "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"
"version" "3.0.1"
"whatwg-url@^5.0.0":
"integrity" "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="
"resolved" "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz"
"version" "5.0.0"
dependencies:
"tr46" "~0.0.3"
"webidl-conversions" "^3.0.0"
"which-boxed-primitive@^1.0.2": "which-boxed-primitive@^1.0.2":
"integrity" "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==" "integrity" "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg=="
"resolved" "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz" "resolved" "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"