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

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

View File

@@ -0,0 +1,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;
}
}

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

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

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

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