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