Playback speed

This commit is contained in:
zisra 2023-03-23 13:52:34 -05:00
parent f2ea05708f
commit 4d08ecc694
18 changed files with 1296 additions and 1440 deletions

View File

@ -1,6 +1,6 @@
window.__CONFIG__ = {
// url must NOT end with a slash
VITE_CORS_PROXY_URL: "",
VITE_CORS_PROXY_URL: "https://rough.isra.workers.dev",
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3",
VITE_OMDB_API_KEY: "aa0937c0",
};

View File

@ -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() {

45
src/components/Slider.tsx Normal file
View File

@ -0,0 +1,45 @@
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">
<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>
);
}

View File

@ -2,7 +2,7 @@ import "core-js/stable";
import React, { Suspense } from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-dom";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import type { ReactNode } from "react/index";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register";

View File

@ -63,12 +63,16 @@
"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",
"speed": "Speed",
"captionPreferences": {
"title": "Customize",
"delay": "Delay",
@ -82,6 +86,7 @@
"customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption (SRT, VTT)",
"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 +97,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": {

View File

@ -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>
);
}

View File

@ -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/index";
import { VideoPlayerHeader } from "./VideoPlayerHeader";
interface ErrorBoundaryState {

View File

@ -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>;

View File

@ -0,0 +1,74 @@
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
label={t("videoPlayer.popouts.speed")}
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>
);
}

View File

@ -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" />
</>
);
}

View File

@ -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: {

View File

@ -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);
},
};
}

View File

@ -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,
};
}

View File

@ -87,6 +87,9 @@ export function createCastingStateProvider(
togglePictureInPicture() {
// no picture in picture while casting
},
setPlaybackSpeed(num) {
this.setPlaybackSpeed(num);
},
async setVolume(v) {
// clamp time between 0 and 1
let volume = Math.min(v, 1);

View File

@ -22,6 +22,7 @@ export type VideoPlayerStateController = {
clearCaption(): void;
getId(): string;
togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void;
};
export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

@ -228,6 +228,10 @@ export function createVideoStateProvider(
}
}
},
setPlaybackSpeed(num) {
player.playbackRate = num;
state.mediaPlaying.playbackSpeed = num;
},
providerStart() {
this.setVolume(getStoredVolume());

View File

@ -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

2511
yarn.lock

File diff suppressed because it is too large Load Diff