41
common/resources/client/player/handle-player-keybinds.ts
Executable file
41
common/resources/client/player/handle-player-keybinds.ts
Executable file
@@ -0,0 +1,41 @@
|
||||
import {PlayerState} from '@common/player/state/player-state';
|
||||
import {isCtrlOrShiftPressed} from '@common/utils/keybinds/is-ctrl-or-shift-pressed';
|
||||
|
||||
export function handlePlayerKeybinds(
|
||||
e: KeyboardEvent,
|
||||
state: () => PlayerState,
|
||||
) {
|
||||
if (
|
||||
['input', 'textarea'].includes(
|
||||
(e.target as HTMLElement)?.tagName.toLowerCase(),
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (e.key === ' ' || e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (state().isPlaying) {
|
||||
state().pause();
|
||||
} else {
|
||||
state().play();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
if (isCtrlOrShiftPressed(e)) {
|
||||
state().playPrevious();
|
||||
} else {
|
||||
state().seek(state().getCurrentTime() - 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
if (isCtrlOrShiftPressed(e)) {
|
||||
state().playNext();
|
||||
} else {
|
||||
state().seek(state().getCurrentTime() + 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
common/resources/client/player/hooks/use-current-time.ts
Executable file
47
common/resources/client/player/hooks/use-current-time.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
|
||||
interface Props {
|
||||
precision: 'ms' | 'seconds';
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function useCurrentTime(
|
||||
{precision, disabled}: Props = {precision: 'ms', disabled: false},
|
||||
) {
|
||||
const timeRef = useRef(0);
|
||||
const {subscribe, getCurrentTime} = usePlayerActions();
|
||||
const providerKey = usePlayerStore(s =>
|
||||
s.providerName && s.cuedMedia?.id
|
||||
? `${s.providerName}+${s.cuedMedia.id}`
|
||||
: null,
|
||||
);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(() => getCurrentTime());
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: () => void;
|
||||
if (!disabled) {
|
||||
unsubscribe = subscribe({
|
||||
progress: ({currentTime}) => {
|
||||
const time =
|
||||
precision === 'ms' ? currentTime : Math.floor(currentTime);
|
||||
if (timeRef.current !== time) {
|
||||
setCurrentTime(time);
|
||||
timeRef.current = time;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return () => unsubscribe?.();
|
||||
}, [precision, subscribe, disabled]);
|
||||
|
||||
// update current time when media or provider changes
|
||||
useEffect(() => {
|
||||
if (providerKey) {
|
||||
setCurrentTime(getCurrentTime());
|
||||
}
|
||||
}, [providerKey, getCurrentTime]);
|
||||
|
||||
return currentTime;
|
||||
}
|
||||
5
common/resources/client/player/hooks/use-is-media-cued.ts
Executable file
5
common/resources/client/player/hooks/use-is-media-cued.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
|
||||
export function useIsMediaCued(mediaId: string | number): boolean {
|
||||
return usePlayerStore(s => s.cuedMedia?.id === mediaId);
|
||||
}
|
||||
14
common/resources/client/player/hooks/use-is-media-playing.ts
Executable file
14
common/resources/client/player/hooks/use-is-media-playing.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
|
||||
export function useIsMediaPlaying(
|
||||
mediaId: string | number,
|
||||
groupId?: string | number
|
||||
): boolean {
|
||||
return usePlayerStore(s => {
|
||||
return (
|
||||
s.isPlaying &&
|
||||
s.cuedMedia?.id === mediaId &&
|
||||
(!groupId || groupId === s.cuedMedia.groupId)
|
||||
);
|
||||
});
|
||||
}
|
||||
55
common/resources/client/player/hooks/use-player-actions.ts
Executable file
55
common/resources/client/player/hooks/use-player-actions.ts
Executable file
@@ -0,0 +1,55 @@
|
||||
import {useContext, useMemo} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
|
||||
export type PlayerActions = ReturnType<typeof usePlayerActions>;
|
||||
|
||||
export function usePlayerActions() {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
return useMemo(() => {
|
||||
const s = store.getState();
|
||||
|
||||
const overrideQueueAndPlay = async (
|
||||
mediaItems: MediaItem[],
|
||||
queuePointer?: number
|
||||
) => {
|
||||
s.stop();
|
||||
await s.overrideQueue(mediaItems, queuePointer);
|
||||
return s.play();
|
||||
};
|
||||
|
||||
return {
|
||||
play: s.play,
|
||||
playNext: s.playNext,
|
||||
playPrevious: s.playPrevious,
|
||||
pause: s.pause,
|
||||
subscribe: s.subscribe,
|
||||
emit: s.emit,
|
||||
getCurrentTime: s.getCurrentTime,
|
||||
seek: s.seek,
|
||||
toggleRepeatMode: s.toggleRepeatMode,
|
||||
toggleShuffling: s.toggleShuffling,
|
||||
getState: store.getState,
|
||||
setVolume: s.setVolume,
|
||||
setMuted: s.setMuted,
|
||||
appendToQueue: s.appendToQueue,
|
||||
removeFromQueue: s.removeFromQueue,
|
||||
enterFullscreen: s.enterFullscreen,
|
||||
exitFullscreen: s.exitFullscreen,
|
||||
toggleFullscreen: s.toggleFullscreen,
|
||||
enterPip: s.enterPip,
|
||||
exitPip: s.exitPip,
|
||||
setTextTrackVisibility: s.setTextTrackVisibility,
|
||||
setCurrentTextTrack: s.setCurrentTextTrack,
|
||||
setCurrentAudioTrack: s.setCurrentAudioTrack,
|
||||
setIsSeeking: s.setIsSeeking,
|
||||
setControlsVisible: s.setControlsVisible,
|
||||
cue: s.cue,
|
||||
overrideQueueAndPlay,
|
||||
overrideQueue: s.overrideQueue,
|
||||
setPlaybackRate: s.setPlaybackRate,
|
||||
setPlaybackQuality: s.setPlaybackQuality,
|
||||
};
|
||||
}, [store]);
|
||||
}
|
||||
29
common/resources/client/player/hooks/use-player-click-handler.ts
Executable file
29
common/resources/client/player/hooks/use-player-click-handler.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import {useCallback, useRef} from 'react';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
|
||||
export function usePlayerClickHandler() {
|
||||
const clickRef = useRef(0);
|
||||
const player = usePlayerActions();
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (player.getState().isPlaying) {
|
||||
player.pause();
|
||||
} else {
|
||||
player.play();
|
||||
}
|
||||
}, [player]);
|
||||
|
||||
return useCallback(() => {
|
||||
if (!player.getState().providerReady) return;
|
||||
clickRef.current += 1;
|
||||
togglePlay();
|
||||
if (clickRef.current === 1) {
|
||||
setTimeout(() => {
|
||||
if (clickRef.current > 1) {
|
||||
player.toggleFullscreen();
|
||||
}
|
||||
clickRef.current = 0;
|
||||
}, 300);
|
||||
}
|
||||
}, [player, togglePlay]);
|
||||
}
|
||||
29
common/resources/client/player/hooks/use-player-store.tsx
Executable file
29
common/resources/client/player/hooks/use-player-store.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import {StoreApi} from 'zustand';
|
||||
import {useContext} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {PlayerState} from '@common/player/state/player-state';
|
||||
import {FullscreenSlice} from '@common/player/state/fullscreen/fullscreen-slice';
|
||||
import {PipSlice} from '@common/player/state/pip/pip-slice';
|
||||
import {useStoreWithEqualityFn} from 'zustand/traditional';
|
||||
|
||||
type ExtractState<S> = S extends {
|
||||
getState: () => infer T;
|
||||
}
|
||||
? T
|
||||
: never;
|
||||
|
||||
type UsePlayerStore = {
|
||||
(): ExtractState<StoreApi<PlayerState>>;
|
||||
<U>(
|
||||
selector: (
|
||||
state: ExtractState<StoreApi<PlayerState & FullscreenSlice & PipSlice>>
|
||||
) => U,
|
||||
equalityFn?: (a: U, b: U) => boolean
|
||||
): U;
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
export const usePlayerStore: UsePlayerStore = (selector, equalityFn) => {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
return useStoreWithEqualityFn(store, selector, equalityFn);
|
||||
};
|
||||
50
common/resources/client/player/media-item.ts
Executable file
50
common/resources/client/player/media-item.ts
Executable file
@@ -0,0 +1,50 @@
|
||||
import {MediaStreamType} from '@common/player/state/player-state';
|
||||
|
||||
interface BaseMediaItem<T = any> {
|
||||
id: string | number;
|
||||
groupId?: string | number;
|
||||
provider: 'youtube' | 'htmlAudio' | 'htmlVideo' | 'hls' | 'dash';
|
||||
meta?: T;
|
||||
initialTime?: number;
|
||||
poster?: string;
|
||||
captions?: {
|
||||
id: string | number;
|
||||
label: string;
|
||||
src: string;
|
||||
language?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface YoutubeMediaItem<T = any> extends BaseMediaItem<T> {
|
||||
provider: 'youtube';
|
||||
src: 'resolve' | string;
|
||||
}
|
||||
|
||||
export interface HlsMediaItem<T = any> extends BaseMediaItem<T> {
|
||||
provider: 'hls';
|
||||
src: string;
|
||||
streamType?: MediaStreamType;
|
||||
}
|
||||
|
||||
export interface DashMediaItem<T = any> extends BaseMediaItem<T> {
|
||||
provider: 'dash';
|
||||
src: string;
|
||||
streamType?: MediaStreamType;
|
||||
}
|
||||
|
||||
export interface HtmlAudioMediaItem<T = any> extends BaseMediaItem<T> {
|
||||
provider: 'htmlAudio';
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface HtmlVideoMediaItem<T = any> extends BaseMediaItem<T> {
|
||||
provider: 'htmlVideo';
|
||||
src: string;
|
||||
}
|
||||
|
||||
export type MediaItem<T = any> =
|
||||
| YoutubeMediaItem<T>
|
||||
| HtmlAudioMediaItem<T>
|
||||
| HtmlVideoMediaItem<T>
|
||||
| HlsMediaItem<T>
|
||||
| DashMediaItem<T>;
|
||||
24
common/resources/client/player/player-context.tsx
Executable file
24
common/resources/client/player/player-context.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import {createContext, ReactNode, useState} from 'react';
|
||||
import {createPlayerStore} from '@common/player/state/player-store';
|
||||
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
|
||||
import type {PlayerStoreApi} from '@common/player/state/player-state';
|
||||
|
||||
export const PlayerStoreContext = createContext<PlayerStoreApi>(null!);
|
||||
|
||||
interface PlayerContextProps {
|
||||
children: ReactNode;
|
||||
id: string | number;
|
||||
options: PlayerStoreOptions;
|
||||
}
|
||||
export function PlayerContext({children, id, options}: PlayerContextProps) {
|
||||
//lazily create store object only once
|
||||
const [store] = useState(() => {
|
||||
return createPlayerStore(id, options);
|
||||
});
|
||||
|
||||
return (
|
||||
<PlayerStoreContext.Provider value={store}>
|
||||
{children}
|
||||
</PlayerStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
43
common/resources/client/player/player-queue.ts
Executable file
43
common/resources/client/player/player-queue.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {PlayerState} from '@common/player/state/player-state';
|
||||
|
||||
export function playerQueue(state: () => PlayerState) {
|
||||
const getPointer = (): number => {
|
||||
if (state().cuedMedia) {
|
||||
return (
|
||||
state().shuffledQueue.findIndex(
|
||||
item => item.id === state().cuedMedia?.id
|
||||
) || 0
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const getCurrent = (): MediaItem | undefined => {
|
||||
return state().shuffledQueue[getPointer()];
|
||||
};
|
||||
const getFirst = (): MediaItem | undefined => {
|
||||
return state().shuffledQueue[0];
|
||||
};
|
||||
const getLast = (): MediaItem | undefined => {
|
||||
return state().shuffledQueue[state().shuffledQueue.length - 1];
|
||||
};
|
||||
const getNext = (): MediaItem | undefined => {
|
||||
return state().shuffledQueue[getPointer() + 1];
|
||||
};
|
||||
const getPrevious = (): MediaItem | undefined => {
|
||||
return state().shuffledQueue[getPointer() - 1];
|
||||
};
|
||||
const isLast = (): boolean => {
|
||||
return getPointer() === state().originalQueue.length - 1;
|
||||
};
|
||||
|
||||
return {
|
||||
getPointer,
|
||||
getCurrent,
|
||||
getFirst,
|
||||
getLast,
|
||||
getNext,
|
||||
getPrevious,
|
||||
isLast,
|
||||
};
|
||||
}
|
||||
7
common/resources/client/player/player-styles.css
vendored
Executable file
7
common/resources/client/player/player-styles.css
vendored
Executable file
@@ -0,0 +1,7 @@
|
||||
.player-bottom-gradient {
|
||||
background-image: linear-gradient(to top, rgb(0 0 0 / 0.5), 10%, transparent, 95%, rgb(0 0 0 / 0.3));
|
||||
}
|
||||
|
||||
.player-bottom-text-shadow {
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
119
common/resources/client/player/providers/dash-provider.tsx
Executable file
119
common/resources/client/player/providers/dash-provider.tsx
Executable file
@@ -0,0 +1,119 @@
|
||||
import {useCallback, useContext, useEffect, useRef, useState} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
import {MediaPlayer, MediaPlayerClass, supportsMediaSource} from 'dashjs';
|
||||
|
||||
export default function DashProvider() {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
|
||||
// html medial element state
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const htmlMediaState = useHtmlMediaInternalState(videoRef);
|
||||
const htmlMediaEvents = useHtmlMediaEvents(htmlMediaState);
|
||||
const htmlMediaApi = useHtmlMediaApi(htmlMediaState);
|
||||
|
||||
const dash = useRef<MediaPlayerClass | undefined>();
|
||||
const [dashReady, setDashReady] = useState(false);
|
||||
|
||||
const destroyDash = useCallback(() => {
|
||||
if (dash.current) {
|
||||
dash.current.destroy();
|
||||
dash.current = undefined;
|
||||
setDashReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setupDash = useCallback(() => {
|
||||
if (!supportsMediaSource()) {
|
||||
store.getState().emit('error', {fatal: true});
|
||||
return;
|
||||
}
|
||||
|
||||
const dashInstance = MediaPlayer().create();
|
||||
|
||||
dashInstance.on(MediaPlayer.events.ERROR, (e: any) => {
|
||||
store.getState().emit('error', {sourceEvent: e});
|
||||
});
|
||||
|
||||
dashInstance.on(MediaPlayer.events.PLAYBACK_METADATA_LOADED, () => {
|
||||
const levels = dashInstance.getBitrateInfoListFor('video');
|
||||
if (!levels?.length) return;
|
||||
|
||||
store.getState().emit('playbackQualities', {
|
||||
qualities: ['auto', ...levels.map(levelToPlaybackQuality)],
|
||||
});
|
||||
|
||||
store.getState().emit('playbackQualityChange', {quality: 'auto'});
|
||||
});
|
||||
|
||||
dashInstance.initialize(videoRef.current, undefined, false);
|
||||
|
||||
// set dash instance after attaching to video element, so "attachSource" is called after
|
||||
dash.current = dashInstance;
|
||||
setDashReady(true);
|
||||
}, [store]);
|
||||
|
||||
useEffect(() => {
|
||||
setupDash();
|
||||
return () => {
|
||||
destroyDash();
|
||||
};
|
||||
}, [setupDash, destroyDash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dash.current && cuedMedia?.src) {
|
||||
dash.current.attachSource(cuedMedia.src);
|
||||
}
|
||||
}, [cuedMedia?.src, dashReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashReady) return;
|
||||
store.setState({
|
||||
providerApi: {
|
||||
...htmlMediaApi,
|
||||
setPlaybackQuality: (quality: string) => {
|
||||
if (!dash.current) return;
|
||||
|
||||
const levels = dash.current.getBitrateInfoListFor('video');
|
||||
const index = levels.findIndex(
|
||||
level => levelToPlaybackQuality(level) === quality
|
||||
);
|
||||
|
||||
dash.current.updateSettings({
|
||||
streaming: {
|
||||
abr: {
|
||||
autoSwitchBitrate: {
|
||||
video: index === -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (index >= 0) {
|
||||
dash.current.setQualityFor('video', index);
|
||||
}
|
||||
|
||||
store.getState().emit('playbackQualityChange', {quality});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [store, htmlMediaApi, dashReady]);
|
||||
|
||||
return (
|
||||
<video
|
||||
className="h-full w-full"
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
poster={cuedMedia?.poster}
|
||||
{...htmlMediaEvents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const levelToPlaybackQuality = (level: any) => {
|
||||
return level === -1 ? 'auto' : `${level.height}p`;
|
||||
};
|
||||
167
common/resources/client/player/providers/hls-provider.tsx
Executable file
167
common/resources/client/player/providers/hls-provider.tsx
Executable file
@@ -0,0 +1,167 @@
|
||||
import {useCallback, useContext, useEffect, useRef, useState} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import Hls, {LevelLoadedData} from 'hls.js';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
import {HlsMediaItem} from '@common/player/media-item';
|
||||
import {AudioTrack} from '@common/player/state/player-state';
|
||||
|
||||
export default function HlsProvider() {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia) as HlsMediaItem;
|
||||
|
||||
// html medial element state
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const htmlMediaState = useHtmlMediaInternalState(videoRef);
|
||||
const htmlMediaEvents = useHtmlMediaEvents(htmlMediaState);
|
||||
const htmlMediaApi = useHtmlMediaApi(htmlMediaState);
|
||||
|
||||
// need both so we can "loadSource" when hls is ready, while keeping other callbacks stable
|
||||
const hls = useRef<Hls | undefined>();
|
||||
const [hlsReady, setHlsReady] = useState(false);
|
||||
|
||||
const destroyHls = useCallback(() => {
|
||||
if (hls) {
|
||||
hls.current?.destroy();
|
||||
hls.current = undefined;
|
||||
setHlsReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setupHls = useCallback(() => {
|
||||
if (!Hls.isSupported()) {
|
||||
store.getState().emit('error', {fatal: true});
|
||||
return;
|
||||
}
|
||||
|
||||
const hlsInstance = new Hls({
|
||||
startLevel: -1,
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hlsInstance.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hlsInstance.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
destroyHls();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
store.getState().emit('error', {sourceEvent: data, fatal: data.fatal});
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (!hlsInstance.levels?.length) return;
|
||||
|
||||
store.getState().emit('playbackQualities', {
|
||||
qualities: ['auto', ...hlsInstance.levels.map(levelToPlaybackQuality)],
|
||||
});
|
||||
|
||||
store.getState().emit('playbackQualityChange', {quality: 'auto'});
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, (eventType, data) => {
|
||||
const track = store.getState().audioTracks.find(t => t.id === data.id);
|
||||
if (track) {
|
||||
store.getState().emit('currentAudioTrackChange', {trackId: track.id});
|
||||
}
|
||||
});
|
||||
|
||||
hlsInstance.on(
|
||||
Hls.Events.LEVEL_LOADED,
|
||||
(eventType: string, data: LevelLoadedData) => {
|
||||
if (!store.getState().providerReady) {
|
||||
const {type, live, totalduration: duration} = data.details;
|
||||
const inferredStreamType = live
|
||||
? type === 'EVENT' && Number.isFinite(duration)
|
||||
? 'live:dvr'
|
||||
: 'live'
|
||||
: 'on-demand';
|
||||
store.getState().emit('streamTypeChange', {
|
||||
streamType:
|
||||
(store.getState().cuedMedia as HlsMediaItem)?.streamType ||
|
||||
inferredStreamType,
|
||||
});
|
||||
store.getState().emit('durationChange', {duration});
|
||||
|
||||
const audioTracks: AudioTrack[] = hlsInstance.audioTracks.map(
|
||||
track => ({
|
||||
id: track.id,
|
||||
label: track.name,
|
||||
language: track.lang || '',
|
||||
kind: 'main',
|
||||
})
|
||||
);
|
||||
store.getState().emit('audioTracks', {tracks: audioTracks});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
hlsInstance.attachMedia(videoRef.current);
|
||||
|
||||
hls.current = hlsInstance;
|
||||
// trigger initial load source
|
||||
setHlsReady(true);
|
||||
}, [destroyHls, store]);
|
||||
|
||||
// setup and destroy hls on mount and unmount
|
||||
useEffect(() => {
|
||||
setupHls();
|
||||
return () => {
|
||||
destroyHls();
|
||||
};
|
||||
}, [setupHls, destroyHls]);
|
||||
|
||||
// load source via hls when media src changes and hls is ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
hls.current &&
|
||||
cuedMedia?.src &&
|
||||
(hls.current as any).url !== cuedMedia?.src
|
||||
) {
|
||||
hls.current.loadSource(cuedMedia.src);
|
||||
}
|
||||
}, [cuedMedia?.src, hlsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsReady) return;
|
||||
store.setState({
|
||||
providerApi: {
|
||||
...htmlMediaApi,
|
||||
setCurrentAudioTrack: (trackId: number) => {
|
||||
if (!hls.current) return;
|
||||
hls.current.audioTrack = trackId;
|
||||
},
|
||||
setPlaybackQuality: (quality: string) => {
|
||||
if (!hls.current) return;
|
||||
hls.current.currentLevel = hls.current.levels.findIndex(
|
||||
level => levelToPlaybackQuality(level) === quality
|
||||
);
|
||||
store.getState().emit('playbackQualityChange', {quality});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [htmlMediaApi, store, hlsReady]);
|
||||
|
||||
return (
|
||||
<video
|
||||
className="h-full w-full"
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
poster={cuedMedia?.poster}
|
||||
{...htmlMediaEvents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const levelToPlaybackQuality = (level: any) => {
|
||||
return level === -1 ? 'auto' : `${level.height}p`;
|
||||
};
|
||||
41
common/resources/client/player/providers/html-audio-provider.tsx
Executable file
41
common/resources/client/player/providers/html-audio-provider.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useContext, useEffect, useRef} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
|
||||
export function HtmlAudioProvider() {
|
||||
const ref = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const autoPlay = usePlayerStore(s => s.options.autoPlay);
|
||||
const muted = usePlayerStore(s => s.muted);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const state = useHtmlMediaInternalState(ref);
|
||||
const events = useHtmlMediaEvents(state);
|
||||
const providerApi = useHtmlMediaApi(state);
|
||||
|
||||
useEffect(() => {
|
||||
store.setState({
|
||||
providerApi,
|
||||
});
|
||||
}, [store, providerApi]);
|
||||
|
||||
let src = cuedMedia?.src;
|
||||
if (src && cuedMedia?.initialTime) {
|
||||
src = `${src}#t=${cuedMedia.initialTime}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<audio
|
||||
className="w-full h-full"
|
||||
ref={ref}
|
||||
src={src}
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
{...events}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
common/resources/client/player/providers/html-media/use-html-media-api.ts
Executable file
67
common/resources/client/player/providers/html-media/use-html-media-api.ts
Executable file
@@ -0,0 +1,67 @@
|
||||
import {HtmlMediaInternalStateReturn} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useContext, useMemo} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {PlayerProviderApi} from '@common/player/state/player-provider-api';
|
||||
|
||||
export function useHtmlMediaApi({
|
||||
ref,
|
||||
internalState,
|
||||
toggleTextTrackModes,
|
||||
}: HtmlMediaInternalStateReturn): PlayerProviderApi {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
return useMemo(
|
||||
() => ({
|
||||
play: async () => {
|
||||
try {
|
||||
await ref.current?.play();
|
||||
} catch (e) {
|
||||
store.getState().emit('error', {sourceEvent: e});
|
||||
}
|
||||
internalState.current.timeRafLoop.start();
|
||||
},
|
||||
pause: () => {
|
||||
ref.current?.pause();
|
||||
internalState.current.timeRafLoop.stop();
|
||||
},
|
||||
stop: () => {
|
||||
if (ref.current) {
|
||||
ref.current.pause();
|
||||
ref.current.currentTime = 0;
|
||||
}
|
||||
},
|
||||
seek: (time: number) => {
|
||||
if (time !== internalState.current.currentTime && ref.current) {
|
||||
ref.current.currentTime = time;
|
||||
}
|
||||
},
|
||||
setVolume: (volume: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.volume = volume / 100;
|
||||
}
|
||||
},
|
||||
setMuted: (muted: boolean) => {
|
||||
if (ref.current) {
|
||||
ref.current.muted = muted;
|
||||
}
|
||||
},
|
||||
setPlaybackRate: (value: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.playbackRate = value;
|
||||
}
|
||||
},
|
||||
setTextTrackVisibility: isVisible => {
|
||||
toggleTextTrackModes(store.getState().currentTextTrack, isVisible);
|
||||
},
|
||||
setCurrentTextTrack: newTrackId => {
|
||||
toggleTextTrackModes(newTrackId, store.getState().textTrackIsVisible);
|
||||
},
|
||||
getCurrentTime: () => {
|
||||
return internalState.current.currentTime;
|
||||
},
|
||||
getSrc: () => {
|
||||
return ref.current?.src;
|
||||
},
|
||||
}),
|
||||
[ref, store, internalState, toggleTextTrackModes]
|
||||
);
|
||||
}
|
||||
125
common/resources/client/player/providers/html-media/use-html-media-events.ts
Executable file
125
common/resources/client/player/providers/html-media/use-html-media-events.ts
Executable file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
HTMLAttributes,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {HtmlMediaInternalStateReturn} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
|
||||
const defaultPlaybackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
|
||||
export function useHtmlMediaEvents({
|
||||
ref,
|
||||
updateCurrentTime,
|
||||
updateBuffered,
|
||||
internalState,
|
||||
}: HtmlMediaInternalStateReturn): HTMLAttributes<HTMLMediaElement> {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const onTextTracksChange = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
const tracks = Array.from(ref.current.textTracks).filter(
|
||||
t => t.label && (t.kind === 'subtitles' || t.kind === 'captions')
|
||||
);
|
||||
|
||||
let trackId = -1;
|
||||
for (let id = 0; id < tracks.length; id += 1) {
|
||||
if (tracks[id].mode === 'hidden') {
|
||||
// Do not break in case there is a following track with showing.
|
||||
trackId = id;
|
||||
} else if (tracks[id].mode === 'showing') {
|
||||
trackId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const isVisible = trackId !== -1 && tracks[trackId].mode === 'showing';
|
||||
store.getState().emit('currentTextTrackChange', {trackId});
|
||||
store.getState().emit('textTrackVisibilityChange', {isVisible});
|
||||
store.getState().emit('textTracks', {tracks});
|
||||
}, [ref, store]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
return () => {
|
||||
el?.textTracks.removeEventListener('change', onTextTracksChange);
|
||||
};
|
||||
}, [ref, onTextTracksChange]);
|
||||
|
||||
return useMemo(() => {
|
||||
const emit = store.getState().emit;
|
||||
return {
|
||||
// set some common props used on audio/video/hls/dash providers
|
||||
autoPlay: false,
|
||||
onContextMenu: e => e.preventDefault(),
|
||||
controlsList: 'nodownload',
|
||||
preload: 'metadata',
|
||||
'x-webkit-airplay': 'allow',
|
||||
onEnded: () => {
|
||||
emit('playbackEnd');
|
||||
updateCurrentTime();
|
||||
internalState.current.timeRafLoop.stop();
|
||||
},
|
||||
onStalled: e => {
|
||||
if (e.currentTarget.readyState < 3) {
|
||||
emit('buffering', {isBuffering: true});
|
||||
}
|
||||
},
|
||||
onWaiting: () => {
|
||||
emit('buffering', {isBuffering: true});
|
||||
},
|
||||
onPlaying: () => {
|
||||
emit('play');
|
||||
emit('buffering', {isBuffering: false});
|
||||
},
|
||||
onPause: e => {
|
||||
emit('pause');
|
||||
emit('buffering', {isBuffering: false});
|
||||
internalState.current.timeRafLoop.stop();
|
||||
},
|
||||
onSuspend: () => {
|
||||
emit('buffering', {isBuffering: false});
|
||||
},
|
||||
onSeeking: () => {
|
||||
updateCurrentTime();
|
||||
},
|
||||
onSeeked: () => {
|
||||
updateCurrentTime();
|
||||
},
|
||||
onTimeUpdate: () => {
|
||||
updateCurrentTime();
|
||||
},
|
||||
onError: e => {
|
||||
emit('error', {sourceEvent: e});
|
||||
},
|
||||
onDurationChange: e => {
|
||||
updateCurrentTime();
|
||||
emit('durationChange', {duration: e.currentTarget.duration});
|
||||
},
|
||||
onRateChange: e => {
|
||||
emit('playbackRateChange', {rate: e.currentTarget.playbackRate});
|
||||
},
|
||||
onLoadedMetadata: e => {
|
||||
if (!internalState.current.playbackReady) {
|
||||
emit('providerReady', {el: e.currentTarget});
|
||||
internalState.current.playbackReady = true;
|
||||
updateBuffered();
|
||||
onTextTracksChange();
|
||||
e.currentTarget.textTracks.addEventListener('change', () => {
|
||||
onTextTracksChange();
|
||||
});
|
||||
}
|
||||
emit('cued');
|
||||
emit('playbackRates', {rates: defaultPlaybackRates});
|
||||
},
|
||||
};
|
||||
}, [
|
||||
internalState,
|
||||
store,
|
||||
updateCurrentTime,
|
||||
onTextTracksChange,
|
||||
updateBuffered,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {createRafLoop} from '@common/utils/dom/create-ref-loop';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
|
||||
export interface HtmlMediaInternalStateReturn {
|
||||
ref: RefObject<HTMLMediaElement>;
|
||||
updateCurrentTime: () => void;
|
||||
updateBuffered: () => void;
|
||||
toggleTextTrackModes: (newTrackId: number, isVisible: boolean) => void;
|
||||
internalState: MutableRefObject<{
|
||||
currentTime: number;
|
||||
playbackReady: boolean;
|
||||
timeRafLoop: ReturnType<typeof createRafLoop>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function useHtmlMediaInternalState(
|
||||
ref: RefObject<HTMLMediaElement>
|
||||
): HtmlMediaInternalStateReturn {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
|
||||
const internalState = useRef({
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
isMediaWaiting: false,
|
||||
playbackReady: false,
|
||||
/**
|
||||
* The `timeupdate` event fires surprisingly infrequently during playback, meaning your progress
|
||||
* bar (or whatever else is synced to the currentTime) moves in a choppy fashion. This helps
|
||||
* resolve that by retrieving time updates in a request animation frame loop.
|
||||
*/
|
||||
timeRafLoop: createRafLoop(() => {
|
||||
updateCurrentTime();
|
||||
updateBuffered();
|
||||
}),
|
||||
});
|
||||
|
||||
const updateBuffered = useCallback(() => {
|
||||
const timeRange = ref.current?.buffered;
|
||||
const seconds =
|
||||
!timeRange || timeRange.length === 0
|
||||
? 0
|
||||
: timeRange.end(timeRange.length - 1);
|
||||
|
||||
if (internalState.current.buffered !== seconds) {
|
||||
store.getState().emit('buffered', {seconds});
|
||||
internalState.current.buffered = seconds;
|
||||
}
|
||||
}, [ref, store]);
|
||||
|
||||
const updateCurrentTime = useCallback(() => {
|
||||
const newTime = ref.current?.currentTime || 0;
|
||||
if (
|
||||
internalState.current.currentTime !== newTime &&
|
||||
!store.getState().isSeeking
|
||||
) {
|
||||
store.getState().emit('progress', {currentTime: newTime});
|
||||
internalState.current.currentTime = newTime;
|
||||
}
|
||||
}, [internalState, store, ref]);
|
||||
|
||||
const toggleTextTrackModes = useCallback(
|
||||
(newTrackId: number, isVisible: boolean) => {
|
||||
if (!ref.current) return;
|
||||
const {textTracks} = ref.current;
|
||||
|
||||
if (newTrackId === -1) {
|
||||
Array.from(textTracks).forEach(track => {
|
||||
track.mode = 'disabled';
|
||||
});
|
||||
} else {
|
||||
const oldTrack = textTracks[store.getState().currentTextTrack];
|
||||
if (oldTrack) oldTrack.mode = 'disabled';
|
||||
}
|
||||
|
||||
const nextTrack = textTracks[newTrackId];
|
||||
|
||||
if (nextTrack) {
|
||||
nextTrack.mode = isVisible ? 'showing' : 'hidden';
|
||||
}
|
||||
|
||||
store.getState().emit('currentTextTrackChange', {
|
||||
trackId: !isVisible ? -1 : newTrackId,
|
||||
});
|
||||
store
|
||||
.getState()
|
||||
.emit('textTrackVisibilityChange', {isVisible: isVisible});
|
||||
},
|
||||
[ref, store]
|
||||
);
|
||||
|
||||
// stop current time loop on unmount
|
||||
useEffect(() => {
|
||||
const timeRafLoop = internalState.current.timeRafLoop;
|
||||
return () => {
|
||||
timeRafLoop.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// reload metadata when new media is cued
|
||||
useEffect(() => {
|
||||
ref.current?.load();
|
||||
}, [cuedMedia?.src, ref]);
|
||||
|
||||
return {
|
||||
ref,
|
||||
internalState,
|
||||
updateCurrentTime,
|
||||
toggleTextTrackModes,
|
||||
updateBuffered,
|
||||
};
|
||||
}
|
||||
54
common/resources/client/player/providers/html-video-provider.tsx
Executable file
54
common/resources/client/player/providers/html-video-provider.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useContext, useEffect, useRef} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
|
||||
export function HtmlVideoProvider() {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const autoPlay = usePlayerStore(s => s.options.autoPlay);
|
||||
const muted = usePlayerStore(s => s.muted);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const state = useHtmlMediaInternalState(ref);
|
||||
const events = useHtmlMediaEvents(state);
|
||||
const providerApi = useHtmlMediaApi(state);
|
||||
|
||||
useEffect(() => {
|
||||
store.setState({
|
||||
providerApi,
|
||||
});
|
||||
}, [store, providerApi]);
|
||||
|
||||
let src = cuedMedia?.src;
|
||||
if (src && cuedMedia?.initialTime) {
|
||||
src = `${src}#t=${cuedMedia.initialTime}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
className="w-full h-full"
|
||||
ref={ref}
|
||||
src={src}
|
||||
playsInline
|
||||
poster={cuedMedia?.poster}
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
{...events}
|
||||
>
|
||||
{cuedMedia?.captions?.map((caption, index) => (
|
||||
<track
|
||||
key={caption.id}
|
||||
label={caption.label}
|
||||
kind="subtitles"
|
||||
srcLang={caption.language || 'en'}
|
||||
src={caption.src}
|
||||
default={index === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
YoutubeInternalState,
|
||||
YouTubeMessage,
|
||||
YoutubeMessageInfo,
|
||||
YouTubePlayerState,
|
||||
YoutubeProviderError,
|
||||
} from '@common/player/providers/youtube/youtube-types';
|
||||
import {MutableRefObject, RefObject} from 'react';
|
||||
import {PlayerStoreApi} from '@common/player/state/player-state';
|
||||
import {isNumber} from '@common/utils/number/is-number';
|
||||
import {loadYoutubePoster} from '@common/player/providers/youtube/load-youtube-poster';
|
||||
|
||||
export function handleYoutubeEmbedMessage(
|
||||
e: MessageEvent,
|
||||
internalStateRef: MutableRefObject<YoutubeInternalState>,
|
||||
iframeRef: RefObject<HTMLIFrameElement>,
|
||||
store: PlayerStoreApi
|
||||
) {
|
||||
const data = (
|
||||
typeof e.data === 'string' ? JSON.parse(e.data) : e.data
|
||||
) as YouTubeMessage;
|
||||
const info = data.info;
|
||||
const internalState = internalStateRef.current;
|
||||
const emit = store.getState().emit;
|
||||
if (!info) return;
|
||||
|
||||
if (info.videoData?.video_id) {
|
||||
internalState.videoId = info.videoData.video_id;
|
||||
}
|
||||
|
||||
if (info.videoData?.errorCode) {
|
||||
const event: YoutubeProviderError = {
|
||||
code: info.videoData.errorCode,
|
||||
videoId: internalState.videoId,
|
||||
};
|
||||
emit('error', {sourceEvent: event});
|
||||
}
|
||||
|
||||
if (isNumber(info.duration) && info.duration !== internalState.duration) {
|
||||
internalState.duration = info.duration;
|
||||
emit('durationChange', {duration: internalState.duration});
|
||||
}
|
||||
|
||||
if (
|
||||
isNumber(info.currentTime) &&
|
||||
info.currentTime !== internalState.currentTime
|
||||
) {
|
||||
internalState.currentTime = info.currentTime;
|
||||
// don't fire progress events while seeking via seekbar
|
||||
if (!store.getState().isSeeking) {
|
||||
emit('progress', {currentTime: internalState.currentTime});
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(info.currentTimeLastUpdated)) {
|
||||
internalState.lastTimeUpdate = info.currentTimeLastUpdated;
|
||||
}
|
||||
|
||||
if (isNumber(info.playbackRate)) {
|
||||
if (internalState.playbackRate !== info.playbackRate) {
|
||||
emit('playbackRateChange', {rate: info.playbackRate});
|
||||
}
|
||||
internalState.playbackRate = info.playbackRate;
|
||||
}
|
||||
|
||||
if (isNumber(info.videoLoadedFraction)) {
|
||||
const buffered = info.videoLoadedFraction * internalState.duration;
|
||||
if (internalState.buffered !== buffered) {
|
||||
emit('buffered', {
|
||||
seconds: info.videoLoadedFraction * internalState.duration,
|
||||
});
|
||||
}
|
||||
internalState.buffered = buffered;
|
||||
}
|
||||
|
||||
if (Array.isArray(info.availablePlaybackRates)) {
|
||||
emit('playbackRates', {rates: info.availablePlaybackRates});
|
||||
}
|
||||
|
||||
if (isNumber(info.playerState)) {
|
||||
onYoutubeStateChange(info, internalStateRef, iframeRef, store);
|
||||
internalState.state = info.playerState;
|
||||
}
|
||||
}
|
||||
|
||||
function onYoutubeStateChange(
|
||||
info: YoutubeMessageInfo,
|
||||
internalStateRef: MutableRefObject<YoutubeInternalState>,
|
||||
iframeRef: RefObject<HTMLIFrameElement>,
|
||||
store: PlayerStoreApi
|
||||
) {
|
||||
const emit = store.getState().emit;
|
||||
const state = info.playerState!;
|
||||
|
||||
const onCued = async () => {
|
||||
// load poster, if needed
|
||||
if (info.videoData?.video_id && !store.getState().cuedMedia?.poster) {
|
||||
const url = await loadYoutubePoster(info.videoData.video_id);
|
||||
if (url) {
|
||||
store.getState().emit('posterLoaded', {url});
|
||||
}
|
||||
}
|
||||
|
||||
// mark provider as ready
|
||||
if (!internalStateRef.current.playbackReady) {
|
||||
emit('providerReady', {el: iframeRef.current!});
|
||||
internalStateRef.current.playbackReady = true;
|
||||
}
|
||||
emit('cued');
|
||||
};
|
||||
|
||||
emit('youtubeStateChange', {state});
|
||||
emit('buffering', {isBuffering: state === YouTubePlayerState.Buffering});
|
||||
|
||||
if (state !== YouTubePlayerState.Ended) {
|
||||
internalStateRef.current.firedPlaybackEnd = false;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case YouTubePlayerState.Unstarted:
|
||||
// When using autoplay, but autoplay fails, player will get "unstarted" event
|
||||
onCued();
|
||||
break;
|
||||
case YouTubePlayerState.Ended:
|
||||
// will sometimes fire twice without this, if player starts buffering as a result of seek to the end
|
||||
if (!internalStateRef.current.firedPlaybackEnd) {
|
||||
emit('playbackEnd');
|
||||
internalStateRef.current.firedPlaybackEnd = true;
|
||||
}
|
||||
break;
|
||||
case YouTubePlayerState.Playing:
|
||||
// When using autoplay, "cued" event is never fired, handle "cued" here instead
|
||||
onCued();
|
||||
emit('play');
|
||||
break;
|
||||
case YouTubePlayerState.Paused:
|
||||
emit('pause');
|
||||
break;
|
||||
case YouTubePlayerState.Cued:
|
||||
onCued();
|
||||
break;
|
||||
}
|
||||
}
|
||||
30
common/resources/client/player/providers/youtube/load-youtube-poster.ts
Executable file
30
common/resources/client/player/providers/youtube/load-youtube-poster.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {loadImage} from '@common/utils/http/load-image';
|
||||
|
||||
const posterCache = new Map<string, string>();
|
||||
|
||||
export async function loadYoutubePoster(
|
||||
videoId: string
|
||||
): Promise<string | undefined> {
|
||||
if (!videoId) return;
|
||||
if (posterCache.has(videoId)) {
|
||||
return posterCache.get(videoId);
|
||||
}
|
||||
|
||||
const posterURL = (quality: string) =>
|
||||
`https://i.ytimg.com/vi/${videoId}/${quality}.jpg`;
|
||||
|
||||
/**
|
||||
* We are testing that the image has a min-width of 121px because if the thumbnail does not
|
||||
* exist YouTube returns a blank/error image that is 120px wide.
|
||||
*/
|
||||
return loadImage(posterURL('maxresdefault'), 121) // 1080p (no padding)
|
||||
.catch(() => loadImage(posterURL('sddefault'), 121)) // 640p (padded 4:3)
|
||||
.catch(() => loadImage(posterURL('hqdefault'), 121)) // 480p (padded 4:3)
|
||||
.catch(() => {})
|
||||
.then(img => {
|
||||
if (!img) return;
|
||||
const poster = img.src;
|
||||
posterCache.set(videoId, poster);
|
||||
return poster;
|
||||
});
|
||||
}
|
||||
73
common/resources/client/player/providers/youtube/use-youtube-provider-src.ts
Executable file
73
common/resources/client/player/providers/youtube/use-youtube-provider-src.ts
Executable file
@@ -0,0 +1,73 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {YoutubeMediaItem} from '@common/player/media-item';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {youtubeIdFromSrc} from '@common/player/utils/youtube-id-from-src';
|
||||
|
||||
const queryString =
|
||||
'&controls=0&disablekb=1&enablejsapi=1&iv_load_policy=3&modestbranding=1&playsinline=1&rel=0&showinfo=0';
|
||||
|
||||
export function useYoutubeProviderSrc(
|
||||
loadVideoById: (videoId: string) => void
|
||||
) {
|
||||
const {getState, emit} = usePlayerActions();
|
||||
const options = usePlayerStore(s => s.options);
|
||||
const media = usePlayerStore(s => s.cuedMedia) as
|
||||
| YoutubeMediaItem
|
||||
| undefined;
|
||||
|
||||
const origin = options.youtube?.useCookies
|
||||
? 'https://www.youtube.com'
|
||||
: 'https://www.youtube-nocookie.com';
|
||||
|
||||
const [initialVideoId, setInitialVideoId] = useState(() => {
|
||||
if (media?.src && media.src !== 'resolve') {
|
||||
return youtubeIdFromSrc(media.src);
|
||||
}
|
||||
});
|
||||
|
||||
const updateVideoIds = useCallback(
|
||||
(src: string) => {
|
||||
const videoId = youtubeIdFromSrc(src);
|
||||
if (!videoId) return;
|
||||
|
||||
// use setState callback, so we don't need to use "initialVideoId" in the dependency array
|
||||
setInitialVideoId(prevId => {
|
||||
if (!prevId) {
|
||||
return videoId;
|
||||
} else {
|
||||
// changing src of iframe will cause it to fully reload, use "loadVideoById" api method instead
|
||||
loadVideoById(videoId);
|
||||
return prevId;
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadVideoById]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (media?.src && media.src !== 'resolve') {
|
||||
updateVideoIds(media.src);
|
||||
} else if (media) {
|
||||
emit('buffering', {isBuffering: true});
|
||||
options.youtube?.srcResolver?.(media).then(item => {
|
||||
// check if resolved media matches the one currently in the store to prevent race conditions.
|
||||
// check against current value in store, because this callback will close over old value
|
||||
if (item?.src && getState().cuedMedia?.id === item.id) {
|
||||
updateVideoIds(item.src);
|
||||
}
|
||||
});
|
||||
}
|
||||
// only update when media id changes to prevent infinite loops
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options, updateVideoIds, media?.id]);
|
||||
|
||||
return {
|
||||
initialVideoUrl: initialVideoId
|
||||
? `${origin}/embed/${initialVideoId}?${queryString}&autoplay=${
|
||||
options.autoPlay ? '1' : '0'
|
||||
}&mute=${getState().muted ? '1' : '0'}&start=${media?.initialTime ?? 0}`
|
||||
: undefined,
|
||||
origin,
|
||||
};
|
||||
}
|
||||
144
common/resources/client/player/providers/youtube/youtube-provider.tsx
Executable file
144
common/resources/client/player/providers/youtube/youtube-provider.tsx
Executable file
@@ -0,0 +1,144 @@
|
||||
import {useGlobalListeners} from '@react-aria/utils';
|
||||
import {useCallback, useContext, useEffect, useRef} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {
|
||||
YoutubeCommand,
|
||||
YouTubeCommandArg,
|
||||
YoutubeInternalState,
|
||||
YoutubeProviderInternalApi,
|
||||
} from '@common/player/providers/youtube/youtube-types';
|
||||
import {handleYoutubeEmbedMessage} from '@common/player/providers/youtube/handle-youtube-embed-message';
|
||||
import {useYoutubeProviderSrc} from '@common/player/providers/youtube/use-youtube-provider-src';
|
||||
|
||||
export function YoutubeProvider() {
|
||||
const {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const youtubeApi = useCallback(
|
||||
<T extends keyof YouTubeCommandArg>(
|
||||
command: T,
|
||||
arg?: YouTubeCommandArg[T]
|
||||
) =>
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({
|
||||
event: 'command',
|
||||
func: command,
|
||||
args: arg ? [arg] : undefined,
|
||||
}),
|
||||
'*'
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const loadVideoById = useCallback(
|
||||
(videoId: string) => {
|
||||
// using "YoutubeCommand.Cue" does not play video when changing sources,
|
||||
// it requires double click on play button without this
|
||||
youtubeApi(YoutubeCommand.CueAndPlay, videoId);
|
||||
},
|
||||
[youtubeApi]
|
||||
);
|
||||
|
||||
const {initialVideoUrl, origin} = useYoutubeProviderSrc(loadVideoById);
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const internalStateRef = useRef<YoutubeInternalState>({
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
lastTimeUpdate: 0,
|
||||
playbackRate: 1,
|
||||
state: -1,
|
||||
playbackReady: false,
|
||||
buffered: 0,
|
||||
firedPlaybackEnd: false,
|
||||
});
|
||||
|
||||
const registerApi = useCallback(() => {
|
||||
const internalProviderApi: YoutubeProviderInternalApi = {
|
||||
loadVideoById,
|
||||
};
|
||||
store.setState({
|
||||
providerApi: {
|
||||
play: () => {
|
||||
youtubeApi(YoutubeCommand.Play);
|
||||
},
|
||||
pause: () => {
|
||||
youtubeApi(YoutubeCommand.Pause);
|
||||
},
|
||||
stop: () => {
|
||||
youtubeApi(YoutubeCommand.Stop);
|
||||
},
|
||||
seek: (time: number) => {
|
||||
if (time !== internalStateRef.current.currentTime) {
|
||||
youtubeApi(YoutubeCommand.Seek, time);
|
||||
}
|
||||
},
|
||||
setVolume: (volume: number) => {
|
||||
youtubeApi(YoutubeCommand.SetVolume, volume);
|
||||
},
|
||||
setMuted: (muted: boolean) => {
|
||||
if (muted) {
|
||||
youtubeApi(YoutubeCommand.Mute);
|
||||
} else {
|
||||
youtubeApi(YoutubeCommand.Unmute);
|
||||
}
|
||||
},
|
||||
setPlaybackRate: (value: number) => {
|
||||
youtubeApi(YoutubeCommand.SetPlaybackRate, value);
|
||||
},
|
||||
setPlaybackQuality: (value: string) => {
|
||||
youtubeApi(YoutubeCommand.SetPlaybackQuality, value);
|
||||
},
|
||||
getCurrentTime: () => {
|
||||
return internalStateRef.current.currentTime;
|
||||
},
|
||||
getSrc: () => {
|
||||
return internalStateRef.current.videoId;
|
||||
},
|
||||
internalProviderApi,
|
||||
},
|
||||
});
|
||||
}, [store, loadVideoById, youtubeApi]);
|
||||
|
||||
useEffect(() => {
|
||||
addGlobalListener(window, 'message', event => {
|
||||
const e = event as MessageEvent;
|
||||
if (
|
||||
e.origin === origin &&
|
||||
e.source === iframeRef.current?.contentWindow
|
||||
) {
|
||||
handleYoutubeEmbedMessage(e, internalStateRef, iframeRef, store);
|
||||
}
|
||||
});
|
||||
|
||||
registerApi();
|
||||
|
||||
return () => {
|
||||
removeAllGlobalListeners();
|
||||
};
|
||||
}, [addGlobalListener, removeAllGlobalListeners, store, origin, registerApi]);
|
||||
|
||||
if (!initialVideoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-full h-full"
|
||||
ref={iframeRef}
|
||||
src={initialVideoUrl}
|
||||
allowFullScreen
|
||||
allow="autoplay; encrypted-media; picture-in-picture;"
|
||||
onLoad={() => {
|
||||
// window does not receive "message" events on safari without waiting a small amount of time for some reason
|
||||
setTimeout(() => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({event: 'listening'}),
|
||||
'*'
|
||||
);
|
||||
registerApi();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
102
common/resources/client/player/providers/youtube/youtube-types.ts
Executable file
102
common/resources/client/player/providers/youtube/youtube-types.ts
Executable file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @see https://developers.google.com/youtube/iframe_api_reference#Playback_controls
|
||||
*/
|
||||
export const enum YoutubeCommand {
|
||||
Play = 'playVideo',
|
||||
Pause = 'pauseVideo',
|
||||
Stop = 'stopVideo',
|
||||
Seek = 'seekTo',
|
||||
Cue = 'cueVideoById',
|
||||
CueAndPlay = 'loadVideoById',
|
||||
Mute = 'mute',
|
||||
Unmute = 'unMute',
|
||||
SetVolume = 'setVolume',
|
||||
SetPlaybackRate = 'setPlaybackRate',
|
||||
SetPlaybackQuality = 'setPlaybackQuality',
|
||||
}
|
||||
|
||||
export interface YouTubeCommandArg {
|
||||
[YoutubeCommand.Play]: void;
|
||||
[YoutubeCommand.Pause]: void;
|
||||
[YoutubeCommand.Stop]: void;
|
||||
[YoutubeCommand.Seek]: number;
|
||||
[YoutubeCommand.Cue]: string;
|
||||
[YoutubeCommand.CueAndPlay]: string;
|
||||
[YoutubeCommand.Mute]: void;
|
||||
[YoutubeCommand.Unmute]: void;
|
||||
[YoutubeCommand.SetVolume]: number;
|
||||
[YoutubeCommand.SetPlaybackRate]: number;
|
||||
[YoutubeCommand.SetPlaybackQuality]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developers.google.com/youtube/iframe_api_reference#onStateChange
|
||||
*/
|
||||
export const enum YouTubePlayerState {
|
||||
Unstarted = -1,
|
||||
Ended = 0,
|
||||
Playing = 1,
|
||||
Paused = 2,
|
||||
Buffering = 3,
|
||||
Cued = 5,
|
||||
}
|
||||
|
||||
export interface YoutubeInternalState {
|
||||
duration: number;
|
||||
currentTime: number;
|
||||
videoId?: string;
|
||||
lastTimeUpdate: number;
|
||||
playbackRate: number;
|
||||
playbackReady: boolean;
|
||||
buffered: number;
|
||||
state: YouTubePlayerState;
|
||||
firedPlaybackEnd: boolean;
|
||||
}
|
||||
|
||||
export const enum YouTubePlaybackQuality {
|
||||
Unknown = 'unknown',
|
||||
Tiny = 'tiny',
|
||||
Small = 'small',
|
||||
Medium = 'medium',
|
||||
Large = 'large',
|
||||
Hd720 = 'hd720',
|
||||
Hd1080 = 'hd1080',
|
||||
Highres = 'highres',
|
||||
Max = 'max',
|
||||
}
|
||||
|
||||
export interface YouTubeMessage {
|
||||
channel: string;
|
||||
event: 'initialDelivery' | 'onReady' | 'infoDelivery' | 'apiInfoDelivery';
|
||||
info?: YoutubeMessageInfo;
|
||||
}
|
||||
|
||||
export interface YoutubeMessageInfo {
|
||||
availablePlaybackRates?: number[];
|
||||
availableQualityLevels?: YouTubePlaybackQuality[];
|
||||
currentTime?: number;
|
||||
currentTimeLastUpdated?: number;
|
||||
videoLoadedFraction?: number;
|
||||
volume?: number;
|
||||
videoUrl?: string;
|
||||
videoData?: {
|
||||
author: string;
|
||||
title: string;
|
||||
video_id: string;
|
||||
errorCode?: string;
|
||||
};
|
||||
duration?: number;
|
||||
muted?: boolean;
|
||||
playbackQuality?: YouTubePlaybackQuality;
|
||||
playbackRate?: number;
|
||||
playerState?: YouTubePlayerState;
|
||||
}
|
||||
|
||||
export interface YoutubeProviderError {
|
||||
code?: string;
|
||||
videoId?: string;
|
||||
}
|
||||
|
||||
export interface YoutubeProviderInternalApi {
|
||||
loadVideoById: (videoId: string) => void;
|
||||
}
|
||||
@@ -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}));
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
80
common/resources/client/player/ui/audio-player.tsx
Executable file
80
common/resources/client/player/ui/audio-player.tsx
Executable file
@@ -0,0 +1,80 @@
|
||||
import {Fragment} from 'react';
|
||||
import {PlayerContext} from '@common/player/player-context';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {PlayerOutlet} from '@common/player/ui/player-outlet';
|
||||
import {PlayButton} from '@common/player/ui/controls/play-button';
|
||||
import {VolumeControls} from '@common/player/ui/controls/volume-controls';
|
||||
import {Seekbar} from '@common/player/ui/controls/seeking/seekbar';
|
||||
import {FormattedCurrentTime} from '@common/player/ui/controls/formatted-current-time';
|
||||
import {FormattedPlayerDuration} from '@common/player/ui/controls/formatted-player-duration';
|
||||
import {PlaybackOptionsButton} from '@common/player/ui/controls/playback-options-button';
|
||||
import clsx from 'clsx';
|
||||
import {SeekButton} from '@common/player/ui/controls/seeking/seek-button';
|
||||
import {Forward10Icon} from '@common/icons/material/Forward10';
|
||||
import {UndoIcon} from '@common/icons/material/Undo';
|
||||
|
||||
const mediaItem: MediaItem = {
|
||||
id: 'test1',
|
||||
src: 'storage/title-videos/pLiHKnN3dXz0Ep0rVrgiZ4mSS0lyDV8fnrcwmDOE.mp4',
|
||||
poster: 'https://peach.blender.org/wp-content/uploads/bbb-splash.png',
|
||||
provider: 'htmlAudio',
|
||||
};
|
||||
|
||||
const mediaItem2: MediaItem = {
|
||||
id: 'test2',
|
||||
src: '0G3_kG5FFfQ',
|
||||
provider: 'youtube',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export function AudioPlayer({className}: Props) {
|
||||
return (
|
||||
<PlayerContext
|
||||
id="audio"
|
||||
options={{
|
||||
initialData: {
|
||||
queue: [mediaItem2, mediaItem],
|
||||
cuedMediaId: 'test1',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={clsx(className, 'shadow rounded')}>
|
||||
<Player />
|
||||
</div>
|
||||
</PlayerContext>
|
||||
);
|
||||
}
|
||||
|
||||
function Player() {
|
||||
return (
|
||||
<Fragment>
|
||||
<PlayerOutlet className="w-full h-full" />
|
||||
<Controls />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function Controls() {
|
||||
return (
|
||||
<div className="flex items-center gap-24 p-14 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<SeekButton seconds="-15">
|
||||
<UndoIcon />
|
||||
</SeekButton>
|
||||
<PlayButton />
|
||||
<SeekButton seconds="+15">
|
||||
<Forward10Icon />
|
||||
</SeekButton>
|
||||
</div>
|
||||
<FormattedCurrentTime className="min-w-40 text-right" />
|
||||
<Seekbar fillColor="bg-black" trackColor="bg-black/20" />
|
||||
<FormattedPlayerDuration className="min-w-40 text-right" />
|
||||
<div className="flex items-center gap-4">
|
||||
<PlaybackOptionsButton />
|
||||
<VolumeControls fillColor="bg-black" trackColor="bg-black/20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
common/resources/client/player/ui/controls/buffering-spinner.tsx
Executable file
41
common/resources/client/player/ui/controls/buffering-spinner.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
ProgressCircle,
|
||||
ProgressCircleProps,
|
||||
} from '@common/ui/progress/progress-circle';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
trackColor?: string;
|
||||
fillColor?: string;
|
||||
size?: ProgressCircleProps['size'];
|
||||
}
|
||||
export function BufferingSpinner({
|
||||
className,
|
||||
trackColor,
|
||||
fillColor,
|
||||
size,
|
||||
}: Props) {
|
||||
const isActive = usePlayerStore(
|
||||
s =>
|
||||
// YouTube will already show a spinner, no need for a custom one
|
||||
(s.isBuffering && s.providerName !== 'youtube') ||
|
||||
(s.playbackStarted && !s.providerReady)
|
||||
);
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isActive && (
|
||||
<m.div {...opacityAnimation} className={className}>
|
||||
<ProgressCircle
|
||||
isIndeterminate
|
||||
trackColor={trackColor}
|
||||
fillColor={fillColor}
|
||||
size={size}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
19
common/resources/client/player/ui/controls/formatted-current-time.tsx
Executable file
19
common/resources/client/player/ui/controls/formatted-current-time.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {useCurrentTime} from '@common/player/hooks/use-current-time';
|
||||
import {FormattedDuration} from '@common/i18n/formatted-duration';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export function FormattedCurrentTime({className}: Props) {
|
||||
const duration = usePlayerStore(s => s.mediaDuration);
|
||||
const currentTime = useCurrentTime();
|
||||
return (
|
||||
<span className={className}>
|
||||
<FormattedDuration
|
||||
seconds={currentTime}
|
||||
addZeroToFirstUnit={duration >= 600}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
17
common/resources/client/player/ui/controls/formatted-player-duration.tsx
Executable file
17
common/resources/client/player/ui/controls/formatted-player-duration.tsx
Executable file
@@ -0,0 +1,17 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {FormattedDuration} from '@common/i18n/formatted-duration';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export function FormattedPlayerDuration({className}: Props) {
|
||||
const duration = usePlayerStore(s => s.mediaDuration);
|
||||
return (
|
||||
<span className={className}>
|
||||
<FormattedDuration
|
||||
seconds={duration}
|
||||
addZeroToFirstUnit={duration >= 600}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
61
common/resources/client/player/ui/controls/fullscreen-button.tsx
Executable file
61
common/resources/client/player/ui/controls/fullscreen-button.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {MediaFullscreenExitIcon} from '@common/icons/media/media-fullscreen-exit';
|
||||
import {MediaFullscreenIcon} from '@common/icons/media/media-fullscreen';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function FullscreenButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const isFullscreen = usePlayerStore(s => s.isFullscreen);
|
||||
const canFullscreen = usePlayerStore(s => s.canFullscreen);
|
||||
|
||||
if (!canFullscreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelMessage = trans(
|
||||
isFullscreen
|
||||
? message('Exit fullscreen (f)')
|
||||
: message('Enter fullscreen (f)')
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message={labelMessage} />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={labelMessage}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
if (isFullscreen) {
|
||||
player.exitFullscreen();
|
||||
} else {
|
||||
player.enterFullscreen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFullscreen ? <MediaFullscreenExitIcon /> : <MediaFullscreenIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
46
common/resources/client/player/ui/controls/next-button.tsx
Executable file
46
common/resources/client/player/ui/controls/next-button.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaNextIcon} from '@common/icons/media/media-next';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
export function NextButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
stopPropagation,
|
||||
}: Props) {
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message="Next" />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={e => {
|
||||
if (stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
player.playNext();
|
||||
}}
|
||||
>
|
||||
<MediaNextIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
60
common/resources/client/player/ui/controls/pip-button.tsx
Executable file
60
common/resources/client/player/ui/controls/pip-button.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {MediaPictureInPictureExitIcon} from '@common/icons/media/media-picture-in-picture-exit';
|
||||
import {MediaPictureInPictureIcon} from '@common/icons/media/media-picture-in-picture';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function PipButton({size = 'md', iconSize, color, className}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const isPip = usePlayerStore(s => s.isPip);
|
||||
const canPip = usePlayerStore(s => s.canPip);
|
||||
|
||||
if (!canPip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelMessage = trans(
|
||||
isPip
|
||||
? message('Exit picture-in-picture (p)')
|
||||
: message('Enter picture-in-picture (p)')
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message={labelMessage} />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={labelMessage}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
if (isPip) {
|
||||
player.exitPip();
|
||||
} else {
|
||||
player.enterPip();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPip ? (
|
||||
<MediaPictureInPictureExitIcon />
|
||||
) : (
|
||||
<MediaPictureInPictureIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
55
common/resources/client/player/ui/controls/play-button.tsx
Executable file
55
common/resources/client/player/ui/controls/play-button.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {MediaPauseIcon} from '@common/icons/media/media-pause';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
export function PlayButton({
|
||||
size = 'md',
|
||||
iconSize = 'xl',
|
||||
color,
|
||||
stopPropagation,
|
||||
}: Props) {
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const player = usePlayerActions();
|
||||
|
||||
const label = isPlaying ? (
|
||||
<Trans message="Pause (k)" />
|
||||
) : (
|
||||
<Trans message="Play (k)" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} usePortal={false}>
|
||||
<IconButton
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
disabled={!playerReady}
|
||||
onClick={e => {
|
||||
if (stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (isPlaying) {
|
||||
player.pause();
|
||||
} else {
|
||||
player.play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <MediaPauseIcon /> : <MediaPlayIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
338
common/resources/client/player/ui/controls/playback-options-button.tsx
Executable file
338
common/resources/client/player/ui/controls/playback-options-button.tsx
Executable file
@@ -0,0 +1,338 @@
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ReactNode, useState} from 'react';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {Button, ButtonProps} from '@common/ui/buttons/button';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {ArrowRightIcon} from '@common/icons/material/ArrowRight';
|
||||
import {useDarkThemeVariables} from '@common/ui/themes/use-dark-theme-variables';
|
||||
import {MediaSettingsIcon} from '@common/icons/media/media-settings';
|
||||
import {MediaPlaybackSpeedCircleIcon} from '@common/icons/media/media-playback-speed-circle';
|
||||
import {MediaSettingsMenuIcon} from '@common/icons/media/media-settings-menu';
|
||||
import {MediaClosedCaptionsIcon} from '@common/icons/media/media-closed-captions';
|
||||
import {MediaArrowLeftIcon} from '@common/icons/media/media-arrow-left';
|
||||
import {MediaLanguageIcon} from '@common/icons/media/media-language';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
type OptionsPanel = 'rate' | 'quality' | 'captions' | 'options' | 'language';
|
||||
|
||||
const Panels = {
|
||||
rate: PlaybackRatePanel,
|
||||
quality: PlaybackQualityPanel,
|
||||
options: OptionsListPanel,
|
||||
captions: CaptionsPanel,
|
||||
language: LanguagePanel,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function PlaybackOptionsButton({
|
||||
color,
|
||||
size,
|
||||
iconSize,
|
||||
className,
|
||||
}: Props) {
|
||||
const darkThemeVars = useDarkThemeVariables();
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="popover"
|
||||
mobileType="tray"
|
||||
placement="top-end"
|
||||
usePortal={!!isMobile}
|
||||
>
|
||||
<IconButton
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
>
|
||||
<MediaSettingsIcon />
|
||||
</IconButton>
|
||||
<Dialog size="w-256" style={darkThemeVars}>
|
||||
<DialogBody padding="p-0">
|
||||
<PlaybackOptionsPanel />
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybackOptionsPanel() {
|
||||
const [activePanel, setActivePanel] = useState<OptionsPanel>('options');
|
||||
const PanelComponent = Panels[activePanel];
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
<PanelComponent
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={setActivePanel}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionsPanelProps {
|
||||
activePanel: OptionsPanel;
|
||||
onActivePanelChange: (panel: OptionsPanel) => void;
|
||||
}
|
||||
function OptionsListPanel({onActivePanelChange}: OptionsPanelProps) {
|
||||
const activeRate = usePlayerStore(s => s.playbackRate);
|
||||
const availableQualities = usePlayerStore(s => s.playbackQualities);
|
||||
const activeQuality = usePlayerStore(s => s.playbackQuality);
|
||||
|
||||
const availableTextTracks = usePlayerStore(s => s.textTracks);
|
||||
const textTrackId = usePlayerStore(s => s.currentTextTrack);
|
||||
const currentTextTrack = availableTextTracks[textTrackId];
|
||||
|
||||
const availableAudioTracks = usePlayerStore(s => s.audioTracks);
|
||||
const audioTrackId = usePlayerStore(s => s.currentAudioTrack);
|
||||
const currentAudioTrack = availableAudioTracks[audioTrackId];
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{x: '-100%', opacity: 0}}
|
||||
animate={{x: 0, opacity: 1}}
|
||||
exit={{x: '100%', opacity: 0}}
|
||||
transition={{type: 'tween', duration: 0.14}}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
startIcon={<MediaPlaybackSpeedCircleIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2">
|
||||
{activeRate}x
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('rate')}
|
||||
>
|
||||
<Trans message="Speed" />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
isDisabled={!availableQualities.length}
|
||||
startIcon={<MediaSettingsMenuIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{activeQuality ? activeQuality : <Trans message="Auto" />}
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('quality')}
|
||||
>
|
||||
<Trans message="Quality" />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
isDisabled={!availableTextTracks.length}
|
||||
startIcon={<MediaClosedCaptionsIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{currentTextTrack ? (
|
||||
currentTextTrack.label
|
||||
) : (
|
||||
<Trans message="None" />
|
||||
)}
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('captions')}
|
||||
>
|
||||
<Trans message="Subtitles/CC" />
|
||||
</ListItem>
|
||||
{availableAudioTracks.length > 1 && (
|
||||
<ListItem
|
||||
startIcon={<MediaLanguageIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{currentAudioTrack ? (
|
||||
currentAudioTrack.label
|
||||
) : (
|
||||
<Trans message="None" />
|
||||
)}
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('language')}
|
||||
>
|
||||
<Trans message="Language" />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybackRatePanel({
|
||||
activePanel,
|
||||
onActivePanelChange,
|
||||
}: OptionsPanelProps) {
|
||||
const activeRate = usePlayerStore(s => s.playbackRate);
|
||||
const availableRates = usePlayerStore(s => s.playbackRates);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Playback speed" />}
|
||||
>
|
||||
<List>
|
||||
{availableRates.map(rate => (
|
||||
<ListItem
|
||||
key={rate}
|
||||
showCheckmark
|
||||
isSelected={activeRate === rate}
|
||||
onSelected={() => {
|
||||
player.setPlaybackRate(rate);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{rate}x
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybackQualityPanel({
|
||||
activePanel,
|
||||
onActivePanelChange,
|
||||
}: OptionsPanelProps) {
|
||||
const activeQuality = usePlayerStore(s => s.playbackQuality);
|
||||
const availableQualities = usePlayerStore(s => s.playbackQualities);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Playback quality" />}
|
||||
>
|
||||
<List>
|
||||
{availableQualities.map(quality => (
|
||||
<ListItem
|
||||
capitalizeFirst
|
||||
key={quality}
|
||||
showCheckmark
|
||||
isSelected={activeQuality === quality}
|
||||
onSelected={() => {
|
||||
player.setPlaybackQuality(quality);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{quality}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionsPanel({activePanel, onActivePanelChange}: OptionsPanelProps) {
|
||||
const currentTextTrack = usePlayerStore(s => s.currentTextTrack);
|
||||
const textTracks = usePlayerStore(s => s.textTracks);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Subtitles/Captions" />}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
key="off"
|
||||
showCheckmark
|
||||
isSelected={currentTextTrack === -1}
|
||||
onSelected={() => {
|
||||
player.setCurrentTextTrack(-1);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
<Trans message="Off" />
|
||||
</ListItem>
|
||||
{textTracks.map((track, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
showCheckmark
|
||||
isSelected={currentTextTrack === index}
|
||||
onSelected={() => {
|
||||
player.setCurrentTextTrack(index);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{track.label}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguagePanel({activePanel, onActivePanelChange}: OptionsPanelProps) {
|
||||
const currentAudioTrack = usePlayerStore(s => s.currentAudioTrack);
|
||||
const audioTracks = usePlayerStore(s => s.audioTracks);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Language" />}
|
||||
>
|
||||
<List>
|
||||
{audioTracks.map((track, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
showCheckmark
|
||||
isSelected={currentAudioTrack === index}
|
||||
onSelected={() => {
|
||||
player.setCurrentAudioTrack(index);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{track.label}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelLayoutProps extends OptionsPanelProps {
|
||||
children: ReactNode;
|
||||
title: ReactNode;
|
||||
}
|
||||
function PanelLayout({onActivePanelChange, children, title}: PanelLayoutProps) {
|
||||
return (
|
||||
<m.div
|
||||
initial={{x: '100%', opacity: 0}}
|
||||
animate={{x: 0, opacity: 1}}
|
||||
exit={{x: '-100%', opacity: 0}}
|
||||
transition={{type: 'tween', duration: 0.14}}
|
||||
>
|
||||
<div className="border-b p-10">
|
||||
<Button
|
||||
className="w-full"
|
||||
color="white"
|
||||
justify="justify-start"
|
||||
startIcon={<MediaArrowLeftIcon />}
|
||||
onClick={() => onActivePanelChange('options')}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
42
common/resources/client/player/ui/controls/player-poster.tsx
Executable file
42
common/resources/client/player/ui/controls/player-poster.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import clsx from 'clsx';
|
||||
import {HTMLAttributes, ReactElement} from 'react';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
hideDuringPlayback?: boolean;
|
||||
fallback?: ReactElement;
|
||||
}
|
||||
export function PlayerPoster({
|
||||
className,
|
||||
hideDuringPlayback = true,
|
||||
fallback,
|
||||
...domProps
|
||||
}: Props) {
|
||||
const posterUrl = usePlayerStore(s => s.posterUrl);
|
||||
const shouldHidePoster = usePlayerStore(
|
||||
s =>
|
||||
hideDuringPlayback && s.playbackStarted && s.providerName !== 'htmlAudio',
|
||||
);
|
||||
if (!posterUrl && !fallback) return null;
|
||||
return (
|
||||
<div
|
||||
{...domProps}
|
||||
className={clsx(
|
||||
'pointer-events-none flex max-h-full w-full items-center justify-center bg-black transition-opacity',
|
||||
shouldHidePoster ? 'opacity-0' : 'opacity-100',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<img
|
||||
loading="lazy"
|
||||
src={posterUrl}
|
||||
alt=""
|
||||
className="max-h-full w-full flex-shrink-0 object-cover"
|
||||
/>
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
common/resources/client/player/ui/controls/previous-button.tsx
Executable file
46
common/resources/client/player/ui/controls/previous-button.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaPreviousIcon} from '@common/icons/media/media-previous';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
export function PreviousButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
stopPropagation,
|
||||
}: Props) {
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message="Previous" />}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={e => {
|
||||
if (stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
player.playPrevious();
|
||||
}}
|
||||
>
|
||||
<MediaPreviousIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
54
common/resources/client/player/ui/controls/repeat-button.tsx
Executable file
54
common/resources/client/player/ui/controls/repeat-button.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaRepeatIcon} from '@common/icons/media/media-repeat';
|
||||
import {MediaRepeatOnIcon} from '@common/icons/media/media-repeat-on';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ReactElement} from 'react';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
activeColor?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function RepeatButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
activeColor = 'primary',
|
||||
className,
|
||||
}: Props) {
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const repeating = usePlayerStore(s => s.repeat);
|
||||
const player = usePlayerActions();
|
||||
|
||||
let label: ReactElement;
|
||||
if (repeating === 'all') {
|
||||
label = <Trans message="Enable repeat one" />;
|
||||
} else if (repeating === 'one') {
|
||||
label = <Trans message="Disable repeat" />;
|
||||
} else {
|
||||
label = <Trans message="Enable repeat" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={repeating ? activeColor : color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.toggleRepeatMode();
|
||||
}}
|
||||
>
|
||||
{repeating === 'one' ? <MediaRepeatOnIcon /> : <MediaRepeatIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
46
common/resources/client/player/ui/controls/seeking/seek-button.tsx
Executable file
46
common/resources/client/player/ui/controls/seeking/seek-button.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {ReactElement} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {MediaSeekForward15Icon} from '@common/icons/media/media-seek-forward15';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
seconds?: number | string;
|
||||
children?: ReactElement<SvgIconProps>;
|
||||
}
|
||||
export function SeekButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
seconds = '+15',
|
||||
children,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={trans(message('Next'))}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.seek(seconds);
|
||||
}}
|
||||
>
|
||||
{children || <MediaSeekForward15Icon />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
63
common/resources/client/player/ui/controls/seeking/seekbar.tsx
Executable file
63
common/resources/client/player/ui/controls/seeking/seekbar.tsx
Executable file
@@ -0,0 +1,63 @@
|
||||
import {Slider} from '@common/ui/forms/slider/slider';
|
||||
import {UseSliderProps} from '@common/ui/forms/slider/use-slider';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useCurrentTime} from '@common/player/hooks/use-current-time';
|
||||
import {useRef} from 'react';
|
||||
|
||||
interface Props {
|
||||
trackColor?: UseSliderProps['trackColor'];
|
||||
fillColor?: UseSliderProps['fillColor'];
|
||||
className?: string;
|
||||
onPointerMove?: UseSliderProps['onPointerMove'];
|
||||
}
|
||||
export function Seekbar({
|
||||
trackColor,
|
||||
fillColor,
|
||||
className,
|
||||
onPointerMove,
|
||||
}: Props) {
|
||||
const {pause, seek, setIsSeeking, play, getState} = usePlayerActions();
|
||||
const duration = usePlayerStore(s => s.mediaDuration);
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const pauseWhileSeeking = usePlayerStore(s => s.pauseWhileSeeking);
|
||||
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
const wasPlayingBeforeDragging = useRef(false);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
fillColor={fillColor}
|
||||
trackColor={trackColor}
|
||||
thumbSize="w-14 h-14"
|
||||
showThumbOnHoverOnly
|
||||
className={className}
|
||||
width="w-auto"
|
||||
isDisabled={!playerReady}
|
||||
value={currentTime}
|
||||
minValue={0}
|
||||
maxValue={duration}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerDown={() => {
|
||||
setIsSeeking(true);
|
||||
if (pauseWhileSeeking) {
|
||||
wasPlayingBeforeDragging.current =
|
||||
getState().isPlaying || getState().isBuffering;
|
||||
pause();
|
||||
}
|
||||
}}
|
||||
onChange={value => {
|
||||
getState().emit('progress', {currentTime: value});
|
||||
seek(value);
|
||||
}}
|
||||
onChangeEnd={() => {
|
||||
setIsSeeking(false);
|
||||
if (pauseWhileSeeking && wasPlayingBeforeDragging.current) {
|
||||
play();
|
||||
wasPlayingBeforeDragging.current = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
common/resources/client/player/ui/controls/shuffle-button.tsx
Executable file
50
common/resources/client/player/ui/controls/shuffle-button.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaShuffleIcon} from '@common/icons/media/media-shuffle';
|
||||
import {MediaShuffleOnIcon} from '@common/icons/media/media-shuffle-on';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
activeColor?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function ShuffleButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
activeColor = 'primary',
|
||||
className,
|
||||
}: Props) {
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const isShuffling = usePlayerStore(s => s.shuffling);
|
||||
const player = usePlayerActions();
|
||||
|
||||
const label = isShuffling ? (
|
||||
<Trans message="Disable shuffle" />
|
||||
) : (
|
||||
<Trans message="Enable shuffle" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={isShuffling ? activeColor : color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.toggleShuffling();
|
||||
}}
|
||||
>
|
||||
{isShuffling ? <MediaShuffleOnIcon /> : <MediaShuffleIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
61
common/resources/client/player/ui/controls/toggle-captions-button.tsx
Executable file
61
common/resources/client/player/ui/controls/toggle-captions-button.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {MediaClosedCaptionsIcon} from '@common/icons/media/media-closed-captions';
|
||||
import {MediaClosedCaptionsOnIcon} from '@common/icons/media/media-closed-captions-on';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function ToggleCaptionsButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const captionsVisible = usePlayerStore(s => s.textTrackIsVisible);
|
||||
const haveCaptions = usePlayerStore(s => !!s.textTracks.length);
|
||||
|
||||
if (!haveCaptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelMessage = trans(
|
||||
captionsVisible
|
||||
? message('Hide subtitles/captions (c)')
|
||||
: message('Show subtitles/captions (c)')
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message={labelMessage} />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={labelMessage}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.setTextTrackVisibility(!captionsVisible);
|
||||
}}
|
||||
>
|
||||
{captionsVisible ? (
|
||||
<MediaClosedCaptionsOnIcon />
|
||||
) : (
|
||||
<MediaClosedCaptionsIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
95
common/resources/client/player/ui/controls/volume-controls.tsx
Executable file
95
common/resources/client/player/ui/controls/volume-controls.tsx
Executable file
@@ -0,0 +1,95 @@
|
||||
import {Slider} from '@common/ui/forms/slider/slider';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {BaseSliderProps} from '@common/ui/forms/slider/base-slider';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaMuteIcon} from '@common/icons/media/media-mute';
|
||||
import {MediaVolumeLowIcon} from '@common/icons/media/media-volume-low';
|
||||
import {MediaVolumeHighIcon} from '@common/icons/media/media-volume-high';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
trackColor?: BaseSliderProps['trackColor'];
|
||||
fillColor?: BaseSliderProps['fillColor'];
|
||||
buttonColor?: ButtonProps['color'];
|
||||
className?: string;
|
||||
}
|
||||
export function VolumeControls({
|
||||
trackColor,
|
||||
fillColor,
|
||||
buttonColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const volume = usePlayerStore(s => s.volume);
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<div className={clsx('flex w-min items-center gap-4', className)}>
|
||||
<ToggleMuteButton color={buttonColor} />
|
||||
<Slider
|
||||
isDisabled={!playerReady}
|
||||
showThumbOnHoverOnly
|
||||
thumbSize="w-14 h-14"
|
||||
trackColor={trackColor}
|
||||
fillColor={fillColor}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
className="flex-auto"
|
||||
width="w-96"
|
||||
value={volume}
|
||||
onChange={value => {
|
||||
player.setVolume(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleMuteButtonProps {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
}
|
||||
export function ToggleMuteButton({
|
||||
color,
|
||||
size = 'sm',
|
||||
iconSize = 'md',
|
||||
}: ToggleMuteButtonProps) {
|
||||
const isMuted = usePlayerStore(s => s.muted);
|
||||
const volume = usePlayerStore(s => s.volume);
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
if (isMuted) {
|
||||
return (
|
||||
<Tooltip label={<Trans message="Unmute" />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
onClick={() => player.setMuted(false)}
|
||||
>
|
||||
<MediaMuteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={<Trans message="Mute" />}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
onClick={() => player.setMuted(true)}
|
||||
>
|
||||
{volume < 40 ? <MediaVolumeLowIcon /> : <MediaVolumeHighIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
57
common/resources/client/player/ui/player-outlet.tsx
Executable file
57
common/resources/client/player/ui/player-outlet.tsx
Executable file
@@ -0,0 +1,57 @@
|
||||
import React, {memo, Suspense, useContext, useEffect} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {YoutubeProvider} from '@common/player/providers/youtube/youtube-provider';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {HtmlVideoProvider} from '@common/player/providers/html-video-provider';
|
||||
import {HtmlAudioProvider} from '@common/player/providers/html-audio-provider';
|
||||
|
||||
const HlsProvider = React.lazy(
|
||||
() => import('@common/player/providers/hls-provider')
|
||||
);
|
||||
const DashProvider = React.lazy(
|
||||
() => import('@common/player/providers/dash-provider')
|
||||
);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export const PlayerOutlet = memo(({className}: Props) => {
|
||||
const {getState} = useContext(PlayerStoreContext);
|
||||
|
||||
useEffect(() => {
|
||||
getState().init();
|
||||
return getState().destroy;
|
||||
}, [getState]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Provider />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Provider() {
|
||||
const provider = usePlayerStore(s => s.providerName);
|
||||
switch (provider) {
|
||||
case 'youtube':
|
||||
return <YoutubeProvider />;
|
||||
case 'htmlVideo':
|
||||
return <HtmlVideoProvider />;
|
||||
case 'htmlAudio':
|
||||
return <HtmlAudioProvider />;
|
||||
case 'hls':
|
||||
return (
|
||||
<Suspense>
|
||||
<HlsProvider />
|
||||
</Suspense>
|
||||
);
|
||||
case 'dash':
|
||||
return (
|
||||
<Suspense>
|
||||
<DashProvider />
|
||||
</Suspense>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
126
common/resources/client/player/ui/video-player/video-player-controls.tsx
Executable file
126
common/resources/client/player/ui/video-player/video-player-controls.tsx
Executable file
@@ -0,0 +1,126 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import clsx from 'clsx';
|
||||
import {Seekbar} from '@common/player/ui/controls/seeking/seekbar';
|
||||
import {PlayButton} from '@common/player/ui/controls/play-button';
|
||||
import {NextButton} from '@common/player/ui/controls/next-button';
|
||||
import {
|
||||
ToggleMuteButton,
|
||||
VolumeControls,
|
||||
} from '@common/player/ui/controls/volume-controls';
|
||||
import {FormattedCurrentTime} from '@common/player/ui/controls/formatted-current-time';
|
||||
import {FormattedPlayerDuration} from '@common/player/ui/controls/formatted-player-duration';
|
||||
import {ToggleCaptionsButton} from '@common/player/ui/controls/toggle-captions-button';
|
||||
import {PlaybackOptionsButton} from '@common/player/ui/controls/playback-options-button';
|
||||
import {FullscreenButton} from '@common/player/ui/controls/fullscreen-button';
|
||||
import {PipButton} from '@common/player/ui/controls/pip-button';
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
interface Props {
|
||||
rightActions?: ReactNode;
|
||||
onPointerEnter?: () => void;
|
||||
onPointerLeave?: () => void;
|
||||
}
|
||||
export function VideoPlayerControls(props: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const controlsVisible = usePlayerStore(s => s.controlsVisible);
|
||||
|
||||
const className = clsx(
|
||||
'player-bottom-text-shadow absolute z-40 text-white/87 transition-opacity duration-300',
|
||||
controlsVisible ? 'opacity-100' : 'opacity-0'
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<MobileControls className={className} {...props} />
|
||||
) : (
|
||||
<DesktopControls className={className} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponsiveControlsProps extends Props {
|
||||
className: string;
|
||||
}
|
||||
function DesktopControls({
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
rightActions,
|
||||
className,
|
||||
}: ResponsiveControlsProps) {
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={clsx('bottom-0 left-0 right-0 p-8', className)}
|
||||
>
|
||||
<Seekbar trackColor="bg-white/40" />
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<PlayButton color="white" />
|
||||
<NextButton color="white" />
|
||||
<VolumeControls
|
||||
className="max-md:hidden"
|
||||
fillColor="bg-white"
|
||||
trackColor="bg-white/20"
|
||||
buttonColor="white"
|
||||
/>
|
||||
<span className="ml-10 text-sm">
|
||||
<FormattedCurrentTime className="min-w-40 text-right" /> /{' '}
|
||||
<FormattedPlayerDuration className="min-w-40 text-right" />
|
||||
</span>
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-4">
|
||||
{rightActions}
|
||||
<ToggleCaptionsButton color="white" />
|
||||
<PlaybackOptionsButton color="white" />
|
||||
<FullscreenButton className="ml-auto" color="white" />
|
||||
<PipButton color="white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileControls({
|
||||
rightActions,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
className,
|
||||
}: ResponsiveControlsProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={clsx('left-0 right-0 top-0 px-6 pt-6 ', className)}
|
||||
>
|
||||
<div className="flex items-end justify-end">
|
||||
{rightActions}
|
||||
<ToggleCaptionsButton color="white" />
|
||||
<PlaybackOptionsButton color="white" />
|
||||
<PipButton color="white" />
|
||||
<ToggleMuteButton color="white" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={clsx('bottom-0 left-0 right-0 px-12', className)}
|
||||
>
|
||||
<div className="flex items-end gap-24">
|
||||
<div className="text-sm">
|
||||
<FormattedCurrentTime className="min-w-40 text-right" /> /{' '}
|
||||
<FormattedPlayerDuration className="min-w-40 text-right" />
|
||||
</div>
|
||||
<FullscreenButton
|
||||
size="sm"
|
||||
iconSize="lg"
|
||||
color="white"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
<Seekbar trackColor="bg-white/40" />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
254
common/resources/client/player/ui/video-player/video-player.tsx
Executable file
254
common/resources/client/player/ui/video-player/video-player.tsx
Executable file
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {PlayerContext} from '@common/player/player-context';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {PlayerOutlet} from '@common/player/ui/player-outlet';
|
||||
import {
|
||||
PlayerActions,
|
||||
usePlayerActions,
|
||||
} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import clsx from 'clsx';
|
||||
import {PlayerPoster} from '@common/player/ui/controls/player-poster';
|
||||
import {usePlayerClickHandler} from '@common/player/hooks/use-player-click-handler';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {BufferingSpinner} from '@common/player/ui/controls/buffering-spinner';
|
||||
import {guessPlayerProvider} from '@common/player/utils/guess-player-provider';
|
||||
import {usePrevious} from '@common/utils/hooks/use-previous';
|
||||
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
|
||||
import {VideoPlayerControls} from '@common/player/ui/video-player/video-player-controls';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
queue?: MediaItem[];
|
||||
cuedMediaId?: string;
|
||||
autoPlay?: boolean;
|
||||
src?: string;
|
||||
listeners?: PlayerStoreOptions['listeners'];
|
||||
onDestroy?: PlayerStoreOptions['onDestroy'];
|
||||
onBeforePlayNext?: PlayerStoreOptions['onBeforePlayNext'];
|
||||
onBeforePlayPrevious?: PlayerStoreOptions['onBeforePlayPrevious'];
|
||||
apiRef?: MutableRefObject<PlayerActions>;
|
||||
rightActions?: ReactNode;
|
||||
}
|
||||
export function VideoPlayer({
|
||||
id,
|
||||
queue,
|
||||
cuedMediaId,
|
||||
autoPlay,
|
||||
src,
|
||||
listeners,
|
||||
onBeforePlayPrevious,
|
||||
onBeforePlayNext,
|
||||
onDestroy,
|
||||
apiRef,
|
||||
rightActions,
|
||||
}: Props) {
|
||||
return (
|
||||
<PlayerContext
|
||||
id={id}
|
||||
options={{
|
||||
autoPlay,
|
||||
listeners,
|
||||
onDestroy,
|
||||
onBeforePlayNext,
|
||||
onBeforePlayPrevious,
|
||||
initialData: {
|
||||
queue: queue ? queue : [mediaItemFromSrc(src!)],
|
||||
cuedMediaId,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<QueueOverrider src={src} queue={queue} />
|
||||
<PlayerLayout apiRef={apiRef} rightActions={rightActions} />
|
||||
</PlayerContext>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlayerLayoutProps {
|
||||
apiRef?: MutableRefObject<PlayerActions>;
|
||||
rightActions?: ReactNode;
|
||||
}
|
||||
function PlayerLayout({apiRef, rightActions}: PlayerLayoutProps) {
|
||||
const leaveTimerRef = useRef<number | null>();
|
||||
const inactiveTimerRef = useRef<number | null>();
|
||||
const pointerIsOverControls = useRef(false);
|
||||
const actions = usePlayerActions();
|
||||
const controlsVisible = usePlayerStore(s => s.controlsVisible);
|
||||
const {setControlsVisible, getState} = actions;
|
||||
|
||||
const clickHandler = usePlayerClickHandler();
|
||||
|
||||
const clearTimers = () => {
|
||||
if (leaveTimerRef.current) {
|
||||
clearTimeout(leaveTimerRef.current);
|
||||
leaveTimerRef.current = null;
|
||||
}
|
||||
if (inactiveTimerRef.current) {
|
||||
clearTimeout(inactiveTimerRef.current);
|
||||
inactiveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startInactiveTimer = useCallback(() => {
|
||||
if (getState().isPlaying) {
|
||||
inactiveTimerRef.current = window.setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
}, 3500);
|
||||
}
|
||||
}, [getState, setControlsVisible]);
|
||||
|
||||
// show controls when any key is pressed
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
clearTimers();
|
||||
setControlsVisible(true);
|
||||
};
|
||||
document.addEventListener('keydown', listener);
|
||||
return () => document.removeEventListener('keydown', listener);
|
||||
}, [setControlsVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiRef) {
|
||||
apiRef.current = actions;
|
||||
return actions.subscribe({
|
||||
play: () => startInactiveTimer(),
|
||||
});
|
||||
}
|
||||
}, [apiRef, actions, setControlsVisible, startInactiveTimer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fullscreen-host relative isolate aspect-video bg-black',
|
||||
!controlsVisible && 'cursor-none',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onPointerEnter={() => {
|
||||
setControlsVisible(true);
|
||||
clearTimers();
|
||||
}}
|
||||
onPointerMove={() => {
|
||||
if (pointerIsOverControls.current && controlsVisible) {
|
||||
return;
|
||||
}
|
||||
if (inactiveTimerRef.current) {
|
||||
setControlsVisible(true);
|
||||
}
|
||||
clearTimers();
|
||||
startInactiveTimer();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
clearTimers();
|
||||
if (!getState().isPlaying) {
|
||||
return;
|
||||
}
|
||||
leaveTimerRef.current = window.setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
}, 2500);
|
||||
}}
|
||||
>
|
||||
<PlayerOutlet className="z-50 h-full w-full" />
|
||||
<Blocker />
|
||||
<PlayerPoster className="absolute inset-0 z-30" />
|
||||
<OverlayButtons />
|
||||
<BufferingSpinner
|
||||
className="spinner pointer-events-none absolute inset-0 z-40 m-auto h-50 w-50"
|
||||
fillColor="border-white"
|
||||
trackColor="border-white/30"
|
||||
size="w-50 h-50"
|
||||
/>
|
||||
<BottomGradient />
|
||||
<VideoPlayerControls
|
||||
rightActions={rightActions}
|
||||
onPointerEnter={() => {
|
||||
pointerIsOverControls.current = true;
|
||||
setControlsVisible(true);
|
||||
clearTimers();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
pointerIsOverControls.current = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverlayButtons() {
|
||||
const showPlayButton = usePlayerStore(s => !s.isPlaying && !s.isSeeking);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute left-0 top-0 z-40 flex h-full w-full items-center justify-center transition-opacity',
|
||||
showPlayButton ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
color="primary"
|
||||
variant="raised"
|
||||
size="lg"
|
||||
radius="rounded-full"
|
||||
>
|
||||
<MediaPlayIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// required in order for "onPointerEnter" to fire consistently when player provider is iframe
|
||||
function Blocker() {
|
||||
return <div className="absolute inset-0 z-20" />;
|
||||
}
|
||||
|
||||
function BottomGradient() {
|
||||
const controlsVisible = usePlayerStore(s => s.controlsVisible);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'player-bottom-gradient pointer-events-none absolute bottom-0 z-30 h-full w-full transition-opacity duration-300',
|
||||
controlsVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaItemFromSrc(src: string): MediaItem {
|
||||
return {
|
||||
id: src,
|
||||
src,
|
||||
provider: guessPlayerProvider(src),
|
||||
};
|
||||
}
|
||||
|
||||
interface QueueOverriderProps {
|
||||
src?: string;
|
||||
queue?: MediaItem[];
|
||||
}
|
||||
function QueueOverrider({src, queue}: QueueOverriderProps) {
|
||||
const {getState, overrideQueue} = usePlayerActions();
|
||||
|
||||
const queueKey = queue?.map(item => item.id).join('-') ?? '';
|
||||
const previousKey = usePrevious(queueKey);
|
||||
|
||||
// override queue when any of specified queue item id or order changes
|
||||
useEffect(() => {
|
||||
if (queue && previousKey && queueKey && previousKey !== queueKey) {
|
||||
overrideQueue(queue);
|
||||
}
|
||||
}, [queueKey, previousKey, queue, overrideQueue]);
|
||||
|
||||
// override queue when src changes
|
||||
useEffect(() => {
|
||||
if (src && getState().cuedMedia?.src !== src) {
|
||||
overrideQueue([mediaItemFromSrc(src)]);
|
||||
}
|
||||
}, [src, getState, overrideQueue]);
|
||||
|
||||
return null;
|
||||
}
|
||||
27
common/resources/client/player/utils/guess-player-provider.ts
Executable file
27
common/resources/client/player/utils/guess-player-provider.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {IS_IOS} from '@common/utils/platform';
|
||||
|
||||
const hlsRegex = /\.(m3u8)($|\?)/i;
|
||||
const dashRegex = /\.(mpd)($|\?)/i;
|
||||
const audioRegex =
|
||||
/\.(m4a|mp4a|mpga|mp2|mp2a|mp3|m2a|m3a|wav|weba|aac|oga|spx|flac)($|\?)/i;
|
||||
const youtubeUrlRegex =
|
||||
/(?:youtu\.be|youtube|youtube\.com|youtube-nocookie\.com)\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=|)((?:\w|-){11})/;
|
||||
const youtubeIdRegex = /^((?:\w|-){11})$/;
|
||||
export function guessPlayerProvider(src: string): MediaItem['provider'] {
|
||||
if (youtubeUrlRegex.test(src) || youtubeIdRegex.test(src)) {
|
||||
return 'youtube';
|
||||
} else if (audioRegex.test(src)) {
|
||||
return 'htmlAudio';
|
||||
} else if (hlsRegex.test(src)) {
|
||||
if (IS_IOS) {
|
||||
return 'htmlVideo';
|
||||
} else {
|
||||
return 'hls';
|
||||
}
|
||||
} else if (dashRegex.test(src)) {
|
||||
return 'dash';
|
||||
} else {
|
||||
return 'htmlVideo';
|
||||
}
|
||||
}
|
||||
35
common/resources/client/player/utils/init-player-media-session.ts
Executable file
35
common/resources/client/player/utils/init-player-media-session.ts
Executable file
@@ -0,0 +1,35 @@
|
||||
import {Optional} from 'utility-types';
|
||||
import {PlayerState} from '@common/player/state/player-state';
|
||||
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
|
||||
|
||||
export function initPlayerMediaSession(
|
||||
state: () => PlayerState,
|
||||
options: PlayerStoreOptions
|
||||
) {
|
||||
if ('mediaSession' in navigator) {
|
||||
const actionHandlers: Optional<
|
||||
Record<MediaSessionAction, MediaSessionActionHandler>
|
||||
> = {
|
||||
play: () => state().play(),
|
||||
pause: () => state().pause(),
|
||||
previoustrack: () => state().playPrevious(),
|
||||
nexttrack: () => state().playNext(),
|
||||
stop: () => state().stop(),
|
||||
seekbackward: () => state().seek(state().getCurrentTime() - 10),
|
||||
seekforward: () => state().seek(state().getCurrentTime() + 10),
|
||||
seekto: details => state().seek(details.seekTime || 0),
|
||||
};
|
||||
for (const key in actionHandlers) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(
|
||||
key as MediaSessionAction,
|
||||
actionHandlers[key as MediaSessionAction]!
|
||||
);
|
||||
} catch (error) {}
|
||||
}
|
||||
const cuedMedia = state().cuedMedia;
|
||||
if (cuedMedia) {
|
||||
options.setMediaSessionMetadata?.(cuedMedia);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
common/resources/client/player/utils/is-same-media.ts
Executable file
6
common/resources/client/player/utils/is-same-media.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
|
||||
export function isSameMedia(a?: MediaItem, b?: MediaItem): boolean {
|
||||
if (!a || !b) return false;
|
||||
return a.id === b.id && a.groupId === b.groupId;
|
||||
}
|
||||
33
common/resources/client/player/utils/player-local-storage.ts
Executable file
33
common/resources/client/player/utils/player-local-storage.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import {getFromLocalStorage} from '@common/utils/hooks/local-storage';
|
||||
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
|
||||
import {PlayerState} from '@common/player/state/player-state';
|
||||
|
||||
export interface PersistablePlayerState {
|
||||
muted?: PlayerState['muted'];
|
||||
repeat?: PlayerState['repeat'];
|
||||
shuffling?: PlayerState['shuffling'];
|
||||
volume?: PlayerState['volume'];
|
||||
}
|
||||
|
||||
export interface PlayerInitialData {
|
||||
state?: PersistablePlayerState;
|
||||
queue?: PlayerState['originalQueue'];
|
||||
cuedMediaId?: string | number;
|
||||
}
|
||||
|
||||
export function getPlayerStateFromLocalStorage(
|
||||
id: string | number,
|
||||
options?: PlayerStoreOptions
|
||||
): PlayerInitialData {
|
||||
const defaultVolume = options?.defaultVolume || 30;
|
||||
return {
|
||||
state: {
|
||||
muted: getFromLocalStorage(`player.${id}.muted`) ?? false,
|
||||
repeat: getFromLocalStorage(`player.${id}.repeat`) ?? 'all',
|
||||
shuffling: getFromLocalStorage(`player.${id}.shuffling`) ?? false,
|
||||
volume: getFromLocalStorage(`player.${id}.volume`) ?? defaultVolume,
|
||||
},
|
||||
queue: getFromLocalStorage(`player.${id}.queue`, []),
|
||||
cuedMediaId: getFromLocalStorage(`player.${id}.cuedMediaId`),
|
||||
};
|
||||
}
|
||||
19
common/resources/client/player/utils/reset-media-session.ts
Executable file
19
common/resources/client/player/utils/reset-media-session.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
export function resetMediaSession() {
|
||||
if ('mediaSession' in navigator) {
|
||||
const actionHandlers: MediaSessionAction[] = [
|
||||
'play',
|
||||
'pause',
|
||||
'previoustrack',
|
||||
'nexttrack',
|
||||
'stop',
|
||||
'seekbackward',
|
||||
'seekforward',
|
||||
'seekto',
|
||||
];
|
||||
actionHandlers.forEach(action =>
|
||||
navigator.mediaSession.setActionHandler(action, null)
|
||||
);
|
||||
navigator.mediaSession.metadata = null;
|
||||
navigator.mediaSession.playbackState = 'none';
|
||||
}
|
||||
}
|
||||
3
common/resources/client/player/utils/youtube-id-from-src.ts
Executable file
3
common/resources/client/player/utils/youtube-id-from-src.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export function youtubeIdFromSrc(src: string) {
|
||||
return src.match(/((?:\w|-){11})/)?.[0];
|
||||
}
|
||||
Reference in New Issue
Block a user