mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-26 15:51:51 +01:00
commit
871780f95e
@ -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": {
|
||||
|
27
src/@types/node_webtt.d.ts
vendored
27
src/@types/node_webtt.d.ts
vendored
@ -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<string, string>;
|
||||
}
|
||||
interface Segment {
|
||||
duration: number;
|
||||
cues: Cue[];
|
||||
}
|
||||
function parse(text: string, options: Options): ParseResult;
|
||||
function segment(input: string, segmentLength?: number): Segment[];
|
||||
}
|
@ -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<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.url.startsWith("blob:")) return caption.url;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ export enum MWStreamType {
|
||||
export enum MWCaptionType {
|
||||
VTT = "vtt",
|
||||
SRT = "srt",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export enum MWStreamQuality {
|
||||
|
@ -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<string, MWStreamQuality> = {
|
||||
"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<any>(`/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<any>(`/watch/${episodeId}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
|
@ -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)) {
|
||||
|
@ -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: {
|
||||
|
@ -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<Icons, string> = {
|
||||
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
|
||||
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
|
||||
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
|
||||
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
47
src/components/Slider.tsx
Normal file
47
src/components/Slider.tsx
Normal file
@ -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<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const ref = useRef<HTMLInputElement>(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 (
|
||||
<div className="mb-6 flex flex-row gap-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{props.label ? (
|
||||
<label className="font-bold">{props.label}</label>
|
||||
) : null}
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
className="styled-slider slider-progress mt-[20px]"
|
||||
onChange={props.onChange}
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
|
||||
<div className="text-center font-bold text-white">
|
||||
{props.valueDisplay ?? props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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,
|
||||
}}
|
||||
>
|
||||
|
@ -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 (
|
||||
<div
|
||||
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
className="is-mobile-view absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
window.innerHeight - (cardRect?.height ?? 0) + 200
|
||||
|
@ -6,7 +6,7 @@ function getInitialValue(params: { type: string; query: string }) {
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = params.query || "";
|
||||
const searchQuery = decodeURIComponent(params.query || "");
|
||||
return { type, searchQuery };
|
||||
}
|
||||
|
||||
|
@ -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 */}
|
||||
<Route exact path="/dev" component={DeveloperView} />
|
||||
<Route exact path="/dev/test" component={TestView} />
|
||||
<Route exact path="/dev/video" component={VideoTesterView} />
|
||||
<Route
|
||||
exact
|
||||
path="/dev/providers"
|
||||
component={ProviderTesterView}
|
||||
/>
|
||||
<Route exact path="/dev/embeds" component={EmbedTesterView} />
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<>
|
||||
<Route
|
||||
exact
|
||||
path="/dev"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/DeveloperView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/test"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/TestView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/video"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/VideoTesterView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/providers"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/ProviderTesterView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/embeds"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/EmbedTesterView")
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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": {
|
||||
|
@ -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}}",
|
||||
|
@ -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<Cue[]>([]);
|
||||
const captions = useRef<ContentCaption[]>([]);
|
||||
|
||||
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) && (
|
||||
<CaptionCue key={identifier || `${start}-${end}`} text={text} />
|
||||
<CaptionCue key={`${start}-${end}`} text={content} />
|
||||
)
|
||||
)}
|
||||
</Transition>
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
<PopoutListAction icon={Icons.TACHOMETER} onClick={props.onClick}>
|
||||
{t("videoPlayer.buttons.playbackSpeed")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
@ -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 {
|
||||
|
@ -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<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
if (!e.target.files) {
|
||||
return;
|
||||
}
|
||||
const captionFile = e.target.files[0];
|
||||
setCustomCaption(captionFile);
|
||||
}
|
||||
return (
|
||||
<FloatingView
|
||||
{...props.router.pageProps(props.prefix)}
|
||||
@ -105,23 +91,29 @@ export function CaptionSelectionPopout(props: {
|
||||
{t("videoPlayer.popouts.noCaptions")}
|
||||
</PopoutListEntry>
|
||||
<PopoutListEntry
|
||||
key={CUSTOM_CAPTION_ID}
|
||||
active={currentCaption === CUSTOM_CAPTION_ID}
|
||||
loading={loadingCustomCaption}
|
||||
errored={!!errorCustomCaption}
|
||||
onClick={() => {
|
||||
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")}
|
||||
<input
|
||||
ref={customCaptionUploadElement}
|
||||
type="file"
|
||||
onChange={handleUploadCaption}
|
||||
className="hidden"
|
||||
accept=".vtt, .srt"
|
||||
ref={customCaptionUploadElement}
|
||||
accept={subtitleTypeList.join(",")}
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
if (!e.target.files) return;
|
||||
const customSubtitle = {
|
||||
langIso: "custom",
|
||||
url: URL.createObjectURL(e.target.files[0]),
|
||||
type: MWCaptionType.UNKNOWN,
|
||||
};
|
||||
setCaption(customSubtitle, false);
|
||||
}}
|
||||
/>
|
||||
</PopoutListEntry>
|
||||
</PopoutSection>
|
||||
|
@ -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<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const ref = useRef<HTMLInputElement>(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 (
|
||||
<div className="mb-6 flex flex-row gap-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<label className="font-bold">{props.label}</label>
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
className="styled-slider slider-progress"
|
||||
onChange={props.onChange}
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
|
||||
<div className="text-center font-bold text-white">
|
||||
{props.valueDisplay ?? props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Slider } from "@/components/Slider";
|
||||
|
||||
export function CaptionSettingsPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
@ -73,7 +30,7 @@ export function CaptionSettingsPopout(props: {
|
||||
/>
|
||||
<FloatingCardView.Content>
|
||||
<Slider
|
||||
label={t("videoPlayer.popouts.captionPreferences.delay")}
|
||||
label={t("videoPlayer.popouts.captionPreferences.delay") as string}
|
||||
max={10}
|
||||
min={-10}
|
||||
step={0.1}
|
||||
@ -90,7 +47,7 @@ export function CaptionSettingsPopout(props: {
|
||||
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
|
||||
/>
|
||||
<Slider
|
||||
label={t("videoPlayer.popouts.captionPreferences.opacity")}
|
||||
label={t("videoPlayer.popouts.captionPreferences.opacity") as string}
|
||||
step={1}
|
||||
min={0}
|
||||
max={255}
|
||||
|
73
src/video/components/popouts/PlaybackSpeedPopout.tsx
Normal file
73
src/video/components/popouts/PlaybackSpeedPopout.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { Slider } from "@/components/Slider";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
const speedSelectionOptions = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
export function PlaybackSpeedPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
return (
|
||||
<FloatingView
|
||||
{...props.router.pageProps(props.prefix)}
|
||||
width={320}
|
||||
height={500}
|
||||
>
|
||||
<FloatingCardView.Header
|
||||
title={t("videoPlayer.popouts.playbackSpeed")}
|
||||
description={t("videoPlayer.popouts.descriptions.playbackSpeed")}
|
||||
goBack={() => props.router.navigate("/")}
|
||||
/>
|
||||
<FloatingCardView.Content noSection>
|
||||
<PopoutSection>
|
||||
{speedSelectionOptions.map((speed) => (
|
||||
<PopoutListEntry
|
||||
key={speed}
|
||||
active={mediaPlaying.playbackSpeed === speed}
|
||||
onClick={() => {
|
||||
controls.setPlaybackSpeed(speed);
|
||||
controls.closePopout();
|
||||
}}
|
||||
>
|
||||
{speed}x
|
||||
</PopoutListEntry>
|
||||
))}
|
||||
</PopoutSection>
|
||||
|
||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-300 px-5 py-3 text-xs font-bold uppercase">
|
||||
<Icon className="text-base" icon={Icons.TACHOMETER} />
|
||||
<span>{t("videoPlayer.popouts.customPlaybackSpeed")}</span>
|
||||
</p>
|
||||
|
||||
<PopoutSection className="pt-0">
|
||||
<div>
|
||||
<Slider
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={mediaPlaying.playbackSpeed}
|
||||
valueDisplay={`${mediaPlaying.playbackSpeed}x`}
|
||||
onChange={(e: { target: { valueAsNumber: number } }) =>
|
||||
controls.setPlaybackSpeed(e.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PopoutSection>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
);
|
||||
}
|
@ -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() {
|
||||
<DownloadAction />
|
||||
<SourceSelectionAction onClick={() => navigate("/source")} />
|
||||
<CaptionsSelectionAction onClick={() => navigate("/captions")} />
|
||||
<PlaybackSpeedSelectionAction
|
||||
onClick={() => navigate("/playback-speed")}
|
||||
/>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
<SourceSelectionPopout router={floatingRouter} prefix="source" />
|
||||
@ -29,6 +34,7 @@ export function SettingsPopout() {
|
||||
router={floatingRouter}
|
||||
prefix="caption-settings"
|
||||
/>
|
||||
<PlaybackSpeedPopout router={floatingRouter} prefix="playback-speed" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -22,6 +22,7 @@ export type VideoPlayerStateController = {
|
||||
clearCaption(): void;
|
||||
getId(): string;
|
||||
togglePictureInPicture(): void;
|
||||
setPlaybackSpeed(num: number): void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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 (
|
||||
<div className="py-48">
|
||||
<Navigation />
|
||||
|
@ -105,7 +105,7 @@ function EmbedScraperSelector(props: EmbedScraperSelectorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbedTesterView() {
|
||||
export default function EmbedTesterView() {
|
||||
const [embed, setEmbed] = useState<MWEmbed | null>(null);
|
||||
const [embedScraperId, setEmbedScraperId] = useState<string | null>(null);
|
||||
const embedScraper = useMemo(
|
||||
|
@ -96,7 +96,7 @@ function ProviderSelector(props: ProviderSelectorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderTesterView() {
|
||||
export default function ProviderTesterView() {
|
||||
const [media, setMedia] = useState<DetailedMeta | null>(null);
|
||||
const [providerId, setProviderId] = useState<string | null>(null);
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
// simple empty view, perfect for putting in tests
|
||||
export function TestView() {
|
||||
export default function TestView() {
|
||||
return <div />;
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ const testMeta: DetailedMeta = {
|
||||
},
|
||||
};
|
||||
|
||||
export function VideoTesterView() {
|
||||
export default function VideoTesterView() {
|
||||
const [video, setVideo] = useState<VideoData | null>(null);
|
||||
const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4);
|
||||
const [url, setUrl] = useState("");
|
||||
@ -64,8 +64,8 @@ export function VideoTesterView() {
|
||||
/>
|
||||
<SourceController
|
||||
source={video.streamUrl}
|
||||
type={MWStreamType.MP4}
|
||||
quality={MWStreamQuality.Q720P}
|
||||
type={videoType}
|
||||
quality={MWStreamQuality.QUNKNOWN}
|
||||
/>
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
|
@ -16,7 +16,6 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "./src",
|
||||
"typeRoots": ["./src/@types"],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
|
22
yarn.lock
22
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"
|
||||
|
Loading…
Reference in New Issue
Block a user