@@ -0,0 +1,38 @@
|
||||
import {FullscreenAdapter} from '@common/player/state/fullscreen/fullscreen-adapter';
|
||||
import {IS_IPHONE} from '@common/utils/platform';
|
||||
|
||||
export function createIphoneFullscreenAdapter(
|
||||
host: HTMLVideoElement,
|
||||
onChange: () => void
|
||||
): FullscreenAdapter {
|
||||
return {
|
||||
/**
|
||||
* @link https://developer.apple.com/documentation/webkitjs/htmlvideoelement/1631913-webkitpresentationmode
|
||||
*/
|
||||
isFullscreen: () => {
|
||||
return host.webkitPresentationMode === 'fullscreen';
|
||||
},
|
||||
/**
|
||||
* @link https://developer.apple.com/documentation/webkitjs/htmlvideoelement/1628805-webkitsupportsfullscreen
|
||||
*/
|
||||
canFullScreen: () => {
|
||||
return (
|
||||
IS_IPHONE &&
|
||||
typeof host.webkitSetPresentationMode === 'function' &&
|
||||
(host.webkitSupportsFullscreen ?? false)
|
||||
);
|
||||
},
|
||||
enter: () => {
|
||||
return host.webkitSetPresentationMode?.('fullscreen');
|
||||
},
|
||||
exit: () => {
|
||||
return host.webkitSetPresentationMode?.('inline');
|
||||
},
|
||||
bindEvents: () => {
|
||||
host.removeEventListener('webkitpresentationmodechanged', onChange);
|
||||
},
|
||||
unbindEvents: () => {
|
||||
host.addEventListener('webkitpresentationmodechanged', onChange);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {FullscreenAdapter} from '@common/player/state/fullscreen/fullscreen-adapter';
|
||||
import fscreen from 'fscreen';
|
||||
|
||||
export function createNativeFullscreenAdapter(
|
||||
host: HTMLElement,
|
||||
onChange: () => void
|
||||
): FullscreenAdapter {
|
||||
host = host.closest('.fullscreen-host') ?? host;
|
||||
return {
|
||||
isFullscreen: () => {
|
||||
if (fscreen.fullscreenElement === host) return true;
|
||||
try {
|
||||
// Throws in iOS Safari...
|
||||
return host.matches(
|
||||
// @ts-expect-error - `fullscreenPseudoClass` is missing from `@types/fscreen`.
|
||||
fscreen.fullscreenPseudoClass
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
canFullScreen: () => {
|
||||
return fscreen.fullscreenEnabled;
|
||||
},
|
||||
enter: () => {
|
||||
return fscreen.requestFullscreen(host);
|
||||
},
|
||||
exit: () => {
|
||||
return fscreen.exitFullscreen();
|
||||
},
|
||||
bindEvents: () => {
|
||||
fscreen.addEventListener('fullscreenchange', onChange);
|
||||
fscreen.addEventListener('fullscreenerror', onChange);
|
||||
},
|
||||
unbindEvents: () => {
|
||||
fscreen.removeEventListener('fullscreenchange', onChange);
|
||||
fscreen.removeEventListener('fullscreenerror', onChange);
|
||||
},
|
||||
};
|
||||
}
|
||||
8
common/resources/client/player/state/fullscreen/fullscreen-adapter.ts
Executable file
8
common/resources/client/player/state/fullscreen/fullscreen-adapter.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export interface FullscreenAdapter {
|
||||
isFullscreen: () => boolean;
|
||||
canFullScreen: () => boolean;
|
||||
enter: () => void;
|
||||
exit: () => void;
|
||||
bindEvents: () => void;
|
||||
unbindEvents: () => void;
|
||||
}
|
||||
112
common/resources/client/player/state/fullscreen/fullscreen-slice.ts
Executable file
112
common/resources/client/player/state/fullscreen/fullscreen-slice.ts
Executable file
@@ -0,0 +1,112 @@
|
||||
import {StateCreator} from 'zustand';
|
||||
import {
|
||||
PlayerState,
|
||||
ProviderListeners,
|
||||
} from '@common/player/state/player-state';
|
||||
import {ScreenOrientation} from '@common/player/state/fullscreen/screen-orientation';
|
||||
import {IS_IPHONE} from '@common/utils/platform';
|
||||
import {FullscreenAdapter} from '@common/player/state/fullscreen/fullscreen-adapter';
|
||||
import {createNativeFullscreenAdapter} from '@common/player/state/fullscreen/create-native-fullscreen-adapter';
|
||||
import {createIphoneFullscreenAdapter} from '@common/player/state/fullscreen/create-iphone-fullscreen-adapter';
|
||||
import {PipSlice} from '@common/player/state/pip/pip-slice';
|
||||
|
||||
export interface FullscreenSlice {
|
||||
isFullscreen: boolean;
|
||||
canFullscreen: boolean;
|
||||
enterFullscreen: () => void;
|
||||
exitFullscreen: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
initFullscreen: () => void;
|
||||
destroyFullscreen: () => void;
|
||||
}
|
||||
|
||||
type BaseSliceCreator = StateCreator<
|
||||
FullscreenSlice & PlayerState & PipSlice,
|
||||
[['zustand/immer', unknown]],
|
||||
[],
|
||||
FullscreenSlice
|
||||
>;
|
||||
|
||||
type StoreLice = BaseSliceCreator extends (...a: infer U) => infer R
|
||||
? (...a: [...U, Set<Partial<ProviderListeners>>]) => R
|
||||
: never;
|
||||
|
||||
const iPhoneProviderBlacklist = ['youtube'];
|
||||
|
||||
export const createFullscreenSlice: StoreLice = (set, get) => {
|
||||
let subscription: () => void | undefined;
|
||||
const orientation = new ScreenOrientation();
|
||||
let adapter: FullscreenAdapter | undefined;
|
||||
|
||||
const onFullscreenChange = async () => {
|
||||
const isFullscreen = adapter?.isFullscreen();
|
||||
if (isFullscreen) {
|
||||
// lock orientation to landscape
|
||||
orientation.lock();
|
||||
} else {
|
||||
orientation.unlock();
|
||||
}
|
||||
set({isFullscreen});
|
||||
};
|
||||
|
||||
const isSupported = (): boolean => {
|
||||
// iPhone only allows putting video element in fullscreen, and
|
||||
// there's no way to get access to it with YouTube iframe api
|
||||
if (IS_IPHONE && iPhoneProviderBlacklist.includes(get().providerName!)) {
|
||||
return false;
|
||||
}
|
||||
return adapter?.canFullScreen() ?? false;
|
||||
};
|
||||
|
||||
return {
|
||||
isFullscreen: false,
|
||||
canFullscreen: false,
|
||||
enterFullscreen: () => {
|
||||
if (!isSupported() || adapter?.isFullscreen()) return;
|
||||
|
||||
// exit pip if it's active
|
||||
if (get().isPip) {
|
||||
get().exitPip();
|
||||
}
|
||||
return adapter?.enter();
|
||||
},
|
||||
exitFullscreen: () => {
|
||||
if (!adapter?.isFullscreen()) return;
|
||||
return adapter.exit();
|
||||
},
|
||||
toggleFullscreen: () => {
|
||||
if (get().isFullscreen) {
|
||||
get().exitFullscreen();
|
||||
} else {
|
||||
get().enterFullscreen();
|
||||
}
|
||||
},
|
||||
initFullscreen: () => {
|
||||
subscription = get().subscribe({
|
||||
providerReady: ({el}) => {
|
||||
// when changing adapters, remove previous adapter events and exit fullscreen
|
||||
adapter?.unbindEvents();
|
||||
if (get().isFullscreen) {
|
||||
adapter?.exit();
|
||||
}
|
||||
// create new adapter, and if fullscreen is supported, bind events
|
||||
adapter = IS_IPHONE
|
||||
? createIphoneFullscreenAdapter(
|
||||
el as HTMLVideoElement,
|
||||
onFullscreenChange
|
||||
)
|
||||
: createNativeFullscreenAdapter(el, onFullscreenChange);
|
||||
const canFullscreen = isSupported();
|
||||
set({canFullscreen});
|
||||
if (canFullscreen) {
|
||||
adapter.bindEvents();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
destroyFullscreen: () => {
|
||||
get().exitFullscreen();
|
||||
subscription?.();
|
||||
},
|
||||
};
|
||||
};
|
||||
86
common/resources/client/player/state/fullscreen/screen-orientation.ts
Executable file
86
common/resources/client/player/state/fullscreen/screen-orientation.ts
Executable file
@@ -0,0 +1,86 @@
|
||||
export class ScreenOrientation {
|
||||
protected currentLock: OrientationLockType | undefined;
|
||||
|
||||
async lock(lockType: OrientationLockType = 'landscape') {
|
||||
if (!this.canOrientScreen() || this.currentLock) return;
|
||||
try {
|
||||
await (screen.orientation as any).lock(lockType);
|
||||
this.currentLock = lockType;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async unlock() {
|
||||
if (!this.canOrientScreen() || !this.currentLock) return;
|
||||
await screen.orientation.unlock();
|
||||
}
|
||||
|
||||
canOrientScreen(): boolean {
|
||||
return (
|
||||
screen.orientation != null &&
|
||||
!!(screen.orientation as any).lock &&
|
||||
!!screen.orientation.unlock
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type OrientationLockType =
|
||||
/**
|
||||
* Any is an orientation that means the screen can be locked to any one of portrait-primary,
|
||||
* portrait-secondary, landscape-primary and landscape-secondary.
|
||||
*/
|
||||
| 'any'
|
||||
|
||||
/**
|
||||
* Landscape is an orientation where the screen width is greater than the screen height and
|
||||
* depending on platform convention locking the screen to landscape can represent
|
||||
* landscape-primary, landscape-secondary or both.
|
||||
*/
|
||||
| 'landscape'
|
||||
|
||||
/**
|
||||
* Landscape-primary is an orientation where the screen width is greater than the screen height.
|
||||
* If the device's natural orientation is landscape, then it is in landscape-primary when held
|
||||
* in that position. If the device's natural orientation is portrait, the user agent sets
|
||||
* landscape-primary from the two options as shown in the screen orientation values table.
|
||||
*/
|
||||
| 'landscape-primary'
|
||||
|
||||
/**
|
||||
* Landscape-secondary is an orientation where the screen width is greater than the screen
|
||||
* height. If the device's natural orientation is landscape, it is in landscape-secondary when
|
||||
* rotated 180º from its natural orientation. If the device's natural orientation is portrait,
|
||||
* the user agent sets landscape-secondary from the two options as shown in the screen
|
||||
* orientation values table.
|
||||
*/
|
||||
| 'landscape-secondary'
|
||||
|
||||
/**
|
||||
* Natural is an orientation that refers to either portrait-primary or landscape-primary
|
||||
* depending on the device's usual orientation. This orientation is usually provided by the
|
||||
* underlying operating system.
|
||||
*/
|
||||
| 'natural'
|
||||
|
||||
/**
|
||||
* Portrait is an orientation where the screen width is less than or equal to the screen height
|
||||
* and depending on platform convention locking the screen to portrait can represent
|
||||
* portrait-primary, portrait-secondary or both.
|
||||
*/
|
||||
| 'portrait'
|
||||
|
||||
/**
|
||||
* Portrait-primary is an orientation where the screen width is less than or equal to the screen
|
||||
* height. If the device's natural orientation is portrait, then it is in portrait-primary when
|
||||
* held in that position. If the device's natural orientation is landscape, the user agent sets
|
||||
* portrait-primary from the two options as shown in the screen orientation values table.
|
||||
*/
|
||||
| 'portrait-primary'
|
||||
|
||||
/**
|
||||
* Portrait-secondary is an orientation where the screen width is less than or equal to the
|
||||
* screen height. If the device's natural orientation is portrait, then it is in
|
||||
* portrait-secondary when rotated 180º from its natural position. If the device's natural
|
||||
* orientation is landscape, the user agent sets portrait-secondary from the two options as
|
||||
* shown in the screen orientation values table.
|
||||
*/
|
||||
| 'portrait-secondary';
|
||||
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;
|
||||
};
|
||||
30
common/resources/client/player/state/player-events.ts
Executable file
30
common/resources/client/player/state/player-events.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {AudioTrack, MediaStreamType} from '@common/player/state/player-state';
|
||||
import {YouTubePlayerState} from '@common/player/providers/youtube/youtube-types';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
|
||||
export interface PlayerEvents {
|
||||
play: void;
|
||||
pause: void;
|
||||
error: {sourceEvent?: any; fatal?: boolean} | void;
|
||||
buffering: {isBuffering: boolean};
|
||||
buffered: {seconds: number};
|
||||
progress: {currentTime: number};
|
||||
playbackRateChange: {rate: number};
|
||||
playbackRates: {rates: number[]};
|
||||
playbackQualityChange: {quality: string};
|
||||
playbackQualities: {qualities: string[]};
|
||||
textTracks: {tracks: TextTrack[]};
|
||||
currentTextTrackChange: {trackId: number};
|
||||
textTrackVisibilityChange: {isVisible: boolean};
|
||||
audioTracks: {tracks: AudioTrack[]};
|
||||
currentAudioTrackChange: {trackId: number};
|
||||
durationChange: {duration: number};
|
||||
streamTypeChange: {streamType: MediaStreamType};
|
||||
posterLoaded: {url: string};
|
||||
seek: {time: number};
|
||||
playbackEnd: void;
|
||||
beforeCued: {previous: MediaItem | undefined};
|
||||
cued: void;
|
||||
providerReady: {el: HTMLElement};
|
||||
youtubeStateChange: {state: YouTubePlayerState};
|
||||
}
|
||||
16
common/resources/client/player/state/player-provider-api.tsx
Executable file
16
common/resources/client/player/state/player-provider-api.tsx
Executable file
@@ -0,0 +1,16 @@
|
||||
export interface PlayerProviderApi {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (value: number) => void;
|
||||
setMuted: (isMuted: boolean) => void;
|
||||
setPlaybackRate: (value: number) => void;
|
||||
setPlaybackQuality?: (value: string) => void;
|
||||
setTextTrackVisibility?: (isVisible: boolean) => void;
|
||||
setCurrentTextTrack?: (trackId: number) => void;
|
||||
setCurrentAudioTrack?: (trackId: number) => void;
|
||||
getCurrentTime: () => number;
|
||||
getSrc: () => string | undefined;
|
||||
internalProviderApi?: any;
|
||||
}
|
||||
116
common/resources/client/player/state/player-state.tsx
Executable file
116
common/resources/client/player/state/player-state.tsx
Executable file
@@ -0,0 +1,116 @@
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import type {createPlayerStore} from '@common/player/state/player-store';
|
||||
import {PlayerEvents} from '@common/player/state/player-events';
|
||||
import {PlayerProviderApi} from '@common/player/state/player-provider-api';
|
||||
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
|
||||
|
||||
export type RepeatMode = 'one' | 'all' | false;
|
||||
|
||||
export type MediaStreamType =
|
||||
| null
|
||||
| 'on-demand'
|
||||
| 'live'
|
||||
| 'live:dvr'
|
||||
| 'll-live'
|
||||
| 'll-live:dvr';
|
||||
|
||||
export type ProviderListeners = {
|
||||
[K in keyof PlayerEvents]: PlayerEvents[K] extends void | undefined | never
|
||||
? () => void
|
||||
: (payload: PlayerEvents[K]) => void;
|
||||
};
|
||||
|
||||
export type PlayerStoreApi = ReturnType<typeof createPlayerStore>;
|
||||
|
||||
export interface AudioTrack {
|
||||
id: number;
|
||||
label: string;
|
||||
language: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
options: PlayerStoreOptions;
|
||||
|
||||
// queue
|
||||
originalQueue: MediaItem[];
|
||||
shuffledQueue: MediaItem[];
|
||||
cuedMedia?: MediaItem;
|
||||
|
||||
// volume
|
||||
volume: number;
|
||||
setVolume: (value: number) => void;
|
||||
muted: boolean;
|
||||
setMuted: (isMuted: boolean) => void;
|
||||
|
||||
isBuffering: boolean;
|
||||
isPlaying: boolean;
|
||||
streamType: MediaStreamType;
|
||||
// whether playback has started at least once
|
||||
playbackStarted: boolean;
|
||||
// whether provider is ready to start playback, this will get set to true when media metadata is loaded
|
||||
providerReady: boolean;
|
||||
mediaDuration: number;
|
||||
getCurrentTime: () => number;
|
||||
|
||||
// playback rate and speed
|
||||
playbackRate: number;
|
||||
setPlaybackRate: (value: number) => void;
|
||||
playbackRates: number[];
|
||||
playbackQuality: string;
|
||||
setPlaybackQuality: (quality: string) => void;
|
||||
playbackQualities: string[];
|
||||
|
||||
// will only be set to true on seekbar pointerDown and false on pointerUp
|
||||
isSeeking: boolean;
|
||||
setIsSeeking: (isSeeking: boolean) => void;
|
||||
pauseWhileSeeking: boolean;
|
||||
|
||||
controlsVisible: boolean;
|
||||
setControlsVisible: (isVisible: boolean) => void;
|
||||
|
||||
repeat: RepeatMode;
|
||||
toggleRepeatMode: () => void;
|
||||
|
||||
shuffling: boolean;
|
||||
toggleShuffling: () => void;
|
||||
|
||||
posterUrl?: string;
|
||||
|
||||
textTrackIsVisible: boolean;
|
||||
setTextTrackVisibility: (isVisible: boolean) => void;
|
||||
textTracks: TextTrack[];
|
||||
currentTextTrack: number;
|
||||
setCurrentTextTrack: (id: number) => void;
|
||||
|
||||
audioTracks: AudioTrack[];
|
||||
currentAudioTrack: number;
|
||||
setCurrentAudioTrack: (id: number) => void;
|
||||
|
||||
providerName?: MediaItem['provider'];
|
||||
providerApi?: PlayerProviderApi;
|
||||
|
||||
// actions
|
||||
cue: (media: MediaItem) => Promise<void>;
|
||||
play: (media?: MediaItem) => Promise<void>;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
playPrevious: () => void;
|
||||
playNext: () => void;
|
||||
seek: (time: number | string) => void;
|
||||
overrideQueue: (
|
||||
mediaItems: MediaItem[],
|
||||
queuePointer?: number
|
||||
) => Promise<void>;
|
||||
appendToQueue: (mediaItems: MediaItem[], afterCuedMedia?: boolean) => void;
|
||||
removeFromQueue: (mediaItems: MediaItem[]) => void;
|
||||
subscribe: (listeners: Partial<ProviderListeners>) => () => void;
|
||||
emit: <T extends keyof PlayerEvents>(
|
||||
event: T,
|
||||
...payload: PlayerEvents[T] extends void | undefined | never
|
||||
? []
|
||||
: [PlayerEvents[T]]
|
||||
) => void;
|
||||
destroy: () => void;
|
||||
init: () => void;
|
||||
}
|
||||
39
common/resources/client/player/state/player-store-options.ts
Executable file
39
common/resources/client/player/state/player-store-options.ts
Executable file
@@ -0,0 +1,39 @@
|
||||
import {MediaItem, YoutubeMediaItem} from '@common/player/media-item';
|
||||
import {PlayerInitialData} from '@common/player/utils/player-local-storage';
|
||||
import type {PlayerState} from '@common/player/state/player-state';
|
||||
import {YouTubePlayerState} from '@common/player/providers/youtube/youtube-types';
|
||||
import {PlayerEvents} from '@common/player/state/player-events';
|
||||
|
||||
// all listeners specified by user will get full player state passed
|
||||
// along with original event payload, so it needs a separate type
|
||||
type ListenersWithState = {
|
||||
[K in keyof PlayerEvents]: PlayerEvents[K] extends void | undefined | never
|
||||
? (payload: {state: PlayerState}) => void
|
||||
: (payload: PlayerEvents[K] & {state: PlayerState}) => void;
|
||||
};
|
||||
// makes it easier to access sourceEvent for user without having to use "in" operator to narrow types
|
||||
type Listeners = Omit<ListenersWithState, 'error'> & {
|
||||
error: (payload: {state: PlayerState; sourceEvent?: any}) => void;
|
||||
};
|
||||
|
||||
export interface PlayerStoreOptions {
|
||||
persistQueueInLocalStorage?: boolean;
|
||||
autoPlay?: boolean;
|
||||
initialData?: PlayerInitialData;
|
||||
listeners?: Partial<Listeners>;
|
||||
defaultVolume?: number;
|
||||
pauseWhileSeeking?: boolean;
|
||||
onBeforePlayNext?: (media?: MediaItem) => boolean | undefined;
|
||||
onBeforePlayPrevious?: (media?: MediaItem) => boolean | undefined;
|
||||
onDestroy?: () => void;
|
||||
setMediaSessionMetadata?: (mediaItem: MediaItem) => void;
|
||||
onBeforePlay?: () => Promise<void> | undefined;
|
||||
loadMoreMediaItems?: (
|
||||
mediaItem?: MediaItem
|
||||
) => Promise<MediaItem[] | undefined>;
|
||||
youtube?: {
|
||||
srcResolver?: (mediaItem: YoutubeMediaItem) => Promise<YoutubeMediaItem>;
|
||||
onStateChange?: (state: YouTubePlayerState) => void;
|
||||
useCookies?: boolean;
|
||||
};
|
||||
}
|
||||
460
common/resources/client/player/state/player-store.tsx
Executable file
460
common/resources/client/player/state/player-store.tsx
Executable file
@@ -0,0 +1,460 @@
|
||||
import {createStore} from 'zustand';
|
||||
import {immer} from 'zustand/middleware/immer';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {setInLocalStorage as _setInLocalStorage} from '@common/utils/hooks/local-storage';
|
||||
import {shuffleArray} from '@common/utils/array/shuffle-array';
|
||||
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
|
||||
import {getPlayerStateFromLocalStorage} from '@common/player/utils/player-local-storage';
|
||||
import {prependToArrayAtIndex} from '@common/utils/array/prepend-to-array-at-index';
|
||||
import deepMerge from 'deepmerge';
|
||||
import {resetMediaSession} from '@common/player/utils/reset-media-session';
|
||||
import {playerQueue} from '@common/player/player-queue';
|
||||
import type {
|
||||
PlayerState,
|
||||
ProviderListeners,
|
||||
RepeatMode,
|
||||
} from '@common/player/state/player-state';
|
||||
import {handlePlayerKeybinds} from '@common/player/handle-player-keybinds';
|
||||
import {initPlayerMediaSession} from '@common/player/utils/init-player-media-session';
|
||||
import {isSameMedia} from '@common/player/utils/is-same-media';
|
||||
import {
|
||||
createFullscreenSlice,
|
||||
FullscreenSlice,
|
||||
} from '@common/player/state/fullscreen/fullscreen-slice';
|
||||
import {createPipSlice, PipSlice} from '@common/player/state/pip/pip-slice';
|
||||
import {subscribeWithSelector} from 'zustand/middleware';
|
||||
|
||||
export const createPlayerStore = (
|
||||
id: string | number,
|
||||
options: PlayerStoreOptions,
|
||||
) => {
|
||||
// initialData from options should take priority over local storage data
|
||||
const initialData = deepMerge(
|
||||
getPlayerStateFromLocalStorage(id, options),
|
||||
options.initialData || {},
|
||||
);
|
||||
|
||||
const setInLocalStorage = (key: string, value: any) => {
|
||||
_setInLocalStorage(`player.${id}.${key}`, value);
|
||||
};
|
||||
|
||||
return createStore<PlayerState & FullscreenSlice & PipSlice>()(
|
||||
subscribeWithSelector(
|
||||
immer((set, get, store) => {
|
||||
const listeners = new Set<Partial<ProviderListeners>>();
|
||||
const internalListeners: Partial<ProviderListeners> = {
|
||||
play: () => {
|
||||
set(s => {
|
||||
s.isPlaying = true;
|
||||
s.playbackStarted = true;
|
||||
});
|
||||
},
|
||||
pause: () => {
|
||||
set(s => {
|
||||
s.isPlaying = false;
|
||||
s.controlsVisible = true;
|
||||
});
|
||||
},
|
||||
error: e => {
|
||||
set(s => {
|
||||
// there could be a number of non-fatal errors where player will continue to
|
||||
// work properly, like autoplay error from HTML5 video or buffer full from HLS
|
||||
if (e?.fatal) {
|
||||
s.isPlaying = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
durationChange: payload => {
|
||||
set({mediaDuration: payload.duration});
|
||||
},
|
||||
streamTypeChange: payload => {
|
||||
set({streamType: payload.streamType});
|
||||
},
|
||||
buffered: payload => {
|
||||
//
|
||||
},
|
||||
playbackRateChange: payload => {
|
||||
set({playbackRate: payload.rate});
|
||||
},
|
||||
playbackRates: ({rates}) => {
|
||||
set({playbackRates: rates});
|
||||
},
|
||||
playbackQualities: ({qualities}) => {
|
||||
set({playbackQualities: qualities});
|
||||
},
|
||||
audioTracks: ({tracks}) => {
|
||||
set({audioTracks: tracks});
|
||||
},
|
||||
currentAudioTrackChange: ({trackId}) => {
|
||||
set({currentAudioTrack: trackId});
|
||||
},
|
||||
playbackQualityChange: ({quality}) => {
|
||||
set({playbackQuality: quality});
|
||||
},
|
||||
textTracks: ({tracks}) => {
|
||||
set({textTracks: tracks});
|
||||
},
|
||||
currentTextTrackChange: ({trackId}) => {
|
||||
set({currentTextTrack: trackId});
|
||||
},
|
||||
textTrackVisibilityChange: ({isVisible}) => {
|
||||
set({textTrackIsVisible: isVisible});
|
||||
},
|
||||
buffering: ({isBuffering}) => {
|
||||
set({isBuffering});
|
||||
},
|
||||
playbackEnd: async () => {
|
||||
const media = get().cuedMedia;
|
||||
|
||||
// don't play next or repeat while seeking via seekbar
|
||||
if (get().isSeeking) return;
|
||||
if (queue.isLast() && options.loadMoreMediaItems) {
|
||||
const items = await options.loadMoreMediaItems(media);
|
||||
if (items?.length) {
|
||||
get().appendToQueue(items);
|
||||
}
|
||||
}
|
||||
|
||||
get().playNext();
|
||||
},
|
||||
posterLoaded: ({url}) => {
|
||||
set({posterUrl: url});
|
||||
},
|
||||
providerReady: () => {
|
||||
const provider = get().providerApi;
|
||||
if (provider) {
|
||||
provider.setVolume(get().volume);
|
||||
provider.setMuted(get().muted);
|
||||
if (options.autoPlay) {
|
||||
provider.play();
|
||||
}
|
||||
set({providerReady: true});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const queue = playerQueue(get);
|
||||
|
||||
const keybindsHandler = (e: KeyboardEvent) => {
|
||||
handlePlayerKeybinds(e, get);
|
||||
};
|
||||
|
||||
const initialQueue = initialData.queue || [];
|
||||
return {
|
||||
options,
|
||||
...createFullscreenSlice(set, get, store, listeners),
|
||||
...createPipSlice(set, get, store, listeners),
|
||||
originalQueue: initialQueue,
|
||||
shuffledQueue: initialData.state?.shuffling
|
||||
? shuffleArray(initialQueue)
|
||||
: initialQueue,
|
||||
isPlaying: false,
|
||||
isBuffering: false,
|
||||
streamType: null,
|
||||
playbackStarted: false,
|
||||
providerReady: false,
|
||||
pauseWhileSeeking: options.pauseWhileSeeking ?? true,
|
||||
isSeeking: false,
|
||||
setIsSeeking: (isSeeking: boolean) => {
|
||||
set({isSeeking});
|
||||
},
|
||||
controlsVisible: true,
|
||||
setControlsVisible: (isVisible: boolean) => {
|
||||
set(s => {
|
||||
s.controlsVisible = isVisible;
|
||||
});
|
||||
},
|
||||
volume: initialData.state?.volume ?? 30,
|
||||
setVolume: value => {
|
||||
get().providerApi?.setVolume(value);
|
||||
set(s => {
|
||||
s.volume = value;
|
||||
});
|
||||
setInLocalStorage('volume', value);
|
||||
},
|
||||
muted: initialData.state?.muted ?? false,
|
||||
setMuted: isMuted => {
|
||||
get().providerApi?.setMuted(isMuted);
|
||||
set(s => {
|
||||
s.muted = isMuted;
|
||||
});
|
||||
setInLocalStorage('muted', isMuted);
|
||||
},
|
||||
playbackRates: [],
|
||||
playbackRate: 1,
|
||||
setPlaybackRate: speed => {
|
||||
get().providerApi?.setPlaybackRate(speed);
|
||||
},
|
||||
playbackQuality: 'auto',
|
||||
setPlaybackQuality: quality => {
|
||||
get().providerApi?.setPlaybackQuality?.(quality);
|
||||
},
|
||||
playbackQualities: [],
|
||||
repeat: initialData.state?.repeat ?? 'all',
|
||||
toggleRepeatMode: () => {
|
||||
let newRepeat: RepeatMode = 'all';
|
||||
const currentRepeat = get().repeat;
|
||||
if (currentRepeat === 'all') {
|
||||
newRepeat = 'one';
|
||||
} else if (currentRepeat === 'one') {
|
||||
newRepeat = false;
|
||||
}
|
||||
|
||||
set({repeat: newRepeat});
|
||||
setInLocalStorage('repeat', newRepeat);
|
||||
},
|
||||
shuffling: initialData.state?.shuffling ?? false,
|
||||
toggleShuffling: () => {
|
||||
let newQueue: MediaItem[] = [];
|
||||
|
||||
if (get().shuffling) {
|
||||
newQueue = get().originalQueue;
|
||||
} else {
|
||||
newQueue = shuffleArray([...get().shuffledQueue]);
|
||||
}
|
||||
|
||||
set(s => {
|
||||
s.shuffling = !s.shuffling;
|
||||
s.shuffledQueue = newQueue;
|
||||
});
|
||||
},
|
||||
mediaDuration: 0,
|
||||
seek: time => {
|
||||
const timeStr = `${time}`;
|
||||
if (timeStr.startsWith('+')) {
|
||||
time = get().getCurrentTime() + Number(time);
|
||||
} else if (timeStr.startsWith('-')) {
|
||||
time = get().getCurrentTime() - Number(timeStr.replace('-', ''));
|
||||
} else {
|
||||
time = Number(time);
|
||||
}
|
||||
get().providerApi?.seek(time);
|
||||
get().emit('seek', {time});
|
||||
},
|
||||
getCurrentTime: () => {
|
||||
return get().providerApi?.getCurrentTime() || 0;
|
||||
},
|
||||
play: async media => {
|
||||
// get currently active queue item, if none is provided
|
||||
if (media) {
|
||||
await get().cue(media);
|
||||
} else {
|
||||
media = get().cuedMedia || queue.getCurrent();
|
||||
}
|
||||
// if no media to play, stop player and bail
|
||||
if (!media) {
|
||||
get().stop();
|
||||
return;
|
||||
}
|
||||
await options.onBeforePlay?.();
|
||||
await get().providerApi?.play();
|
||||
},
|
||||
pause: () => {
|
||||
get().providerApi?.pause();
|
||||
},
|
||||
stop: () => {
|
||||
if (!get().isPlaying) return;
|
||||
get().pause();
|
||||
get().seek(0);
|
||||
},
|
||||
playNext: async () => {
|
||||
get().stop();
|
||||
let media = queue.getCurrent();
|
||||
|
||||
if (get().repeat === 'all' && queue.isLast()) {
|
||||
media = queue.getFirst();
|
||||
} else if (get().repeat !== 'one') {
|
||||
media = queue.getNext();
|
||||
}
|
||||
|
||||
// YouTube provider will not play the same tray unless we wait some time after playback end
|
||||
if (get().repeat === 'one' && get().providerName === 'youtube') {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// allow user to handle playing next track
|
||||
if (options.onBeforePlayNext?.(media)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (media) {
|
||||
await get().play(media);
|
||||
} else {
|
||||
get().seek(0);
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
playPrevious: async () => {
|
||||
get().stop();
|
||||
let media = queue.getCurrent();
|
||||
|
||||
if (get().repeat === 'all' && queue.getPointer() === 0) {
|
||||
media = queue.getLast();
|
||||
} else if (get().repeat !== 'one') {
|
||||
media = queue.getPrevious();
|
||||
}
|
||||
|
||||
// allow user to handle playing previous track
|
||||
if (options.onBeforePlayPrevious?.(media)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (media) {
|
||||
await get().play(media);
|
||||
} else {
|
||||
get().seek(0);
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
cue: async media => {
|
||||
if (isSameMedia(media, get().cuedMedia)) return;
|
||||
|
||||
get().emit('beforeCued', {previous: get().cuedMedia});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const previousProvider = get().providerName;
|
||||
|
||||
// wait until media is cued on provider or 3 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}, 3000);
|
||||
const unsubscribe = get().subscribe({
|
||||
cued: () => {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
error: e => {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe();
|
||||
reject('Could not cue media');
|
||||
},
|
||||
});
|
||||
|
||||
set({
|
||||
cuedMedia: media,
|
||||
posterUrl: media.poster,
|
||||
providerName: media.provider,
|
||||
providerReady: previousProvider === media.provider,
|
||||
streamType: 'streamType' in media ? media.streamType : null,
|
||||
});
|
||||
|
||||
if (media) {
|
||||
options.setMediaSessionMetadata?.(media);
|
||||
}
|
||||
|
||||
if (options.persistQueueInLocalStorage) {
|
||||
setInLocalStorage('cuedMediaId', media.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
async overrideQueue(
|
||||
mediaItems: MediaItem[],
|
||||
queuePointer: number = 0,
|
||||
): Promise<any> {
|
||||
if (!mediaItems?.length) return;
|
||||
const items = [...mediaItems];
|
||||
set(s => {
|
||||
s.shuffledQueue = get().shuffling
|
||||
? shuffleArray(items, true)
|
||||
: items;
|
||||
s.originalQueue = items;
|
||||
});
|
||||
if (options.persistQueueInLocalStorage) {
|
||||
setInLocalStorage('queue', get().originalQueue.slice(0, 15));
|
||||
}
|
||||
const media =
|
||||
queuePointer > -1 ? mediaItems[queuePointer] : queue.getCurrent();
|
||||
if (media) {
|
||||
return get().cue(media);
|
||||
}
|
||||
},
|
||||
appendToQueue: (mediaItems, afterCuedMedia = true) => {
|
||||
const shuffledNewItems = get().shuffling
|
||||
? shuffleArray([...mediaItems])
|
||||
: [...mediaItems];
|
||||
const index = afterCuedMedia ? queue.getPointer() : 0;
|
||||
set(s => {
|
||||
s.shuffledQueue = prependToArrayAtIndex(
|
||||
s.shuffledQueue,
|
||||
shuffledNewItems,
|
||||
index,
|
||||
);
|
||||
s.originalQueue = prependToArrayAtIndex(
|
||||
s.originalQueue,
|
||||
mediaItems,
|
||||
index,
|
||||
);
|
||||
});
|
||||
if (options.persistQueueInLocalStorage) {
|
||||
setInLocalStorage('queue', get().originalQueue.slice(0, 15));
|
||||
}
|
||||
},
|
||||
removeFromQueue: mediaItems => {
|
||||
set(s => {
|
||||
s.shuffledQueue = s.shuffledQueue.filter(
|
||||
item => !mediaItems.find(m => isSameMedia(m, item)),
|
||||
);
|
||||
s.originalQueue = s.originalQueue.filter(
|
||||
item => !mediaItems.find(m => isSameMedia(m, item)),
|
||||
);
|
||||
});
|
||||
if (options.persistQueueInLocalStorage) {
|
||||
setInLocalStorage('queue', get().originalQueue.slice(0, 15));
|
||||
}
|
||||
},
|
||||
textTracks: [],
|
||||
currentTextTrack: -1,
|
||||
setCurrentTextTrack: trackId => {
|
||||
get().providerApi?.setCurrentTextTrack?.(trackId);
|
||||
},
|
||||
textTrackIsVisible: false,
|
||||
setTextTrackVisibility: isVisible => {
|
||||
get().providerApi?.setTextTrackVisibility?.(isVisible);
|
||||
},
|
||||
audioTracks: [],
|
||||
currentAudioTrack: -1,
|
||||
setCurrentAudioTrack: trackId => {
|
||||
get().providerApi?.setCurrentAudioTrack?.(trackId);
|
||||
},
|
||||
destroy: () => {
|
||||
get().destroyFullscreen();
|
||||
get().destroyPip();
|
||||
options?.onDestroy?.();
|
||||
resetMediaSession();
|
||||
listeners.clear();
|
||||
document.removeEventListener('keydown', keybindsHandler);
|
||||
},
|
||||
init: async () => {
|
||||
// add initial and listeners from options, these will be present for the entire lifetime of the player
|
||||
get().initFullscreen();
|
||||
|
||||
listeners.add(internalListeners);
|
||||
if (options.listeners) {
|
||||
listeners.add(options.listeners as Partial<ProviderListeners>);
|
||||
}
|
||||
|
||||
const mediaId =
|
||||
initialData.cuedMediaId || initialData.queue?.[0]?.id;
|
||||
const mediaToCue = initialData.queue?.find(
|
||||
media => media.id === mediaId,
|
||||
);
|
||||
if (mediaToCue) {
|
||||
await get().cue(mediaToCue);
|
||||
}
|
||||
initPlayerMediaSession(get, options);
|
||||
document.addEventListener('keydown', keybindsHandler);
|
||||
},
|
||||
subscribe: newListeners => {
|
||||
listeners.add(newListeners);
|
||||
return () => listeners.delete(newListeners);
|
||||
},
|
||||
emit(event, payload?: any) {
|
||||
listeners.forEach(l => l[event]?.({state: get(), ...payload}));
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user