first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -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);
},
};
}

View File

@@ -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);
},
};
}

View File

@@ -0,0 +1,8 @@
export interface FullscreenAdapter {
isFullscreen: () => boolean;
canFullScreen: () => boolean;
enter: () => void;
exit: () => void;
bindEvents: () => void;
unbindEvents: () => void;
}

View 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?.();
},
};
};

View 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';

View 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;
};

View File

@@ -0,0 +1,8 @@
export interface PipAdapter {
isSupported: () => boolean;
isPip: () => boolean;
enter: () => Promise<unknown> | undefined;
exit: () => Promise<unknown> | undefined;
bindEvents: () => void;
unbindEvents: () => void;
}

View 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?.();
},
};
};

View 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;
};

View 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};
}

View 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;
}

View 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;
}

View 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;
};
}

View 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}));
},
};
}),
),
);
};