diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index ff1cef8c..382b7133 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -36,6 +36,7 @@ export enum Icons { CASTING = "casting", CIRCLE_EXCLAMATION = "circle_exclamation", DOWNLOAD = "download", + PICTURE_IN_PICTURE = "pictureInPicture", } export interface IconProps { @@ -79,6 +80,7 @@ const iconList: Record = { circle_exclamation: ``, casting: "", download: ``, + pictureInPicture: ``, }; function ChromeCastButton() { diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json index 5a3fe230..f358f841 100644 --- a/src/setup/locales/en/translation.json +++ b/src/setup/locales/en/translation.json @@ -61,7 +61,8 @@ "episodes": "Episodes", "source": "Source", "captions": "Captions", - "download": "Download" + "download": "Download", + "pictureInPicture": "Picture in Picture" }, "popouts": { "sources": "Sources", diff --git a/src/utils/detectFeatures.ts b/src/utils/detectFeatures.ts index 15be4c69..0180ae49 100644 --- a/src/utils/detectFeatures.ts +++ b/src/utils/detectFeatures.ts @@ -38,3 +38,7 @@ export function canWebkitFullscreen(): boolean { export function canFullscreen(): boolean { return canFullscreenAnyElement() || canWebkitFullscreen(); } + +export function canPictureInPicture(): boolean { + return "pictureInPictureEnabled" in document; +} diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx index 9553b70c..7aef90bc 100644 --- a/src/video/components/VideoPlayer.tsx +++ b/src/video/components/VideoPlayer.tsx @@ -31,6 +31,7 @@ import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderA import { ChromecastAction } from "@/video/components/actions/ChromecastAction"; import { CastingTextAction } from "@/video/components/actions/CastingTextAction"; import { DownloadAction } from "@/video/components/actions/DownloadAction"; +import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction"; type Props = VideoPlayerBaseProps; @@ -144,6 +145,7 @@ export function VideoPlayer(props: Props) {
+ @@ -161,6 +163,7 @@ export function VideoPlayer(props: Props) { + diff --git a/src/video/components/actions/DownloadAction.tsx b/src/video/components/actions/DownloadAction.tsx index 10d59ce5..307f14c7 100644 --- a/src/video/components/actions/DownloadAction.tsx +++ b/src/video/components/actions/DownloadAction.tsx @@ -28,7 +28,7 @@ export function DownloadAction(props: Props) { href={isHLS ? undefined : sourceInterface.source?.url} rel="noreferrer" target="_blank" - download={title ? normalizeTitle(title) : undefined} + download={title ? `${normalizeTitle(title)}.mp4` : undefined} > { + controls.togglePictureInPicture(); + }, [controls]); + + if (!canPictureInPicture()) return null; + + return ( + + ); +} diff --git a/src/video/state/init.ts b/src/video/state/init.ts index 13118a1d..6a67777d 100644 --- a/src/video/state/init.ts +++ b/src/video/state/init.ts @@ -31,6 +31,7 @@ function initPlayer(): VideoPlayerState { isFocused: false, leftControlHovering: false, popoutBounds: null, + isPictureInPicture: false, }, mediaPlaying: { diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts index 56c89a10..f21ec05a 100644 --- a/src/video/state/logic/controls.ts +++ b/src/video/state/logic/controls.ts @@ -13,6 +13,7 @@ export type ControlMethods = { setMeta(data?: VideoPlayerMeta): void; setCurrentEpisode(sId: string, eId: string): void; setDraggingTime(num: number): void; + togglePictureInPicture(): void; }; export function useControls( @@ -100,5 +101,9 @@ export function useControls( updateMeta(descriptor, state); } }, + togglePictureInPicture() { + state.stateProvider?.togglePictureInPicture(); + updateInterface(descriptor, state); + }, }; } diff --git a/src/video/state/providers/castingStateProvider.ts b/src/video/state/providers/castingStateProvider.ts index e3b56dfb..520ca814 100644 --- a/src/video/state/providers/castingStateProvider.ts +++ b/src/video/state/providers/castingStateProvider.ts @@ -148,6 +148,10 @@ export function createCastingStateProvider( updateSource(descriptor, state); } }, + togglePictureInPicture() { + controller?.togglePictureInPicture(); + updateSource(descriptor, state); + }, providerStart() { this.setVolume(getStoredVolume()); diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts index e34b950d..1643a980 100644 --- a/src/video/state/providers/providerTypes.ts +++ b/src/video/state/providers/providerTypes.ts @@ -19,6 +19,7 @@ export type VideoPlayerStateController = { setCaption(id: string, url: string): void; clearCaption(): void; getId(): string; + togglePictureInPicture(): void; }; export type VideoPlayerStateProvider = VideoPlayerStateController & { diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index ae009aae..729ac0b0 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -5,6 +5,7 @@ import { canFullscreen, canFullscreenAnyElement, canWebkitFullscreen, + canPictureInPicture, } from "@/utils/detectFeatures"; import { MWStreamType } from "@/backend/helpers/streams"; import { updateInterface } from "@/video/state/logic/interface"; @@ -204,6 +205,20 @@ export function createVideoStateProvider( 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() { this.setVolume(getStoredVolume()); diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 6b04a55a..b1557d7f 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -30,6 +30,7 @@ export type VideoPlayerState = { 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 popoutBounds: null | DOMRect; // bounding box of current popout + isPictureInPicture: boolean; // is picture in picture active }; // state related to the playing state of the media