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/setup/locales/en/translation.json b/src/setup/locales/en/translation.json index 1838b63c..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", @@ -82,6 +85,7 @@ "customCaption": "Custom caption", "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/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/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 7ea0e321..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,6 +281,10 @@ 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 || // other browsers @@ -326,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