diff --git a/package.json b/package.json index 7aa795f7..097149c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "movie-web", - "version": "3.0.9", + "version": "3.0.10", "private": true, "homepage": "https://movie.squeezebox.dev", "dependencies": { @@ -19,7 +19,6 @@ "json5": "^2.2.0", "lodash.throttle": "^4.1.1", "nanoid": "^4.0.0", - "node-webvtt": "^1.9.4", "ofetch": "^1.0.0", "pako": "^2.1.0", "react": "^17.0.2", @@ -31,7 +30,7 @@ "react-stickynode": "^4.1.0", "react-transition-group": "^4.4.5", "react-use": "^17.4.0", - "srt-webvtt": "^2.0.0", + "subsrt-ts": "^2.1.0", "unpacker": "^1.0.1" }, "scripts": { diff --git a/src/@types/node_webtt.d.ts b/src/@types/node_webtt.d.ts deleted file mode 100644 index 5662d89f..00000000 --- a/src/@types/node_webtt.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -declare module "node-webvtt" { - interface Cue { - identifier: string; - start: number; - end: number; - text: string; - styles: string; - } - interface Options { - meta?: boolean; - strict?: boolean; - } - type ParserError = Error; - interface ParseResult { - valid: boolean; - strict: boolean; - cues: Cue[]; - errors: ParserError[]; - meta?: Map; - } - interface Segment { - duration: number; - cues: Cue[]; - } - function parse(text: string, options: Options): ParseResult; - function segment(input: string, segmentLength?: number): Segment[]; -} diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts index 4bd11052..83edaa84 100644 --- a/src/backend/helpers/captions.ts +++ b/src/backend/helpers/captions.ts @@ -1,48 +1,24 @@ import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; -import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; -import toWebVTT from "srt-webvtt"; +import { MWCaption } from "@/backend/helpers/streams"; import DOMPurify from "dompurify"; +import { parse, detect, list } from "subsrt-ts"; +import { ContentCaption } from "subsrt-ts/dist/types/handler"; +export const subtitleTypeList = list().map((type) => `.${type}`); export const sanitize = DOMPurify.sanitize; -export const CUSTOM_CAPTION_ID = "customCaption"; export async function getCaptionUrl(caption: MWCaption): Promise { - if (caption.type === MWCaptionType.SRT) { - let captionBlob: Blob; - - if (caption.needsProxy) { - captionBlob = await proxiedFetch(caption.url, { - responseType: "blob" as any, - }); - } else { - captionBlob = await mwFetch(caption.url, { - responseType: "blob" as any, - }); - } - - return toWebVTT(captionBlob); + if (caption.url.startsWith("blob:")) return caption.url; + let captionBlob: Blob; + if (caption.needsProxy) { + captionBlob = await proxiedFetch(caption.url, { + responseType: "blob" as any, + }); + } else { + captionBlob = await mwFetch(caption.url, { + responseType: "blob" as any, + }); } - - if (caption.type === MWCaptionType.VTT) { - if (caption.needsProxy) { - const blob = await proxiedFetch(caption.url, { - responseType: "blob" as any, - }); - return URL.createObjectURL(blob); - } - - return caption.url; - } - - throw new Error("invalid type"); -} - -export async function convertCustomCaptionFileToWebVTT(file: File) { - const header = await file.slice(0, 6).text(); - const isWebVTT = header === "WEBVTT"; - if (!isWebVTT) { - return toWebVTT(file); - } - return URL.createObjectURL(file); + return URL.createObjectURL(captionBlob); } export function revokeCaptionBlob(url: string | undefined) { @@ -50,3 +26,12 @@ export function revokeCaptionBlob(url: string | undefined) { URL.revokeObjectURL(url); } } + +export function parseSubtitles(text: string): ContentCaption[] { + if (detect(text) === "") { + throw new Error("Invalid subtitle format"); + } + return parse(text).filter( + (cue) => cue.type === "caption" + ) as ContentCaption[]; +} diff --git a/src/backend/helpers/streams.ts b/src/backend/helpers/streams.ts index 27e9fddb..12cbc551 100644 --- a/src/backend/helpers/streams.ts +++ b/src/backend/helpers/streams.ts @@ -6,6 +6,7 @@ export enum MWStreamType { export enum MWCaptionType { VTT = "vtt", SRT = "srt", + UNKNOWN = "unknown", } export enum MWStreamQuality { diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts index c2a1b84b..d9440213 100644 --- a/src/backend/providers/flixhq.ts +++ b/src/backend/providers/flixhq.ts @@ -10,12 +10,13 @@ import { MWMediaType } from "../metadata/types"; const flixHqBase = "https://api.consumet.org/meta/tmdb"; +type FlixHQMediaType = "Movie" | "TV Series"; interface FLIXMediaBase { id: number; title: string; url: string; image: string; - type: "Movie" | "TV Series"; + type: FlixHQMediaType; releaseDate: string; } @@ -38,9 +39,9 @@ const qualityMap: Record = { "1080": MWStreamQuality.Q1080P, }; -enum FlixHQMediaType { - MOVIE = "movie", - SERIES = "series", +function flixTypeToMWType(type: FlixHQMediaType) { + if (type === "Movie") return MWMediaType.MOVIE; + return MWMediaType.SERIES; } registerProvider({ @@ -48,7 +49,6 @@ registerProvider({ displayName: "FlixHQ", rank: 100, type: [MWMediaType.MOVIE, MWMediaType.SERIES], - async scrape({ media, episode, progress }) { if (!this.type.includes(media.meta.type)) { throw new Error("Unsupported type"); @@ -65,9 +65,11 @@ registerProvider({ if (v.type !== "Movie" && v.type !== "TV Series") return false; return ( compareTitle(v.title, media.meta.title) && + flixTypeToMWType(v.type) === media.meta.type && v.releaseDate === media.meta.year ); }); + if (!foundItem) throw new Error("No watchable item found"); // get media info @@ -75,15 +77,12 @@ registerProvider({ const mediaInfo = await proxiedFetch(`/info/${foundItem.id}`, { baseURL: flixHqBase, params: { - type: - media.meta.type === MWMediaType.MOVIE - ? FlixHQMediaType.MOVIE - : FlixHQMediaType.SERIES, + type: flixTypeToMWType(foundItem.type), }, }); if (!mediaInfo.id) throw new Error("No watchable item found"); // get stream info from media - progress(75); + progress(50); let episodeId: string | undefined; if (media.meta.type === MWMediaType.MOVIE) { @@ -98,7 +97,7 @@ registerProvider({ episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id; } if (!episodeId) throw new Error("No watchable item found"); - + progress(75); const watchInfo = await proxiedFetch(`/watch/${episodeId}`, { baseURL: flixHqBase, params: { diff --git a/src/backend/providers/netfilm.ts b/src/backend/providers/netfilm.ts index 23a8cf90..f7efcfbe 100644 --- a/src/backend/providers/netfilm.ts +++ b/src/backend/providers/netfilm.ts @@ -22,6 +22,7 @@ registerProvider({ displayName: "NetFilm", rank: 15, type: [MWMediaType.MOVIE, MWMediaType.SERIES], + disabled: true, // The creator has asked us (very nicely) to leave him alone. Until (if) we self-host, netfilm should remain disabled async scrape({ media, episode, progress }) { if (!this.type.includes(media.meta.type)) { diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts index 8abed467..3c499739 100644 --- a/src/backend/providers/superstream/index.ts +++ b/src/backend/providers/superstream/index.ts @@ -225,15 +225,21 @@ registerProvider({ const subtitleRes = (await get(subtitleApiQuery)).data; - const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => { - return { - needsProxy: true, - langIso: subtitle.language, - url: subtitle.subtitles[0].file_path, - type: MWCaptionType.SRT, - }; - }); - + const mappedCaptions = subtitleRes.list.map( + (subtitle: any): MWCaption | null => { + const sub = subtitle; + sub.subtitles = subtitle.subtitles.filter((subFile: any) => { + const extension = subFile.file_path.slice(-3); + return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension); + }); + return { + needsProxy: true, + langIso: subtitle.language, + url: sub.subtitles[0].file_path, + type: MWCaptionType.SRT, + }; + } + ); return { embeds: [], stream: { diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 96a7e430..af71ab4e 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -40,6 +40,7 @@ export enum Icons { WATCH_PARTY = "watch_party", PICTURE_IN_PICTURE = "pictureInPicture", CHECKMARK = "checkmark", + TACHOMETER = "tachometer", } export interface IconProps { @@ -87,6 +88,7 @@ const iconList: Record = { watch_party: ``, pictureInPicture: ``, checkmark: ``, + tachometer: ``, }; function ChromeCastButton() { diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx new file mode 100644 index 00000000..39b63e6e --- /dev/null +++ b/src/components/Slider.tsx @@ -0,0 +1,47 @@ +import { ChangeEventHandler, useEffect, useRef } from "react"; + +export type SliderProps = { + label?: string; + min: number; + max: number; + step: number; + value?: number; + valueDisplay?: string; + onChange: ChangeEventHandler; +}; + +export function Slider(props: SliderProps) { + const ref = useRef(null); + useEffect(() => { + const e = ref.current as HTMLInputElement; + e.style.setProperty("--value", e.value); + e.style.setProperty("--min", e.min === "" ? "0" : e.min); + e.style.setProperty("--max", e.max === "" ? "100" : e.max); + e.addEventListener("input", () => e.style.setProperty("--value", e.value)); + }, [ref]); + + return ( +
+
+ {props.label ? ( + + ) : null} + +
+
+
+ {props.valueDisplay ?? props.value} +
+
+
+ ); +} diff --git a/src/components/popout/FloatingView.tsx b/src/components/popout/FloatingView.tsx index 9ae797ee..4c21f136 100644 --- a/src/components/popout/FloatingView.tsx +++ b/src/components/popout/FloatingView.tsx @@ -29,6 +29,7 @@ export function FloatingView(props: Props) { data-floating-page={props.show ? "true" : undefined} style={{ height: props.height ? `${props.height}px` : undefined, + maxHeight: "70vh", width: props.width ? width : undefined, }} > diff --git a/src/components/popout/positions/FloatingCardMobilePosition.tsx b/src/components/popout/positions/FloatingCardMobilePosition.tsx index 059f6667..dece3ccc 100644 --- a/src/components/popout/positions/FloatingCardMobilePosition.tsx +++ b/src/components/popout/positions/FloatingCardMobilePosition.tsx @@ -21,8 +21,20 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) { })); const bind = useDrag( - ({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => { + ({ + last, + velocity: [, vy], + direction: [, dy], + movement: [, my], + ...event + }) => { if (closing.current) return; + + const isInScrollable = (event.target as HTMLDivElement).closest( + ".overflow-y-auto" + ); + if (isInScrollable) return; // Don't attempt to swipe the thing away if it's a scroll area unless the scroll area is at the top and the user is swiping down + const height = cardRect?.height ?? 0; if (last) { // if past half height downwards @@ -69,7 +81,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) { return (
params.type === v) || MWMediaType.MOVIE; - const searchQuery = params.query || ""; + const searchQuery = decodeURIComponent(params.query || ""); return { type, searchQuery }; } diff --git a/src/setup/App.tsx b/src/setup/App.tsx index f2c63d91..6aef8905 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -1,3 +1,4 @@ +import { lazy } from "react"; import { Redirect, Route, Switch } from "react-router-dom"; import { BookmarkContextProvider } from "@/state/bookmark"; import { WatchedContextProvider } from "@/state/watched"; @@ -8,13 +9,8 @@ import { MediaView } from "@/views/media/MediaView"; import { SearchView } from "@/views/search/SearchView"; import { MWMediaType } from "@/backend/metadata/types"; import { V2MigrationView } from "@/views/other/v2Migration"; -import { DeveloperView } from "@/views/developer/DeveloperView"; -import { VideoTesterView } from "@/views/developer/VideoTesterView"; -import { ProviderTesterView } from "@/views/developer/ProviderTesterView"; -import { EmbedTesterView } from "@/views/developer/EmbedTesterView"; import { BannerContextProvider } from "@/hooks/useBanner"; import { Layout } from "@/setup/Layout"; -import { TestView } from "@/views/developer/TestView"; function App() { return ( @@ -44,15 +40,45 @@ function App() { /> {/* other */} - - - - - + {process.env.NODE_ENV === "development" ? ( + <> + import("@/views/developer/DeveloperView") + )} + /> + import("@/views/developer/TestView") + )} + /> + import("@/views/developer/VideoTesterView") + )} + /> + import("@/views/developer/ProviderTesterView") + )} + /> + import("@/views/developer/EmbedTesterView") + )} + /> + + ) : null} diff --git a/src/setup/index.css b/src/setup/index.css index e699aa1b..af81851d 100644 --- a/src/setup/index.css +++ b/src/setup/index.css @@ -38,6 +38,7 @@ body[data-no-select] { from { transform: rotate(0deg); } + to { transform: rotate(360deg); } @@ -55,6 +56,10 @@ body[data-no-select] { @apply brightness-[500]; } +.is-mobile-view .overflow-y-auto { + height: 60vh; +} + /*generated with Input range slider CSS style generator (version 20211225) https://toughengineer.github.io/demo/slider-styler*/ :root { @@ -62,6 +67,7 @@ https://toughengineer.github.io/demo/slider-styler*/ --slider-border-radius: 1em; --slider-progress-background: #8652bb; } + input[type=range].styled-slider { height: var(--slider-height); -webkit-appearance: none; @@ -101,7 +107,7 @@ input[type=range].styled-slider::-webkit-slider-thumb:hover { } input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track { - background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B; + background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B; } /*mozilla*/ @@ -127,7 +133,7 @@ input[type=range].styled-slider::-moz-range-thumb:hover { } input[type=range].styled-slider.slider-progress::-moz-range-track { - background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B; + background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B; } /*ms*/ @@ -172,4 +178,4 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower { background: var(--slider-progress-background); border: none; border-right-width: 0; -} +} \ No newline at end of file diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json index d1408148..ad479bb4 100644 --- a/src/setup/locales/en/translation.json +++ b/src/setup/locales/en/translation.json @@ -63,12 +63,15 @@ "captions": "Captions", "download": "Download", "settings": "Settings", - "pictureInPicture": "Picture in Picture" + "pictureInPicture": "Picture in Picture", + "playbackSpeed": "Playback speed" }, "popouts": { "sources": "Sources", "seasons": "Seasons", "captions": "Captions", + "playbackSpeed": "Playback speed", + "customPlaybackSpeed": "Custom playback speed", "captionPreferences": { "title": "Customize", "delay": "Delay", @@ -80,8 +83,9 @@ "noCaptions": "No captions", "linkedCaptions": "Linked captions", "customCaption": "Custom caption", - "uploadCustomCaption": "Upload caption (SRT, VTT)", + "uploadCustomCaption": "Upload caption", "noEmbeds": "No embeds were found for this source", + "errors": { "loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}", "embedsError": "Something went wrong loading the embeds for this thing that you like" @@ -92,7 +96,8 @@ "seasons": "Choose which season you want to watch", "episode": "Pick an episode", "captions": "Choose a subtitle language", - "captionPreferences": "Make subtitles look how you want it" + "captionPreferences": "Make subtitles look how you want it", + "playbackSpeed": "Change the playback speed" } }, "errors": { diff --git a/src/setup/locales/fr/translation.json b/src/setup/locales/fr/translation.json index de40a796..e5c669ce 100644 --- a/src/setup/locales/fr/translation.json +++ b/src/setup/locales/fr/translation.json @@ -62,7 +62,7 @@ "noCaptions": "Pas de sous-titres", "linkedCaptions": "Sous-titres liés", "customCaption": "Sous-titres personnalisés", - "uploadCustomCaption": "Télécharger des sous-titres (SRT, VTT)", + "uploadCustomCaption": "Télécharger des sous-titres", "noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source", "errors": { "loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}", diff --git a/src/video/components/actions/CaptionRendererAction.tsx b/src/video/components/actions/CaptionRendererAction.tsx index 6abc18c9..ab7edba0 100644 --- a/src/video/components/actions/CaptionRendererAction.tsx +++ b/src/video/components/actions/CaptionRendererAction.tsx @@ -1,7 +1,7 @@ import { Transition } from "@/components/Transition"; import { useSettings } from "@/state/settings"; -import { sanitize } from "@/backend/helpers/captions"; -import { parse, Cue } from "node-webvtt"; +import { sanitize, parseSubtitles } from "@/backend/helpers/captions"; +import { ContentCaption } from "subsrt-ts/dist/types/handler"; import { useRef } from "react"; import { useAsync } from "react-use"; import { useVideoPlayerDescriptor } from "../../state/hooks"; @@ -48,16 +48,18 @@ export function CaptionRendererAction({ const source = useSource(descriptor).source; const videoTime = useProgress(descriptor).time; const { captionSettings } = useSettings(); - const captions = useRef([]); + const captions = useRef([]); useAsync(async () => { - const url = source?.caption?.url; - if (url) { - // Is there a better way? - const result = await fetch(url); - // Uses UTF-8 by default + const blobUrl = source?.caption?.url; + if (blobUrl) { + const result = await fetch(blobUrl); const text = await result.text(); - captions.current = parse(text, { strict: false }).cues; + try { + captions.current = parseSubtitles(text); + } catch (error) { + captions.current = []; + } } else { captions.current = []; } @@ -65,8 +67,8 @@ export function CaptionRendererAction({ if (!captions.current.length) return null; const isVisible = (start: number, end: number): boolean => { - const delayedStart = start + captionSettings.delay; - const delayedEnd = end + captionSettings.delay; + const delayedStart = start / 1000 + captionSettings.delay; + const delayedEnd = end / 1000 + captionSettings.delay; return ( Math.max(0, delayedStart) <= videoTime && Math.max(0, delayedEnd) >= videoTime @@ -82,9 +84,9 @@ export function CaptionRendererAction({ show > {captions.current.map( - ({ identifier, end, start, text }) => + ({ start, end, content }) => isVisible(start, end) && ( - + ) )} diff --git a/src/video/components/actions/KeyboardShortcutsAction.tsx b/src/video/components/actions/KeyboardShortcutsAction.tsx index cab92baa..24e8b813 100644 --- a/src/video/components/actions/KeyboardShortcutsAction.tsx +++ b/src/video/components/actions/KeyboardShortcutsAction.tsx @@ -63,6 +63,16 @@ export function KeyboardShortcutsAction() { toggleVolume(); break; + // Decrease volume + case "arrowdown": + controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0)); + break; + + // Increase volume + case "arrowup": + controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1)); + break; + // Do a barrel Roll! case "r": if (isRolling || evt.ctrlKey || evt.metaKey) return; diff --git a/src/video/components/actions/list-entries/PlaybackSpeedSelectionAction.tsx b/src/video/components/actions/list-entries/PlaybackSpeedSelectionAction.tsx new file mode 100644 index 00000000..983f345e --- /dev/null +++ b/src/video/components/actions/list-entries/PlaybackSpeedSelectionAction.tsx @@ -0,0 +1,17 @@ +import { Icons } from "@/components/Icon"; +import { useTranslation } from "react-i18next"; +import { PopoutListAction } from "../../popouts/PopoutUtils"; + +interface Props { + onClick: () => any; +} + +export function PlaybackSpeedSelectionAction(props: Props) { + const { t } = useTranslation(); + + return ( + + {t("videoPlayer.buttons.playbackSpeed")} + + ); +} diff --git a/src/video/components/parts/VideoErrorBoundary.tsx b/src/video/components/parts/VideoErrorBoundary.tsx index 58e7c13d..5c7cf291 100644 --- a/src/video/components/parts/VideoErrorBoundary.tsx +++ b/src/video/components/parts/VideoErrorBoundary.tsx @@ -3,8 +3,8 @@ import { ErrorMessage } from "@/components/layout/ErrorBoundary"; import { Link } from "@/components/text/Link"; import { conf } from "@/setup/config"; import { Component } from "react"; -import type { ReactNode } from "react-router-dom/node_modules/@types/react/index"; import { Trans } from "react-i18next"; +import type { ReactNode } from "react-router-dom/node_modules/@types/react/index"; import { VideoPlayerHeader } from "./VideoPlayerHeader"; interface ErrorBoundaryState { diff --git a/src/video/components/popouts/CaptionSelectionPopout.tsx b/src/video/components/popouts/CaptionSelectionPopout.tsx index 9caaca96..2ae9fced 100644 --- a/src/video/components/popouts/CaptionSelectionPopout.tsx +++ b/src/video/components/popouts/CaptionSelectionPopout.tsx @@ -1,9 +1,9 @@ import { getCaptionUrl, - convertCustomCaptionFileToWebVTT, - CUSTOM_CAPTION_ID, + parseSubtitles, + subtitleTypeList, } from "@/backend/helpers/captions"; -import { MWCaption } from "@/backend/helpers/streams"; +import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; import { Icon, Icons } from "@/components/Icon"; import { FloatingCardView } from "@/components/popout/FloatingCard"; import { FloatingView } from "@/components/popout/FloatingView"; @@ -13,10 +13,11 @@ 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 { ChangeEvent, useMemo, useRef } from "react"; +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}`; } @@ -41,35 +42,20 @@ export function CaptionSelectionPopout(props: { async (caption: MWCaption, isLinked: boolean) => { const id = makeCaptionId(caption, isLinked); loadingId.current = id; - controls.setCaption(id, await getCaptionUrl(caption)); - controls.closePopout(); + const blobUrl = await getCaptionUrl(caption); + const result = await fetch(blobUrl); + const text = await result.text(); + parseSubtitles(text); // This will throw if the file is invalid + controls.setCaption(id, blobUrl); + // sometimes this doesn't work, so we add a small delay + setTimeout(() => { + controls.closePopout(); + }, 100); } ); const currentCaption = source.source?.caption?.id; const customCaptionUploadElement = useRef(null); - const [setCustomCaption, loadingCustomCaption, errorCustomCaption] = - useLoading(async (captionFile: File) => { - if ( - !captionFile.name.endsWith(".srt") && - !captionFile.name.endsWith(".vtt") - ) { - throw new Error("Only SRT or VTT files are allowed"); - } - controls.setCaption( - CUSTOM_CAPTION_ID, - await convertCustomCaptionFileToWebVTT(captionFile) - ); - controls.closePopout(); - }); - - async function handleUploadCaption(e: ChangeEvent) { - if (!e.target.files) { - return; - } - const captionFile = e.target.files[0]; - setCustomCaption(captionFile); - } return ( { - customCaptionUploadElement.current?.click(); - }} + key={customCaption} + active={currentCaption === customCaption} + loading={loading && loadingId.current === customCaption} + errored={error && loadingId.current === customCaption} + onClick={() => customCaptionUploadElement.current?.click()} > - {currentCaption === CUSTOM_CAPTION_ID + {currentCaption === customCaption ? t("videoPlayer.popouts.customCaption") : t("videoPlayer.popouts.uploadCustomCaption")} { + if (!e.target.files) return; + const customSubtitle = { + langIso: "custom", + url: URL.createObjectURL(e.target.files[0]), + type: MWCaptionType.UNKNOWN, + }; + setCaption(customSubtitle, false); + }} /> diff --git a/src/video/components/popouts/CaptionSettingsPopout.tsx b/src/video/components/popouts/CaptionSettingsPopout.tsx index 09bf6eea..35bf8010 100644 --- a/src/video/components/popouts/CaptionSettingsPopout.tsx +++ b/src/video/components/popouts/CaptionSettingsPopout.tsx @@ -3,52 +3,9 @@ import { FloatingView } from "@/components/popout/FloatingView"; import { useFloatingRouter } from "@/hooks/useFloatingRouter"; import { useSettings } from "@/state/settings"; import { useTranslation } from "react-i18next"; -import { ChangeEventHandler, useEffect, useRef } from "react"; + import { Icon, Icons } from "@/components/Icon"; - -export type SliderProps = { - label: string; - min: number; - max: number; - step: number; - value: number; - valueDisplay?: string; - onChange: ChangeEventHandler; -}; - -export function Slider(props: SliderProps) { - const ref = useRef(null); - useEffect(() => { - const e = ref.current as HTMLInputElement; - e.style.setProperty("--value", e.value); - e.style.setProperty("--min", e.min === "" ? "0" : e.min); - e.style.setProperty("--max", e.max === "" ? "100" : e.max); - e.addEventListener("input", () => e.style.setProperty("--value", e.value)); - }, [ref]); - - return ( -
-
- - -
-
-
- {props.valueDisplay ?? props.value} -
-
-
- ); -} +import { Slider } from "@/components/Slider"; export function CaptionSettingsPopout(props: { router: ReturnType; @@ -73,7 +30,7 @@ export function CaptionSettingsPopout(props: { /> setCaptionFontSize(e.target.valueAsNumber)} /> ; + prefix: string; +}) { + const { t } = useTranslation(); + + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + const mediaPlaying = useMediaPlaying(descriptor); + + return ( + + props.router.navigate("/")} + /> + + + {speedSelectionOptions.map((speed) => ( + { + controls.setPlaybackSpeed(speed); + controls.closePopout(); + }} + > + {speed}x + + ))} + + +

+ + {t("videoPlayer.popouts.customPlaybackSpeed")} +

+ + +
+ + controls.setPlaybackSpeed(e.target.valueAsNumber) + } + /> +
+
+
+
+ ); +} diff --git a/src/video/components/popouts/SettingsPopout.tsx b/src/video/components/popouts/SettingsPopout.tsx index 20e6736f..9c92575e 100644 --- a/src/video/components/popouts/SettingsPopout.tsx +++ b/src/video/components/popouts/SettingsPopout.tsx @@ -5,9 +5,11 @@ import { useFloatingRouter } from "@/hooks/useFloatingRouter"; import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction"; import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction"; import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction"; +import { PlaybackSpeedSelectionAction } from "@/video/components/actions/list-entries/PlaybackSpeedSelectionAction"; import { CaptionSelectionPopout } from "./CaptionSelectionPopout"; import { SourceSelectionPopout } from "./SourceSelectionPopout"; import { CaptionSettingsPopout } from "./CaptionSettingsPopout"; +import { PlaybackSpeedPopout } from "./PlaybackSpeedPopout"; export function SettingsPopout() { const floatingRouter = useFloatingRouter(); @@ -21,6 +23,9 @@ export function SettingsPopout() { navigate("/source")} /> navigate("/captions")} /> + navigate("/playback-speed")} + />
@@ -29,6 +34,7 @@ export function SettingsPopout() { router={floatingRouter} prefix="caption-settings" /> + ); } diff --git a/src/video/state/init.ts b/src/video/state/init.ts index 13118a1d..bd4037fe 100644 --- a/src/video/state/init.ts +++ b/src/video/state/init.ts @@ -13,6 +13,7 @@ export function resetForSource(s: VideoPlayerState) { isFirstLoading: true, hasPlayedOnce: false, volume: state.mediaPlaying.volume, // volume settings needs to persist through resets + playbackSpeed: 1, }; state.progress = { time: 0, @@ -42,6 +43,7 @@ function initPlayer(): VideoPlayerState { isFirstLoading: true, hasPlayedOnce: false, volume: 0, + playbackSpeed: 1, }, progress: { diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts index f21ec05a..e6d33369 100644 --- a/src/video/state/logic/controls.ts +++ b/src/video/state/logic/controls.ts @@ -14,6 +14,7 @@ export type ControlMethods = { setCurrentEpisode(sId: string, eId: string): void; setDraggingTime(num: number): void; togglePictureInPicture(): void; + setPlaybackSpeed(num: number): void; }; export function useControls( @@ -105,5 +106,9 @@ export function useControls( state.stateProvider?.togglePictureInPicture(); updateInterface(descriptor, state); }, + setPlaybackSpeed(num) { + state.stateProvider?.setPlaybackSpeed(num); + updateInterface(descriptor, state); + }, }; } diff --git a/src/video/state/logic/mediaplaying.ts b/src/video/state/logic/mediaplaying.ts index ebe89875..ff631064 100644 --- a/src/video/state/logic/mediaplaying.ts +++ b/src/video/state/logic/mediaplaying.ts @@ -12,6 +12,7 @@ export type VideoMediaPlayingEvent = { hasPlayedOnce: boolean; isFirstLoading: boolean; volume: number; + playbackSpeed: number; }; function getMediaPlayingFromState( @@ -26,6 +27,7 @@ function getMediaPlayingFromState( isDragSeeking: state.mediaPlaying.isDragSeeking, isFirstLoading: state.mediaPlaying.isFirstLoading, volume: state.mediaPlaying.volume, + playbackSpeed: state.mediaPlaying.playbackSpeed, }; } diff --git a/src/video/state/providers/castingStateProvider.ts b/src/video/state/providers/castingStateProvider.ts index 5f9490ce..faf34dc5 100644 --- a/src/video/state/providers/castingStateProvider.ts +++ b/src/video/state/providers/castingStateProvider.ts @@ -87,6 +87,23 @@ export function createCastingStateProvider( togglePictureInPicture() { // no picture in picture while casting }, + setPlaybackSpeed(num) { + const mediaInfo = new chrome.cast.media.MediaInfo( + state.meta?.meta.meta.id ?? "video", + "video/mp4" + ); + (mediaInfo as any).contentUrl = state.source?.url; + mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; + mediaInfo.metadata = new chrome.cast.media.MovieMediaMetadata(); + mediaInfo.metadata.title = state.meta?.meta.meta.title ?? ""; + mediaInfo.customData = { + playbackRate: num, + }; + const request = new chrome.cast.media.LoadRequest(mediaInfo); + request.autoplay = true; + const session = ins?.getCurrentSession(); + session?.loadMedia(request); + }, async setVolume(v) { // clamp time between 0 and 1 let volume = Math.min(v, 1); @@ -114,7 +131,7 @@ export function createCastingStateProvider( movieMeta.title = state.meta?.meta.meta.title ?? ""; const mediaInfo = new chrome.cast.media.MediaInfo( - state.meta?.meta.meta.id ?? "hello", + state.meta?.meta.meta.id ?? "video", "video/mp4" ); (mediaInfo as any).contentUrl = source?.source; diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts index 3a01b145..ad09e812 100644 --- a/src/video/state/providers/providerTypes.ts +++ b/src/video/state/providers/providerTypes.ts @@ -22,6 +22,7 @@ export type VideoPlayerStateController = { clearCaption(): void; getId(): string; togglePictureInPicture(): void; + setPlaybackSpeed(num: number): void; }; export type VideoPlayerStateProvider = VideoPlayerStateController & { diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index 4b085133..e527419b 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -228,6 +228,11 @@ export function createVideoStateProvider( } } }, + setPlaybackSpeed(num) { + player.playbackRate = num; + state.mediaPlaying.playbackSpeed = num; + updateMediaPlaying(descriptor, state); + }, providerStart() { this.setVolume(getStoredVolume()); @@ -276,8 +281,14 @@ export function createVideoStateProvider( state.mediaPlaying.isLoading = false; updateMediaPlaying(descriptor, state); }; + const ratechange = () => { + state.mediaPlaying.playbackSpeed = player.playbackRate; + updateMediaPlaying(descriptor, state); + }; const fullscreenchange = () => { - state.interface.isFullscreen = !!document.fullscreenElement; + state.interface.isFullscreen = + !!document.fullscreenElement || // other browsers + !!(document as any).webkitFullscreenElement; // safari updateInterface(descriptor, state); }; const volumechange = async () => { @@ -324,6 +335,7 @@ export function createVideoStateProvider( player.addEventListener("timeupdate", timeupdate); player.addEventListener("loadedmetadata", loadedmetadata); player.addEventListener("canplay", canplay); + player.addEventListener("ratechange", ratechange); fscreen.addEventListener("fullscreenchange", fullscreenchange); player.addEventListener("error", error); player.addEventListener( diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 8b27c25e..1ba9ef7a 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -42,6 +42,7 @@ export type VideoPlayerState = { isFirstLoading: boolean; // first buffering of the video, when set to false the video can start playing hasPlayedOnce: boolean; // has the video played at all? volume: number; + playbackSpeed: number; }; // state related to video progress diff --git a/src/views/developer/DeveloperView.tsx b/src/views/developer/DeveloperView.tsx index c671786f..6168758b 100644 --- a/src/views/developer/DeveloperView.tsx +++ b/src/views/developer/DeveloperView.tsx @@ -3,7 +3,7 @@ import { ThinContainer } from "@/components/layout/ThinContainer"; import { ArrowLink } from "@/components/text/ArrowLink"; import { Title } from "@/components/text/Title"; -export function DeveloperView() { +export default function DeveloperView() { return (
diff --git a/src/views/developer/EmbedTesterView.tsx b/src/views/developer/EmbedTesterView.tsx index dd4cef10..9b659bb7 100644 --- a/src/views/developer/EmbedTesterView.tsx +++ b/src/views/developer/EmbedTesterView.tsx @@ -105,7 +105,7 @@ function EmbedScraperSelector(props: EmbedScraperSelectorProps) { ); } -export function EmbedTesterView() { +export default function EmbedTesterView() { const [embed, setEmbed] = useState(null); const [embedScraperId, setEmbedScraperId] = useState(null); const embedScraper = useMemo( diff --git a/src/views/developer/ProviderTesterView.tsx b/src/views/developer/ProviderTesterView.tsx index 7883428a..0862b2b8 100644 --- a/src/views/developer/ProviderTesterView.tsx +++ b/src/views/developer/ProviderTesterView.tsx @@ -96,7 +96,7 @@ function ProviderSelector(props: ProviderSelectorProps) { ); } -export function ProviderTesterView() { +export default function ProviderTesterView() { const [media, setMedia] = useState(null); const [providerId, setProviderId] = useState(null); diff --git a/src/views/developer/TestView.tsx b/src/views/developer/TestView.tsx index 8dc1ccdd..2b9e3817 100644 --- a/src/views/developer/TestView.tsx +++ b/src/views/developer/TestView.tsx @@ -1,4 +1,4 @@ // simple empty view, perfect for putting in tests -export function TestView() { +export default function TestView() { return
; } diff --git a/src/views/developer/VideoTesterView.tsx b/src/views/developer/VideoTesterView.tsx index 7681af6e..7e3a409f 100644 --- a/src/views/developer/VideoTesterView.tsx +++ b/src/views/developer/VideoTesterView.tsx @@ -33,7 +33,7 @@ const testMeta: DetailedMeta = { }, }; -export function VideoTesterView() { +export default function VideoTesterView() { const [video, setVideo] = useState(null); const [videoType, setVideoType] = useState(MWStreamType.MP4); const [url, setUrl] = useState(""); @@ -64,8 +64,8 @@ export function VideoTesterView() { />
diff --git a/tsconfig.json b/tsconfig.json index a00c1a1f..e1004c43 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,6 @@ "noEmit": true, "jsx": "react-jsx", "baseUrl": "./src", - "typeRoots": ["./src/@types"], "paths": { "@/*": ["./*"] }, diff --git a/yarn.lock b/yarn.lock index 1c29c291..48e68bc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2123,11 +2123,6 @@ commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commander@^8.0.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" @@ -3854,13 +3849,6 @@ node-releases@^2.0.8: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w== -node-webvtt@^1.9.4: - version "1.9.4" - resolved "https://registry.yarnpkg.com/node-webvtt/-/node-webvtt-1.9.4.tgz#b71b98f879c6c88ebeda40c358bd45a882ca5d89" - integrity sha512-EjrJdKdxSyd8j4LMLW6s2Ah4yNoeVXp18Ob04CQl1In18xcUmKzEE8pcsxxnFVqanTyjbGYph2VnvtwIXR4EjA== - dependencies: - commander "^7.1.0" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -4699,11 +4687,6 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -srt-webvtt@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/srt-webvtt/-/srt-webvtt-2.0.0.tgz#debd2f56dd2b6600894caa11bb78893e5fc6509b" - integrity sha512-G2Z7/Jf2NRKrmLYNSIhSYZZYE6OFlKXFp9Au2/zJBKgrioUzmrAys1x7GT01dwl6d2sEnqr5uahEIOd0JW/Rbw== - stack-generator@^2.0.5: version "2.0.10" resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.10.tgz#8ae171e985ed62287d4f1ed55a1633b3fb53bb4d" @@ -4859,6 +4842,11 @@ subscribe-ui-event@^2.0.6: lodash "^4.17.15" raf "^3.0.0" +subsrt-ts@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/subsrt-ts/-/subsrt-ts-2.1.0.tgz#97b5e0f97800fb08b64465b53c7c4f14f43d6fd4" + integrity sha512-LOdp6A91l/yPLPFuEaYvGzFDusUz0J52ksZjaCFdl347DOhedZOVQEciTaH7KaVDRlb7wstOx4dPFdjf9AyuFw== + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"