@7sage/vidstack
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
556 lines (548 loc) • 17.8 kB
JavaScript
import { IS_SAFARI, IS_IOS, isHLSSrc, isMediaStream } from '../chunks/vidstack-xMS8dnYq.js';
import { signal, EventsController, effect, onDispose, peek, isNil, listenEvent, DOMEvent, createScope, setAttribute, isString } from '../chunks/vidstack-BGSTndAW.js';
import { RAFLoop } from '../chunks/vidstack-DqAw8m9J.js';
import { getNumberOfDecimalPlaces } from '../chunks/vidstack-Dihypf8P.js';
import { ListSymbol } from '../chunks/vidstack-D5EzK014.js';
let audioContext = null, gainNodes = [], elAudioSources = [];
function getOrCreateAudioCtx() {
return audioContext ??= new AudioContext();
}
function createGainNode() {
const audioCtx = getOrCreateAudioCtx(), gainNode = audioCtx.createGain();
gainNode.connect(audioCtx.destination);
gainNodes.push(gainNode);
return gainNode;
}
function createElementSource(el, gainNode) {
const audioCtx = getOrCreateAudioCtx(), src = audioCtx.createMediaElementSource(el);
if (gainNode) {
src.connect(gainNode);
}
elAudioSources.push(src);
return src;
}
function destroyGainNode(node) {
const idx = gainNodes.indexOf(node);
if (idx !== -1) {
gainNodes.splice(idx, 1);
node.disconnect();
freeAudioCtxWhenAllResourcesFreed();
}
}
function destroyElementSource(src) {
const idx = elAudioSources.indexOf(src);
if (idx !== -1) {
elAudioSources.splice(idx, 1);
src.disconnect();
freeAudioCtxWhenAllResourcesFreed();
}
}
function freeAudioCtxWhenAllResourcesFreed() {
if (audioContext && gainNodes.length === 0 && elAudioSources.length === 0) {
audioContext.close().then(() => {
audioContext = null;
});
}
}
class AudioGain {
#media;
#onChange;
#gainNode = null;
#srcAudioNode = null;
get currentGain() {
return this.#gainNode?.gain?.value ?? null;
}
get supported() {
return true;
}
constructor(media, onChange) {
this.#media = media;
this.#onChange = onChange;
}
setGain(gain) {
const currGain = this.currentGain;
if (gain === this.currentGain) {
return;
}
if (gain === 1 && currGain !== 1) {
this.removeGain();
return;
}
if (!this.#gainNode) {
this.#gainNode = createGainNode();
if (this.#srcAudioNode) {
this.#srcAudioNode.connect(this.#gainNode);
}
}
if (!this.#srcAudioNode) {
this.#srcAudioNode = createElementSource(this.#media, this.#gainNode);
}
this.#gainNode.gain.value = gain;
this.#onChange(gain);
}
removeGain() {
if (!this.#gainNode) return;
if (this.#srcAudioNode) {
this.#srcAudioNode.connect(getOrCreateAudioCtx().destination);
}
this.#destroyGainNode();
this.#onChange(null);
}
destroy() {
this.#destroySrcNode();
this.#destroyGainNode();
}
#destroySrcNode() {
if (!this.#srcAudioNode) return;
try {
destroyElementSource(this.#srcAudioNode);
} catch (e) {
} finally {
this.#srcAudioNode = null;
}
}
#destroyGainNode() {
if (!this.#gainNode) return;
try {
destroyGainNode(this.#gainNode);
} catch (e) {
} finally {
this.#gainNode = null;
}
}
}
const PAGE_EVENTS = ["focus", "blur", "visibilitychange", "pageshow", "pagehide"];
class PageVisibility {
#state = signal(determinePageState());
#visibility = signal(document.visibilityState);
#safariBeforeUnloadTimeout;
connect() {
const events = new EventsController(window), handlePageEvent = this.#handlePageEvent.bind(this);
for (const eventType of PAGE_EVENTS) {
events.add(eventType, handlePageEvent);
}
if (IS_SAFARI) {
events.add("beforeunload", (event) => {
this.#safariBeforeUnloadTimeout = setTimeout(() => {
if (!(event.defaultPrevented || event.returnValue.length > 0)) {
this.#state.set("hidden");
this.#visibility.set("hidden");
}
}, 0);
});
}
}
/**
* The current page state. Important to note we only account for a subset of page states, as
* the rest aren't valuable to the player at the moment.
*
* - **active:** A page is in the active state if it is visible and has input focus.
* - **passive:** A page is in the passive state if it is visible and does not have input focus.
* - **hidden:** A page is in the hidden state if it is not visible.
*
* @see https://developers.google.com/web/updates/2018/07/page-lifecycle-api#states
*/
get pageState() {
return this.#state();
}
/**
* The current document visibility state.
*
* - **visible:** The page content may be at least partially visible. In practice, this means that
* the page is the foreground tab of a non-minimized window.
* - **hidden:** The page content is not visible to the user. In practice this means that the
* document is either a background tab or part of a minimized window, or the OS screen lock is
* active.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState
*/
get visibility() {
return this.#visibility();
}
#handlePageEvent(event) {
if (IS_SAFARI) window.clearTimeout(this.#safariBeforeUnloadTimeout);
if (event.type !== "blur" || this.#state() === "active") {
this.#state.set(determinePageState(event));
this.#visibility.set(document.visibilityState == "hidden" ? "hidden" : "visible");
}
}
}
function determinePageState(event) {
if (event?.type === "blur" || document.visibilityState === "hidden") return "hidden";
if (document.hasFocus()) return "active";
return "passive";
}
class HTMLMediaEvents {
#provider;
#ctx;
#waiting = false;
#attachedLoadStart = false;
#attachedCanPlay = false;
#timeRAF = new RAFLoop(this.#onAnimationFrame.bind(this));
#pageVisibility = new PageVisibility();
#events;
get #media() {
return this.#provider.media;
}
constructor(provider, ctx) {
this.#provider = provider;
this.#ctx = ctx;
this.#events = new EventsController(provider.media);
this.#attachInitialListeners();
this.#pageVisibility.connect();
effect(this.#attachTimeUpdate.bind(this));
onDispose(this.#onDispose.bind(this));
}
#onDispose() {
this.#attachedLoadStart = false;
this.#attachedCanPlay = false;
this.#timeRAF.stop();
this.#events.abort();
this.#devHandlers?.clear();
}
/**
* 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.
*/
#lastSeenTime = 0;
#seekedTo = -1;
#onAnimationFrame() {
const newTime = this.#media.currentTime;
const didStutter = IS_SAFARI && newTime - this.#seekedTo < 0.35;
if (!didStutter && this.#lastSeenTime !== newTime) {
this.#updateCurrentTime(newTime);
this.#lastSeenTime = newTime;
}
}
#attachInitialListeners() {
this.#attachEventListener("loadstart", this.#onLoadStart);
this.#attachEventListener("abort", this.#onAbort);
this.#attachEventListener("emptied", this.#onEmptied);
this.#attachEventListener("error", this.#onError);
this.#attachEventListener("volumechange", this.#onVolumeChange);
}
#attachLoadStartListeners() {
if (this.#attachedLoadStart) return;
this.#attachEventListener("loadeddata", this.#onLoadedData);
this.#attachEventListener("loadedmetadata", this.#onLoadedMetadata);
this.#attachEventListener("canplay", this.#onCanPlay);
this.#attachEventListener("canplaythrough", this.#onCanPlayThrough);
this.#attachEventListener("durationchange", this.#onDurationChange);
this.#attachEventListener("play", this.#onPlay);
this.#attachEventListener("progress", this.#onProgress);
this.#attachEventListener("stalled", this.#onStalled);
this.#attachEventListener("suspend", this.#onSuspend);
this.#attachEventListener("ratechange", this.#onRateChange);
this.#attachedLoadStart = true;
}
#attachCanPlayListeners() {
if (this.#attachedCanPlay) return;
this.#attachEventListener("pause", this.#onPause);
this.#attachEventListener("playing", this.#onPlaying);
this.#attachEventListener("seeked", this.#onSeeked);
this.#attachEventListener("seeking", this.#onSeeking);
this.#attachEventListener("ended", this.#onEnded);
this.#attachEventListener("waiting", this.#onWaiting);
this.#attachedCanPlay = true;
}
#devHandlers = void 0;
#handleDevEvent = void 0;
#attachEventListener(eventType, handler) {
this.#events.add(eventType, handler.bind(this));
}
#onDevEvent(event2) {
return;
}
#updateCurrentTime(time, trigger) {
const newTime = Math.min(time, this.#ctx.$state.seekableEnd());
this.#ctx.notify("time-change", newTime, trigger);
}
#onLoadStart(event2) {
if (this.#media.networkState === 3) {
this.#onAbort(event2);
return;
}
this.#attachLoadStartListeners();
this.#ctx.notify("load-start", void 0, event2);
}
#onAbort(event2) {
this.#ctx.notify("abort", void 0, event2);
}
#onEmptied() {
this.#ctx.notify("emptied", void 0, event);
}
#onLoadedData(event2) {
this.#ctx.notify("loaded-data", void 0, event2);
}
#onLoadedMetadata(event2) {
this.#lastSeenTime = 0;
this.#seekedTo = -1;
this.#attachCanPlayListeners();
this.#ctx.notify("loaded-metadata", void 0, event2);
if (IS_IOS || IS_SAFARI && isHLSSrc(this.#ctx.$state.source())) {
this.#ctx.delegate.ready(this.#getCanPlayDetail(), event2);
}
}
#getCanPlayDetail() {
return {
provider: peek(this.#ctx.$provider),
duration: this.#media.duration,
buffered: this.#media.buffered,
seekable: this.#media.seekable
};
}
#onPlay(event2) {
if (!this.#ctx.$state.canPlay) return;
this.#ctx.notify("play", void 0, event2);
}
#onPause(event2) {
if (this.#media.readyState === 1 && !this.#waiting) return;
this.#waiting = false;
this.#timeRAF.stop();
this.#ctx.notify("pause", void 0, event2);
}
#onCanPlay(event2) {
this.#ctx.delegate.ready(this.#getCanPlayDetail(), event2);
}
#onCanPlayThrough(event2) {
if (this.#ctx.$state.started()) return;
this.#ctx.notify("can-play-through", this.#getCanPlayDetail(), event2);
}
#onPlaying(event2) {
if (this.#media.paused) return;
this.#waiting = false;
this.#ctx.notify("playing", void 0, event2);
this.#timeRAF.start();
}
#onStalled(event2) {
this.#ctx.notify("stalled", void 0, event2);
if (this.#media.readyState < 3) {
this.#waiting = true;
this.#ctx.notify("waiting", void 0, event2);
}
}
#onWaiting(event2) {
if (this.#media.readyState < 3) {
this.#waiting = true;
this.#ctx.notify("waiting", void 0, event2);
}
}
#onEnded(event2) {
this.#timeRAF.stop();
this.#updateCurrentTime(this.#media.duration, event2);
this.#ctx.notify("end", void 0, event2);
if (this.#ctx.$state.loop()) {
const hasCustomControls = isNil(this.#media.controls);
if (hasCustomControls) this.#media.controls = false;
}
}
#attachTimeUpdate() {
const isPaused = this.#ctx.$state.paused(), isPageHidden = this.#pageVisibility.visibility === "hidden", shouldListenToTimeUpdates = isPaused || isPageHidden;
if (shouldListenToTimeUpdates) {
listenEvent(this.#media, "timeupdate", this.#onTimeUpdate.bind(this));
}
}
#onTimeUpdate(event2) {
this.#updateCurrentTime(this.#media.currentTime, event2);
}
#onDurationChange(event2) {
if (this.#ctx.$state.ended()) {
this.#updateCurrentTime(this.#media.duration, event2);
}
this.#ctx.notify("duration-change", this.#media.duration, event2);
}
#onVolumeChange(event2) {
const detail = {
volume: this.#media.volume,
muted: this.#media.muted
};
this.#ctx.notify("volume-change", detail, event2);
}
#onSeeked(event2) {
this.#seekedTo = this.#media.currentTime;
this.#updateCurrentTime(this.#media.currentTime, event2);
this.#ctx.notify("seeked", this.#media.currentTime, event2);
if (Math.trunc(this.#media.currentTime) === Math.trunc(this.#media.duration) && getNumberOfDecimalPlaces(this.#media.duration) > getNumberOfDecimalPlaces(this.#media.currentTime)) {
this.#updateCurrentTime(this.#media.duration, event2);
if (!this.#media.ended) {
this.#ctx.player.dispatch(
new DOMEvent("media-play-request", {
trigger: event2
})
);
}
}
}
#onSeeking(event2) {
this.#ctx.notify("seeking", this.#media.currentTime, event2);
}
#onProgress(event2) {
const detail = {
buffered: this.#media.buffered,
seekable: this.#media.seekable
};
this.#ctx.notify("progress", detail, event2);
}
#onSuspend(event2) {
this.#ctx.notify("suspend", void 0, event2);
}
#onRateChange(event2) {
this.#ctx.notify("rate-change", this.#media.playbackRate, event2);
}
#onError(event2) {
const error = this.#media.error;
if (!error) return;
const detail = {
message: error.message,
code: error.code,
mediaError: error
};
this.#ctx.notify("error", detail, event2);
}
}
class NativeAudioTracks {
#provider;
#ctx;
get #nativeTracks() {
return this.#provider.media.audioTracks;
}
constructor(provider, ctx) {
this.#provider = provider;
this.#ctx = ctx;
this.#nativeTracks.onaddtrack = this.#onAddNativeTrack.bind(this);
this.#nativeTracks.onremovetrack = this.#onRemoveNativeTrack.bind(this);
this.#nativeTracks.onchange = this.#onChangeNativeTrack.bind(this);
listenEvent(this.#ctx.audioTracks, "change", this.#onChangeTrack.bind(this));
}
#onAddNativeTrack(event) {
const nativeTrack = event.track;
if (nativeTrack.label === "") return;
const id = nativeTrack.id.toString() || `native-audio-${this.#ctx.audioTracks.length}`, audioTrack = {
id,
label: nativeTrack.label,
language: nativeTrack.language,
kind: nativeTrack.kind,
selected: false
};
this.#ctx.audioTracks[ListSymbol.add](audioTrack, event);
if (nativeTrack.enabled) audioTrack.selected = true;
}
#onRemoveNativeTrack(event) {
const track = this.#ctx.audioTracks.getById(event.track.id);
if (track) this.#ctx.audioTracks[ListSymbol.remove](track, event);
}
#onChangeNativeTrack(event) {
let enabledTrack = this.#getEnabledNativeTrack();
if (!enabledTrack) return;
const track = this.#ctx.audioTracks.getById(enabledTrack.id);
if (track) this.#ctx.audioTracks[ListSymbol.select](track, true, event);
}
#getEnabledNativeTrack() {
return Array.from(this.#nativeTracks).find((track) => track.enabled);
}
#onChangeTrack(event) {
const { current } = event.detail;
if (!current) return;
const track = this.#nativeTracks.getTrackById(current.id);
if (track) {
const prev = this.#getEnabledNativeTrack();
if (prev) prev.enabled = false;
track.enabled = true;
}
}
}
class HTMLMediaProvider {
constructor(media, ctx) {
this.media = media;
this.ctx = ctx;
this.audioGain = new AudioGain(media, (gain) => {
this.ctx.notify("audio-gain-change", gain);
});
}
scope = createScope();
currentSrc = null;
audioGain;
setup() {
new HTMLMediaEvents(this, this.ctx);
if ("audioTracks" in this.media) new NativeAudioTracks(this, this.ctx);
onDispose(() => {
this.audioGain.destroy();
this.media.srcObject = null;
this.media.removeAttribute("src");
for (const source of this.media.querySelectorAll("source")) source.remove();
this.media.load();
});
}
get type() {
return "";
}
setPlaybackRate(rate) {
this.media.playbackRate = rate;
}
async play() {
return this.media.play();
}
async pause() {
return this.media.pause();
}
setMuted(muted) {
this.media.muted = muted;
}
setVolume(volume) {
this.media.volume = volume;
}
setCurrentTime(time) {
this.media.currentTime = time;
}
setPlaysInline(inline) {
setAttribute(this.media, "playsinline", inline);
}
async loadSource({ src, type }, preload) {
this.media.preload = preload || "";
if (isMediaStream(src)) {
this.removeSource();
this.media.srcObject = src;
} else {
this.media.srcObject = null;
if (isString(src)) {
if (type !== "?") {
this.appendSource({ src, type });
} else {
this.removeSource();
this.media.src = this.#appendMediaFragment(src);
}
} else {
this.removeSource();
this.media.src = window.URL.createObjectURL(src);
}
}
this.media.load();
this.currentSrc = { src, type };
}
/**
* Append source so it works when requesting AirPlay since hls.js will remove it.
*/
appendSource(src, defaultType) {
const prevSource = this.media.querySelector("source[data-vds]"), source = prevSource ?? document.createElement("source");
setAttribute(source, "src", this.#appendMediaFragment(src.src));
setAttribute(source, "type", src.type !== "?" ? src.type : defaultType);
setAttribute(source, "data-vds", "");
if (!prevSource) this.media.append(source);
}
removeSource() {
this.media.querySelector("source[data-vds]")?.remove();
}
#appendMediaFragment(src) {
const { clipStartTime, clipEndTime } = this.ctx.$state, startTime = clipStartTime(), endTime = clipEndTime();
if (startTime > 0 && endTime > 0) {
return `${src}#t=${startTime},${endTime}`;
} else if (startTime > 0) {
return `${src}#t=${startTime}`;
} else if (endTime > 0) {
return `${src}#t=0,${endTime}`;
}
return src;
}
}
export { HTMLMediaProvider };