53
common/resources/client/player/state/pip/chrome-pip-adapter.ts
Executable file
53
common/resources/client/player/state/pip/chrome-pip-adapter.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
import {PipAdapter} from '@common/player/state/pip/pip-adapter';
|
||||
import {IS_CLIENT} from '@common/utils/platform';
|
||||
|
||||
export const createChromePipAdapter = (
|
||||
host: HTMLVideoElement,
|
||||
onChange: () => void
|
||||
): PipAdapter => {
|
||||
return {
|
||||
isSupported: () => canUsePiPInChrome(),
|
||||
isPip: () => {
|
||||
return host === document.pictureInPictureElement;
|
||||
},
|
||||
enter: () => {
|
||||
if (canUsePiPInChrome()) {
|
||||
return host.requestPictureInPicture();
|
||||
}
|
||||
},
|
||||
exit: () => {
|
||||
if (canUsePiPInChrome()) {
|
||||
return document.exitPictureInPicture();
|
||||
}
|
||||
},
|
||||
bindEvents: () => {
|
||||
if (canUsePiPInChrome()) {
|
||||
host.addEventListener('enterpictureinpicture', onChange);
|
||||
host.addEventListener('leavepictureinpicture', onChange);
|
||||
}
|
||||
},
|
||||
unbindEvents: () => {
|
||||
if (canUsePiPInChrome()) {
|
||||
host.removeEventListener('enterpictureinpicture', onChange);
|
||||
host.removeEventListener('leavepictureinpicture', onChange);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the native HTML5 video player can enter picture-in-picture (PIP) mode when using
|
||||
* the Chrome browser.
|
||||
*
|
||||
* @see https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture
|
||||
*/
|
||||
let _canUsePiPInChrome: boolean | undefined;
|
||||
const canUsePiPInChrome = (): boolean => {
|
||||
if (!IS_CLIENT) return false;
|
||||
if (_canUsePiPInChrome == null) {
|
||||
const video = document.createElement('video');
|
||||
_canUsePiPInChrome =
|
||||
!!document.pictureInPictureEnabled && !video.disablePictureInPicture;
|
||||
}
|
||||
return _canUsePiPInChrome;
|
||||
};
|
||||
8
common/resources/client/player/state/pip/pip-adapter.ts
Executable file
8
common/resources/client/player/state/pip/pip-adapter.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export interface PipAdapter {
|
||||
isSupported: () => boolean;
|
||||
isPip: () => boolean;
|
||||
enter: () => Promise<unknown> | undefined;
|
||||
exit: () => Promise<unknown> | undefined;
|
||||
bindEvents: () => void;
|
||||
unbindEvents: () => void;
|
||||
}
|
||||
91
common/resources/client/player/state/pip/pip-slice.ts
Executable file
91
common/resources/client/player/state/pip/pip-slice.ts
Executable file
@@ -0,0 +1,91 @@
|
||||
import {StateCreator} from 'zustand';
|
||||
import {
|
||||
PlayerState,
|
||||
ProviderListeners,
|
||||
} from '@common/player/state/player-state';
|
||||
import {PipAdapter} from '@common/player/state/pip/pip-adapter';
|
||||
import {createChromePipAdapter} from '@common/player/state/pip/chrome-pip-adapter';
|
||||
import {createSafariPipAdapter} from '@common/player/state/pip/safari-pip-adapter';
|
||||
|
||||
export interface PipSlice {
|
||||
isPip: boolean;
|
||||
canPip: boolean;
|
||||
enterPip: () => void;
|
||||
exitPip: () => void;
|
||||
togglePip: () => void;
|
||||
initPip: () => void;
|
||||
destroyPip: () => void;
|
||||
}
|
||||
|
||||
type BaseSliceCreator = StateCreator<
|
||||
PipSlice & PlayerState,
|
||||
[['zustand/immer', unknown]],
|
||||
[],
|
||||
PipSlice
|
||||
>;
|
||||
|
||||
type StoreLice = BaseSliceCreator extends (...a: infer U) => infer R
|
||||
? (...a: [...U, Set<Partial<ProviderListeners>>]) => R
|
||||
: never;
|
||||
|
||||
const adapterFactories = [createChromePipAdapter, createSafariPipAdapter];
|
||||
|
||||
export const createPipSlice: StoreLice = (set, get) => {
|
||||
let subscription: () => void | undefined;
|
||||
let adapters: PipAdapter[] = [];
|
||||
|
||||
const onPipChange = () => {
|
||||
set({isPip: adapters.some(a => a.isPip())});
|
||||
};
|
||||
|
||||
const isSupported = (): boolean => {
|
||||
if (get().providerName !== 'htmlVideo') {
|
||||
return false;
|
||||
}
|
||||
return adapters.some(adapter => adapter.isSupported());
|
||||
};
|
||||
|
||||
return {
|
||||
isPip: false,
|
||||
canPip: false,
|
||||
enterPip: async () => {
|
||||
if (get().isPip || !isSupported()) return;
|
||||
await adapters.find(a => a.isSupported())?.enter();
|
||||
},
|
||||
exitPip: async () => {
|
||||
if (!get().isPip) return;
|
||||
await adapters.find(a => a.isSupported())?.exit();
|
||||
},
|
||||
togglePip: () => {
|
||||
if (get().isPip) {
|
||||
get().exitPip();
|
||||
} else {
|
||||
get().enterPip();
|
||||
}
|
||||
},
|
||||
initPip: () => {
|
||||
subscription = get().subscribe({
|
||||
providerReady: ({el}) => {
|
||||
// when changing adapters, remove previous adapter events and exit pip
|
||||
adapters.every(a => a.unbindEvents());
|
||||
if (get().isPip) {
|
||||
adapters.every(a => a.exit());
|
||||
}
|
||||
// create new adapters, and if pip is supported on at least one, bind events
|
||||
adapters = adapterFactories.map(factory =>
|
||||
factory(el as HTMLVideoElement, onPipChange)
|
||||
);
|
||||
const canPip = isSupported();
|
||||
if (canPip) {
|
||||
adapters.every(a => a.bindEvents());
|
||||
}
|
||||
set({canPip});
|
||||
},
|
||||
});
|
||||
},
|
||||
destroyPip: () => {
|
||||
get().exitPip();
|
||||
subscription?.();
|
||||
},
|
||||
};
|
||||
};
|
||||
56
common/resources/client/player/state/pip/safari-pip-adapter.ts
Executable file
56
common/resources/client/player/state/pip/safari-pip-adapter.ts
Executable file
@@ -0,0 +1,56 @@
|
||||
import {PipAdapter} from '@common/player/state/pip/pip-adapter';
|
||||
import {IS_CLIENT, IS_IPHONE} from '@common/utils/platform';
|
||||
|
||||
export const createSafariPipAdapter = (
|
||||
host: HTMLVideoElement,
|
||||
onChange: () => void
|
||||
): PipAdapter => {
|
||||
return {
|
||||
isSupported: () => canUsePiPInSafari(),
|
||||
isPip: () => {
|
||||
return host.webkitPresentationMode === 'picture-in-picture';
|
||||
},
|
||||
enter: () => {
|
||||
if (canUsePiPInSafari()) {
|
||||
return host.webkitSetPresentationMode?.('picture-in-picture');
|
||||
}
|
||||
},
|
||||
exit: () => {
|
||||
if (canUsePiPInSafari()) {
|
||||
return host.webkitSetPresentationMode?.('inline');
|
||||
}
|
||||
},
|
||||
bindEvents: () => {
|
||||
if (canUsePiPInSafari()) {
|
||||
host.addEventListener('webkitpresentationmodechanged', onChange);
|
||||
}
|
||||
},
|
||||
unbindEvents: () => {
|
||||
if (canUsePiPInSafari()) {
|
||||
host.removeEventListener('webkitpresentationmodechanged', onChange);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the native HTML5 video player can enter picture-in-picture (PIP) mode when using
|
||||
* the desktop Safari browser, iOS Safari appears to "support" PiP through the check, however PiP
|
||||
* does not function.
|
||||
*
|
||||
* @see https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls
|
||||
*/
|
||||
let _canUsePiPInSafari: boolean | undefined;
|
||||
const canUsePiPInSafari = (): boolean => {
|
||||
if (!IS_CLIENT) return false;
|
||||
const video = document.createElement('video');
|
||||
if (_canUsePiPInSafari == null) {
|
||||
_canUsePiPInSafari =
|
||||
// @ts-ignore
|
||||
!!video.webkitSupportsPresentationMode &&
|
||||
// @ts-ignore
|
||||
!!video.webkitSetPresentationMode &&
|
||||
!IS_IPHONE;
|
||||
}
|
||||
return _canUsePiPInSafari;
|
||||
};
|
||||
Reference in New Issue
Block a user