mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-30 16:17:41 +01:00
Picture in picture
This commit is contained in:
parent
f76db3e4b7
commit
a1f3986e64
12 changed files with 77 additions and 2 deletions
|
@ -36,6 +36,7 @@ export enum Icons {
|
||||||
CASTING = "casting",
|
CASTING = "casting",
|
||||||
CIRCLE_EXCLAMATION = "circle_exclamation",
|
CIRCLE_EXCLAMATION = "circle_exclamation",
|
||||||
DOWNLOAD = "download",
|
DOWNLOAD = "download",
|
||||||
|
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
@ -79,6 +80,7 @@ const iconList: Record<Icons, string> = {
|
||||||
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 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="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
|
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 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="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
|
||||||
casting: "",
|
casting: "",
|
||||||
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></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>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChromeCastButton() {
|
function ChromeCastButton() {
|
||||||
|
|
|
@ -61,7 +61,8 @@
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"captions": "Captions",
|
"captions": "Captions",
|
||||||
"download": "Download"
|
"download": "Download",
|
||||||
|
"pictureInPicture": "Picture in Picture"
|
||||||
},
|
},
|
||||||
"popouts": {
|
"popouts": {
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
|
|
|
@ -38,3 +38,7 @@ export function canWebkitFullscreen(): boolean {
|
||||||
export function canFullscreen(): boolean {
|
export function canFullscreen(): boolean {
|
||||||
return canFullscreenAnyElement() || canWebkitFullscreen();
|
return canFullscreenAnyElement() || canWebkitFullscreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canPictureInPicture(): boolean {
|
||||||
|
return "pictureInPictureEnabled" in document;
|
||||||
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderA
|
||||||
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||||
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
|
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
|
||||||
import { DownloadAction } from "@/video/components/actions/DownloadAction";
|
import { DownloadAction } from "@/video/components/actions/DownloadAction";
|
||||||
|
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
|
||||||
|
|
||||||
type Props = VideoPlayerBaseProps;
|
type Props = VideoPlayerBaseProps;
|
||||||
|
|
||||||
|
@ -144,6 +145,7 @@ export function VideoPlayer(props: Props) {
|
||||||
<div />
|
<div />
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<DownloadAction />
|
<DownloadAction />
|
||||||
|
<PictureInPictureAction />
|
||||||
<CaptionsSelectionAction />
|
<CaptionsSelectionAction />
|
||||||
<SeriesSelectionAction />
|
<SeriesSelectionAction />
|
||||||
<SourceSelectionAction />
|
<SourceSelectionAction />
|
||||||
|
@ -161,6 +163,7 @@ export function VideoPlayer(props: Props) {
|
||||||
<ChromecastAction />
|
<ChromecastAction />
|
||||||
<AirplayAction />
|
<AirplayAction />
|
||||||
<DownloadAction />
|
<DownloadAction />
|
||||||
|
<PictureInPictureAction />
|
||||||
<CaptionsSelectionAction />
|
<CaptionsSelectionAction />
|
||||||
<FullscreenAction />
|
<FullscreenAction />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -28,7 +28,7 @@ export function DownloadAction(props: Props) {
|
||||||
href={isHLS ? undefined : sourceInterface.source?.url}
|
href={isHLS ? undefined : sourceInterface.source?.url}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
download={title ? normalizeTitle(title) : undefined}
|
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
|
||||||
>
|
>
|
||||||
<VideoPlayerIconButton
|
<VideoPlayerIconButton
|
||||||
className={props.className}
|
className={props.className}
|
||||||
|
|
38
src/video/components/actions/PictureInPictureAction.tsx
Normal file
38
src/video/components/actions/PictureInPictureAction.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { Icons } from "@/components/Icon";
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { canPictureInPicture } from "@/utils/detectFeatures";
|
||||||
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PictureInPictureAction(props: Props) {
|
||||||
|
const { isMobile } = useIsMobile();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
|
const controls = useControls(descriptor);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
controls.togglePictureInPicture();
|
||||||
|
}, [controls]);
|
||||||
|
|
||||||
|
if (!canPictureInPicture()) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VideoPlayerIconButton
|
||||||
|
className={props.className}
|
||||||
|
icon={Icons.PICTURE_IN_PICTURE}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={false}
|
||||||
|
text={
|
||||||
|
isMobile ? (t("videoPlayer.buttons.pictureInPicture") as string) : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ function initPlayer(): VideoPlayerState {
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
leftControlHovering: false,
|
leftControlHovering: false,
|
||||||
popoutBounds: null,
|
popoutBounds: null,
|
||||||
|
isPictureInPicture: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
mediaPlaying: {
|
mediaPlaying: {
|
||||||
|
|
|
@ -13,6 +13,7 @@ export type ControlMethods = {
|
||||||
setMeta(data?: VideoPlayerMeta): void;
|
setMeta(data?: VideoPlayerMeta): void;
|
||||||
setCurrentEpisode(sId: string, eId: string): void;
|
setCurrentEpisode(sId: string, eId: string): void;
|
||||||
setDraggingTime(num: number): void;
|
setDraggingTime(num: number): void;
|
||||||
|
togglePictureInPicture(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useControls(
|
export function useControls(
|
||||||
|
@ -100,5 +101,9 @@ export function useControls(
|
||||||
updateMeta(descriptor, state);
|
updateMeta(descriptor, state);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
togglePictureInPicture() {
|
||||||
|
state.stateProvider?.togglePictureInPicture();
|
||||||
|
updateInterface(descriptor, state);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -148,6 +148,10 @@ export function createCastingStateProvider(
|
||||||
updateSource(descriptor, state);
|
updateSource(descriptor, state);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
togglePictureInPicture() {
|
||||||
|
controller?.togglePictureInPicture();
|
||||||
|
updateSource(descriptor, state);
|
||||||
|
},
|
||||||
providerStart() {
|
providerStart() {
|
||||||
this.setVolume(getStoredVolume());
|
this.setVolume(getStoredVolume());
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ export type VideoPlayerStateController = {
|
||||||
setCaption(id: string, url: string): void;
|
setCaption(id: string, url: string): void;
|
||||||
clearCaption(): void;
|
clearCaption(): void;
|
||||||
getId(): string;
|
getId(): string;
|
||||||
|
togglePictureInPicture(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
canFullscreen,
|
canFullscreen,
|
||||||
canFullscreenAnyElement,
|
canFullscreenAnyElement,
|
||||||
canWebkitFullscreen,
|
canWebkitFullscreen,
|
||||||
|
canPictureInPicture,
|
||||||
} from "@/utils/detectFeatures";
|
} from "@/utils/detectFeatures";
|
||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
import { MWStreamType } from "@/backend/helpers/streams";
|
||||||
import { updateInterface } from "@/video/state/logic/interface";
|
import { updateInterface } from "@/video/state/logic/interface";
|
||||||
|
@ -204,6 +205,20 @@ export function createVideoStateProvider(
|
||||||
updateSource(descriptor, state);
|
updateSource(descriptor, state);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async togglePictureInPicture() {
|
||||||
|
if (!canPictureInPicture()) return;
|
||||||
|
if (player !== document.pictureInPictureElement) {
|
||||||
|
try {
|
||||||
|
await player.requestPictureInPicture();
|
||||||
|
} catch {
|
||||||
|
state.interface.isPictureInPicture = false;
|
||||||
|
}
|
||||||
|
state.interface.isPictureInPicture = true;
|
||||||
|
} else {
|
||||||
|
await document.exitPictureInPicture();
|
||||||
|
state.interface.isPictureInPicture = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
providerStart() {
|
providerStart() {
|
||||||
this.setVolume(getStoredVolume());
|
this.setVolume(getStoredVolume());
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ export type VideoPlayerState = {
|
||||||
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
|
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
|
||||||
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
||||||
popoutBounds: null | DOMRect; // bounding box of current popout
|
popoutBounds: null | DOMRect; // bounding box of current popout
|
||||||
|
isPictureInPicture: boolean; // is picture in picture active
|
||||||
};
|
};
|
||||||
|
|
||||||
// state related to the playing state of the media
|
// state related to the playing state of the media
|
||||||
|
|
Loading…
Reference in a new issue