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; } 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; /** * The active Picture-in-Picture `Window`, or `null` when none is open */ pipWindow: ShallowRef; /** * Whether a Picture-in-Picture window is currently open */ isOpen: ComputedRef; /** * The last error thrown by `open()`, or `null` */ error: ShallowRef; /** * 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; /** * 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(null); const error = shallowRef(null); const isOpen = computed(() => pipWindow.value !== null); function handleClose(): void { pipWindow.value = null; } async function open(pipOptions?: DocumentPictureInPictureOptions): Promise { 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, }; }