170 lines
4.7 KiB
TypeScript
170 lines
4.7 KiB
TypeScript
import { computed, shallowRef } from 'vue';
|
|
import type { ComputedRef, ShallowRef } from 'vue';
|
|
import { noop } from '@robonen/stdlib';
|
|
import { defaultWindow } from '@/types';
|
|
import type { ConfigurableWindow } from '@/types';
|
|
import { useSupported } from '@/composables/utilities/useSupported';
|
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
|
|
|
export interface DocumentPictureInPictureOptions {
|
|
/**
|
|
* The initial width of the Picture-in-Picture window, in pixels.
|
|
*/
|
|
width?: number;
|
|
|
|
/**
|
|
* The initial height of the Picture-in-Picture window, in pixels.
|
|
*/
|
|
height?: number;
|
|
|
|
/**
|
|
* Hide the "back to tab" button in the Picture-in-Picture window.
|
|
*
|
|
* @default false
|
|
*/
|
|
disallowReturnToOpener?: boolean;
|
|
|
|
/**
|
|
* Open the window in its default position/size rather than reusing the last one.
|
|
*
|
|
* @default false
|
|
*/
|
|
preferInitialWindowPlacement?: boolean;
|
|
}
|
|
|
|
interface DocumentPictureInPicture {
|
|
readonly window: Window | null;
|
|
requestWindow: (options?: DocumentPictureInPictureOptions) => Promise<Window>;
|
|
}
|
|
|
|
interface WindowWithDocumentPiP {
|
|
documentPictureInPicture: DocumentPictureInPicture;
|
|
}
|
|
|
|
export interface UseDocumentPiPOptions extends ConfigurableWindow {
|
|
/**
|
|
* Called when `open()` rejects (e.g. not triggered by a user gesture) instead
|
|
* of throwing. The same value is also stored in the returned `error` ref.
|
|
*
|
|
* @default noop
|
|
*/
|
|
onError?: (error: unknown) => void;
|
|
}
|
|
|
|
export interface UseDocumentPiPReturn {
|
|
/**
|
|
* Whether the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) is supported
|
|
*/
|
|
isSupported: ComputedRef<boolean>;
|
|
|
|
/**
|
|
* The active Picture-in-Picture `Window`, or `null` when none is open
|
|
*/
|
|
pipWindow: ShallowRef<Window | null>;
|
|
|
|
/**
|
|
* Whether a Picture-in-Picture window is currently open
|
|
*/
|
|
isOpen: ComputedRef<boolean>;
|
|
|
|
/**
|
|
* The last error thrown by `open()`, or `null`
|
|
*/
|
|
error: ShallowRef<unknown | null>;
|
|
|
|
/**
|
|
* Open a Picture-in-Picture window. Must be called from a user gesture.
|
|
* Resolves with the new `Window`, or `undefined` when unsupported.
|
|
*/
|
|
open: (pipOptions?: DocumentPictureInPictureOptions) => Promise<Window | undefined>;
|
|
|
|
/**
|
|
* Close the active Picture-in-Picture window, if any
|
|
*/
|
|
close: () => void;
|
|
}
|
|
|
|
/**
|
|
* @name useDocumentPiP
|
|
* @category Browser
|
|
* @description Reactive wrapper around the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) for rendering arbitrary DOM in an always-on-top window.
|
|
*
|
|
* @param {UseDocumentPiPOptions} [options={}] Options
|
|
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
|
|
* @param {Window} [options.window=defaultWindow] Custom `window` instance
|
|
* @returns {UseDocumentPiPReturn} `isSupported`, `pipWindow`, `isOpen`, `error`, `open()`, and `close()`
|
|
*
|
|
* @example
|
|
* const { isSupported, pipWindow, open } = useDocumentPiP();
|
|
* async function popOut(content: HTMLElement) {
|
|
* const win = await open({ width: 320, height: 240 });
|
|
* win?.document.body.append(content);
|
|
* }
|
|
*
|
|
* @example
|
|
* // Move a player into the PiP window and track open state
|
|
* const { isOpen, pipWindow, open, close } = useDocumentPiP();
|
|
* watchEffect(() => {
|
|
* if (pipWindow.value)
|
|
* pipWindow.value.document.body.append(playerEl);
|
|
* });
|
|
*
|
|
* @since 0.0.14
|
|
*/
|
|
export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn {
|
|
const {
|
|
window = defaultWindow,
|
|
onError = noop,
|
|
} = options;
|
|
|
|
const isSupported = useSupported(() => !!window && 'documentPictureInPicture' in window);
|
|
const pipWindow = shallowRef<Window | null>(null);
|
|
const error = shallowRef<unknown | null>(null);
|
|
|
|
const isOpen = computed<boolean>(() => pipWindow.value !== null);
|
|
|
|
function handleClose(): void {
|
|
pipWindow.value = null;
|
|
}
|
|
|
|
async function open(pipOptions?: DocumentPictureInPictureOptions): Promise<Window | undefined> {
|
|
if (!isSupported.value || !window)
|
|
return undefined;
|
|
|
|
error.value = null;
|
|
|
|
try {
|
|
const controller = (window as unknown as WindowWithDocumentPiP).documentPictureInPicture;
|
|
const pip = await controller.requestWindow(pipOptions);
|
|
|
|
// The PiP window closing (user or programmatic) clears our reference.
|
|
pip.addEventListener('pagehide', handleClose, { once: true });
|
|
pipWindow.value = pip;
|
|
|
|
return pip;
|
|
}
|
|
catch (err) {
|
|
error.value = err;
|
|
onError(err);
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function close(): void {
|
|
pipWindow.value?.close();
|
|
pipWindow.value = null;
|
|
}
|
|
|
|
tryOnScopeDispose(close);
|
|
|
|
return {
|
|
isSupported,
|
|
pipWindow,
|
|
isOpen,
|
|
error,
|
|
open,
|
|
close,
|
|
};
|
|
}
|