+
+ {videoPlayerContext.quality}
+
+
+ );
+}
diff --git a/src/components/video/controls/SkipTime.tsx b/src/components/video/controls/SkipTime.tsx
index 928b3e5c..5e47cb90 100644
--- a/src/components/video/controls/SkipTime.tsx
+++ b/src/components/video/controls/SkipTime.tsx
@@ -28,6 +28,7 @@ function formatSeconds(secs: number, showHours = false): string {
interface Props {
className?: string;
+ noDuration?: boolean;
}
export function SkipTime(props: Props) {
@@ -39,7 +40,7 @@ export function SkipTime(props: Props) {
return (
- {time} / {duration}
+ {time} {props.noDuration ? "" : `/ ${duration}`}
);
diff --git a/src/components/video/controls/SourceControl.tsx b/src/components/video/controls/SourceControl.tsx
index 9025c404..c8cccfbc 100644
--- a/src/components/video/controls/SourceControl.tsx
+++ b/src/components/video/controls/SourceControl.tsx
@@ -1,10 +1,11 @@
-import { MWStreamType } from "@/backend/helpers/streams";
+import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { useContext, useEffect, useRef } from "react";
import { VideoPlayerDispatchContext } from "../VideoContext";
interface SourceControlProps {
source: string;
type: MWStreamType;
+ quality: MWStreamQuality;
}
export function SourceControl(props: SourceControlProps) {
@@ -17,6 +18,7 @@ export function SourceControl(props: SourceControlProps) {
type: "SET_SOURCE",
url: props.source,
sourceType: props.type,
+ quality: props.quality,
});
didInitialize.current = true;
}, [props, dispatch]);
diff --git a/src/components/video/controls/SourceSelectionControl.tsx b/src/components/video/controls/SourceSelectionControl.tsx
new file mode 100644
index 00000000..08566e48
--- /dev/null
+++ b/src/components/video/controls/SourceSelectionControl.tsx
@@ -0,0 +1,185 @@
+import { useParams } from "react-router-dom";
+import { useCallback, useContext, useMemo, useState } from "react";
+import { Icon, Icons } from "@/components/Icon";
+import { getProviders } from "@/backend/helpers/register";
+import { useLoading } from "@/hooks/useLoading";
+import { DetailedMeta } from "@/backend/metadata/getmeta";
+import { MWMediaType } from "@/backend/metadata/types";
+import { MWProviderScrapeResult } from "@/backend/helpers/provider";
+import { runProvider } from "@/backend/helpers/run";
+import { IconPatch } from "@/components/buttons/IconPatch";
+import { Loading } from "@/components/layout/Loading";
+import {
+ useVideoPlayerState,
+ VideoPlayerDispatchContext,
+} from "../VideoContext";
+import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
+import { VideoPopout } from "../parts/VideoPopout";
+
+interface Props {
+ className?: string;
+ media?: DetailedMeta;
+}
+
+function PopoutSourceSelect(props: { media: DetailedMeta }) {
+ const dispatch = useContext(VideoPlayerDispatchContext);
+ const providers = useMemo(
+ () => getProviders().filter((v) => v.type.includes(props.media.meta.type)),
+ [props]
+ );
+ const { episode, season } = useParams<{ episode: string; season: string }>();
+ const [selected, setSelected] = useState
(null);
+ const selectedProvider = useMemo(
+ () => providers.find((v) => v.id === selected),
+ [selected, providers]
+ );
+
+ const [scrapeData, setScrapeData] = useState(
+ null
+ );
+ const [scrapeProvider, loadingProvider, errorProvider] = useLoading(
+ async (providerId: string) => {
+ const theProvider = providers.find((v) => v.id === providerId);
+ if (!theProvider) throw new Error("Invalid provider");
+ return runProvider(theProvider, {
+ media: props.media,
+ progress: () => {},
+ type: props.media.meta.type,
+ episode: (props.media.meta.type === MWMediaType.SERIES
+ ? episode
+ : undefined) as any,
+ season: (props.media.meta.type === MWMediaType.SERIES
+ ? season
+ : undefined) as any,
+ });
+ }
+ );
+
+ // TODO add embed support
+ // TODO restore startAt when changing source
+ // TODO auto choose when only one option
+ // TODO close when selecting item
+ // TODO show currently selected provider
+ // TODO clear error state when switching
+ // const [scrapeEmbed, embedLoading, embedError] = useLoading(
+ // async (embed: MWEmbed) => {
+ // if (!embed.type) throw new Error("Invalid embed type");
+ // const theScraper = getEmbedScraperByType(embed.type);
+ // if (!theScraper) throw new Error("Invalid scraper");
+ // return runEmbedScraper(theScraper, {
+ // progress: () => {},
+ // url: embed.url,
+ // });
+ // }
+ // );
+
+ const selectProvider = useCallback(
+ (id: string) => {
+ scrapeProvider(id).then((v) => {
+ if (!v) throw new Error("No scrape result");
+ setScrapeData(v);
+ });
+ setSelected(id);
+ },
+ [setSelected, scrapeProvider]
+ );
+
+ if (!selectedProvider)
+ return (
+ <>
+
+ Select video source
+
+
+
+ {providers.map((e) => (
+
selectProvider(e.id)}
+ key={e.id}
+ >
+ {e.displayName}
+
+ ))}
+
+
+ >
+ );
+
+ return (
+ <>
+
+
+ {selectedProvider.displayName}
+
+
+ {loadingProvider ? (
+
+
+
+ ) : errorProvider ? (
+
+
+
+
+ Something went wrong loading streams.
+
+
+
+ ) : scrapeData ? (
+
+ {scrapeData.stream ? (
+
+ scrapeData.stream &&
+ dispatch({
+ url: scrapeData.stream.streamUrl,
+ quality: scrapeData.stream.quality,
+ sourceType: scrapeData.stream.type,
+ type: "SET_SOURCE",
+ })
+ }
+ >
+ {selectedProvider.displayName}
+
+ ) : null}
+
+ ) : null}
+
+ >
+ );
+}
+
+export function SourceSelectionControl(props: Props) {
+ const { videoState } = useVideoPlayerState();
+
+ if (!props.media) return null;
+
+ return (
+
+
+
+
+
+
videoState.openPopout("source")}
+ />
+
+
+ );
+}
diff --git a/src/components/video/controls/TimeControl.tsx b/src/components/video/controls/TimeControl.tsx
index fdc67064..75eef38e 100644
--- a/src/components/video/controls/TimeControl.tsx
+++ b/src/components/video/controls/TimeControl.tsx
@@ -6,29 +6,37 @@ interface Props {
className?: string;
}
-export function TimeControl(props: Props) {
+export function SkipTimeBackward() {
+ const { videoState } = useVideoPlayerState();
+
+ const skipBackward = () => {
+ videoState.setTime(videoState.time - 10);
+ };
+
+ return (
+
+ );
+}
+
+export function SkipTimeForward() {
const { videoState } = useVideoPlayerState();
const skipForward = () => {
videoState.setTime(videoState.time + 10);
};
- const skipBackward = () => {
- videoState.setTime(videoState.time - 10);
- };
+ return (
+
+ );
+}
+export function TimeControl(props: Props) {
return (
);
}
diff --git a/src/components/video/controls/VolumeControl.tsx b/src/components/video/controls/VolumeControl.tsx
index 61aff1fc..707a88bb 100644
--- a/src/components/video/controls/VolumeControl.tsx
+++ b/src/components/video/controls/VolumeControl.tsx
@@ -4,6 +4,7 @@ import {
makePercentageString,
useProgressBar,
} from "@/hooks/useProgressBar";
+import { useVolumeControl } from "@/hooks/useVolumeToggle";
import { canChangeVolume } from "@/utils/detectFeatures";
import { useCallback, useEffect, useRef, useState } from "react";
import { useVideoPlayerState } from "../VideoContext";
@@ -15,7 +16,7 @@ interface Props {
export function VolumeControl(props: Props) {
const { videoState } = useVideoPlayerState();
const ref = useRef(null);
- const [storedVolume, setStoredVolume] = useState(1);
+ const { setStoredVolume, toggleVolume } = useVolumeControl();
const [hoveredOnce, setHoveredOnce] = useState(false);
const commitVolume = useCallback(
@@ -36,13 +37,8 @@ export function VolumeControl(props: Props) {
}, [videoState, setHoveredOnce]);
const handleClick = useCallback(() => {
- if (videoState.volume > 0) {
- videoState.setVolume(0);
- setStoredVolume(videoState.volume);
- } else {
- videoState.setVolume(storedVolume > 0 ? storedVolume : 1);
- }
- }, [videoState, setStoredVolume, storedVolume]);
+ toggleVolume();
+ }, [toggleVolume]);
const handleMouseEnter = useCallback(async () => {
if (await canChangeVolume()) setHoveredOnce(true);
diff --git a/src/components/video/hooks/useVideoPlayer.ts b/src/components/video/hooks/useVideoPlayer.ts
index 5ba7dbac..9bc9421d 100644
--- a/src/components/video/hooks/useVideoPlayer.ts
+++ b/src/components/video/hooks/useVideoPlayer.ts
@@ -24,6 +24,7 @@ export type PlayerState = {
leftControlHovering: boolean;
hasPlayedOnce: boolean;
popout: string | null;
+ isFocused: boolean;
seasonData: {
isSeries: boolean;
current?: {
@@ -50,6 +51,7 @@ export const initialPlayerState: PlayerContext = {
isPlaying: false,
isPaused: true,
isFullscreen: false,
+ isFocused: false,
isLoading: false,
isSeeking: false,
isFirstLoading: true,
@@ -177,7 +179,19 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
}));
}
};
+ const isFocused = (evt: any) => {
+ update((s) => ({
+ ...s,
+ isFocused: evt.type !== "mouseleave",
+ }));
+ };
+
+ const playerWrapper = player.closest(".is-video-player");
+ if (!playerWrapper) return;
+ playerWrapper.addEventListener("click", isFocused);
+ playerWrapper.addEventListener("mouseenter", isFocused);
+ playerWrapper.addEventListener("mouseleave", isFocused);
player.addEventListener("pause", pause);
player.addEventListener("playing", playing);
player.addEventListener("seeking", seeking);
@@ -196,6 +210,9 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
);
return () => {
+ playerWrapper.removeEventListener("click", isFocused);
+ playerWrapper.removeEventListener("mouseenter", isFocused);
+ playerWrapper.removeEventListener("mouseleave", isFocused);
player.removeEventListener("pause", pause);
player.removeEventListener("playing", playing);
player.removeEventListener("seeking", seeking);
diff --git a/src/components/video/parts/VideoPlayerHeader.tsx b/src/components/video/parts/VideoPlayerHeader.tsx
index 3f835645..e66769d3 100644
--- a/src/components/video/parts/VideoPlayerHeader.tsx
+++ b/src/components/video/parts/VideoPlayerHeader.tsx
@@ -6,10 +6,13 @@ import {
getIfBookmarkedFromPortable,
useBookmarkContext,
} from "@/state/bookmark";
+import { AirplayControl } from "../controls/AirplayControl";
+import { ChromeCastControl } from "../controls/ChromeCastControl";
interface VideoPlayerHeaderProps {
media?: MWMediaMeta;
onClick?: () => void;
+ isMobile?: boolean;
}
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
@@ -40,7 +43,7 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
) : null}
- {props.media ? (
+ {props.media && (
- ) : null}
+ )}
-