461 lines
16 KiB
TypeScript
Executable File
461 lines
16 KiB
TypeScript
Executable File
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}));
|
|
},
|
|
};
|
|
}),
|
|
),
|
|
);
|
|
};
|