diff --git a/package.json b/package.json index 75d5a021..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": { 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/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 (
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