@7sage/vidstack
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
286 lines (282 loc) • 9.11 kB
JavaScript
import { createScope, signal, effect, isString, deferredPromise, isObject, isNumber, isBoolean } from '../chunks/vidstack-Bu2kfzUd.js';
import { TimeRange } from '../chunks/vidstack-BFg1ZqiG.js';
import { preconnect } from '../chunks/vidstack-zG6PIeGg.js';
import { EmbedProvider } from '../chunks/vidstack-BoAGnlRt.js';
import { resolveYouTubeVideoId } from '../chunks/vidstack-Dm1xEU9Q.js';
import '../chunks/vidstack-CjhKISI0.js';
const YouTubePlayerState = {
Ended: 0,
Playing: 1,
Paused: 2,
Buffering: 3,
Cued: 5
};
class YouTubeProvider extends EmbedProvider {
$$PROVIDER_TYPE = "YOUTUBE";
scope = createScope();
#ctx;
#videoId = signal("");
#state = -1;
#currentSrc = null;
#seekingTimer = -1;
#invalidPlay = false;
#promises = /* @__PURE__ */ new Map();
constructor(iframe, ctx) {
super(iframe);
this.#ctx = ctx;
}
/**
* Sets the player's interface language. The parameter value is an ISO 639-1 two-letter
* language code or a fully specified locale. For example, fr and fr-ca are both valid values.
* Other language input codes, such as IETF language tags (BCP 47) might also be handled properly.
*
* The interface language is used for tooltips in the player and also affects the default caption
* track. Note that YouTube might select a different caption track language for a particular
* user based on the user's individual language preferences and the availability of caption tracks.
*
* @defaultValue 'en'
*/
language = "en";
color = "red";
/**
* Whether cookies should be enabled on the embed. This is turned off by default to be
* GDPR-compliant.
*
* @defaultValue `false`
*/
cookies = false;
get currentSrc() {
return this.#currentSrc;
}
get type() {
return "youtube";
}
get videoId() {
return this.#videoId();
}
preconnect() {
preconnect(this.getOrigin());
}
setup() {
super.setup();
effect(this.#watchVideoId.bind(this));
this.#ctx.notify("provider-setup", this);
}
destroy() {
this.#reset();
const message = "provider destroyed";
for (const promises of this.#promises.values()) {
for (const { reject } of promises) reject(message);
}
this.#promises.clear();
}
async play() {
return this.#remote("playVideo");
}
#playFail(message) {
this.#getPromise("playVideo")?.reject(message);
}
async pause() {
return this.#remote("pauseVideo");
}
#pauseFail(message) {
this.#getPromise("pauseVideo")?.reject(message);
}
setMuted(muted) {
if (muted) this.#remote("mute");
else this.#remote("unMute");
}
setCurrentTime(time) {
this.#remote("seekTo", time);
this.#ctx.notify("seeking", time);
}
setVolume(volume) {
this.#remote("setVolume", volume * 100);
}
setPlaybackRate(rate) {
this.#remote("setPlaybackRate", rate);
}
async loadSource(src) {
if (!isString(src.src)) {
this.#currentSrc = null;
this.#videoId.set("");
return;
}
const videoId = resolveYouTubeVideoId(src.src);
this.#videoId.set(videoId ?? "");
this.#currentSrc = src;
}
getOrigin() {
return !this.cookies ? "https://www.youtube-nocookie.com" : "https://www.youtube.com";
}
#watchVideoId() {
this.#reset();
const videoId = this.#videoId();
if (!videoId) {
this.src.set("");
return;
}
this.src.set(`${this.getOrigin()}/embed/${videoId}`);
this.#ctx.notify("load-start");
}
buildParams() {
const { keyDisabled } = this.#ctx.$props, { muted, playsInline, nativeControls } = this.#ctx.$state, showControls = nativeControls();
return {
rel: 0,
autoplay: 0,
cc_lang_pref: this.language,
cc_load_policy: showControls ? 1 : void 0,
color: this.color,
controls: showControls ? 1 : 0,
disablekb: !showControls || keyDisabled() ? 1 : 0,
enablejsapi: 1,
fs: 1,
hl: this.language,
iv_load_policy: showControls ? 1 : 3,
mute: muted() ? 1 : 0,
playsinline: playsInline() ? 1 : 0
};
}
#remote(command, arg) {
let promise = deferredPromise(), promises = this.#promises.get(command);
if (!promises) this.#promises.set(command, promises = []);
promises.push(promise);
this.postMessage({
event: "command",
func: command,
args: arg ? [arg] : void 0
});
return promise.promise;
}
onLoad() {
window.setTimeout(() => this.postMessage({ event: "listening" }), 100);
}
#onReady(trigger) {
this.#ctx.notify("loaded-metadata");
this.#ctx.notify("loaded-data");
this.#ctx.delegate.ready(void 0, trigger);
}
#onPause(trigger) {
this.#getPromise("pauseVideo")?.resolve();
this.#ctx.notify("pause", void 0, trigger);
}
#onTimeUpdate(time, trigger) {
const { duration, realCurrentTime } = this.#ctx.$state, hasEnded = this.#state === YouTubePlayerState.Ended, boundTime = hasEnded ? duration() : time;
this.#ctx.notify("time-change", boundTime, trigger);
if (!hasEnded && Math.abs(boundTime - realCurrentTime()) > 1) {
this.#ctx.notify("seeking", boundTime, trigger);
}
}
#onProgress(buffered, seekable, trigger) {
const detail = {
buffered: new TimeRange(0, buffered),
seekable
};
this.#ctx.notify("progress", detail, trigger);
const { seeking, realCurrentTime } = this.#ctx.$state;
if (seeking() && buffered > realCurrentTime()) {
this.#onSeeked(trigger);
}
}
#onSeeked(trigger) {
const { paused, realCurrentTime } = this.#ctx.$state;
window.clearTimeout(this.#seekingTimer);
this.#seekingTimer = window.setTimeout(
() => {
this.#ctx.notify("seeked", realCurrentTime(), trigger);
this.#seekingTimer = -1;
},
paused() ? 100 : 0
);
}
#onEnded(trigger) {
const { seeking } = this.#ctx.$state;
if (seeking()) this.#onSeeked(trigger);
this.#ctx.notify("pause", void 0, trigger);
this.#ctx.notify("end", void 0, trigger);
}
#onStateChange(state, trigger) {
const { paused, seeking } = this.#ctx.$state, isPlaying = state === YouTubePlayerState.Playing, isBuffering = state === YouTubePlayerState.Buffering, isPendingPlay = this.#isPending("playVideo"), isPlay = paused() && (isBuffering || isPlaying);
if (isBuffering) this.#ctx.notify("waiting", void 0, trigger);
if (seeking() && isPlaying) {
this.#onSeeked(trigger);
}
if (this.#invalidPlay && isPlaying) {
this.pause();
this.#invalidPlay = false;
this.setMuted(this.#ctx.$state.muted());
return;
}
if (!isPendingPlay && isPlay) {
this.#invalidPlay = true;
this.setMuted(true);
return;
}
if (isPlay) {
this.#getPromise("playVideo")?.resolve();
this.#ctx.notify("play", void 0, trigger);
}
switch (state) {
case YouTubePlayerState.Cued:
this.#onReady(trigger);
break;
case YouTubePlayerState.Playing:
this.#ctx.notify("playing", void 0, trigger);
break;
case YouTubePlayerState.Paused:
this.#onPause(trigger);
break;
case YouTubePlayerState.Ended:
this.#onEnded(trigger);
break;
}
this.#state = state;
}
onMessage({ info }, event) {
if (!info) return;
const { title, intrinsicDuration, playbackRate } = this.#ctx.$state;
if (isObject(info.videoData) && info.videoData.title !== title()) {
this.#ctx.notify("title-change", info.videoData.title, event);
}
if (isNumber(info.duration) && info.duration !== intrinsicDuration()) {
if (isNumber(info.videoLoadedFraction)) {
const buffered = info.progressState?.loaded ?? info.videoLoadedFraction * info.duration, seekable = new TimeRange(0, info.duration);
this.#onProgress(buffered, seekable, event);
}
this.#ctx.notify("duration-change", info.duration, event);
}
if (isNumber(info.playbackRate) && info.playbackRate !== playbackRate()) {
this.#ctx.notify("rate-change", info.playbackRate, event);
}
if (info.progressState) {
const { current, seekableStart, seekableEnd, loaded, duration } = info.progressState;
this.#onTimeUpdate(current, event);
this.#onProgress(loaded, new TimeRange(seekableStart, seekableEnd), event);
if (duration !== intrinsicDuration()) {
this.#ctx.notify("duration-change", duration, event);
}
}
if (isNumber(info.volume) && isBoolean(info.muted) && !this.#invalidPlay) {
const detail = {
muted: info.muted,
volume: info.volume / 100
};
this.#ctx.notify("volume-change", detail, event);
}
if (isNumber(info.playerState) && info.playerState !== this.#state) {
this.#onStateChange(info.playerState, event);
}
}
#reset() {
this.#state = -1;
this.#seekingTimer = -1;
this.#invalidPlay = false;
}
#getPromise(command) {
return this.#promises.get(command)?.shift();
}
#isPending(command) {
return Boolean(this.#promises.get(command)?.length);
}
}
export { YouTubeProvider };