@ktt45678/vidstack
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
578 lines (570 loc) • 18.6 kB
JavaScript
import { IS_SAFARI, IS_IOS, isHLSSrc, isMediaStream } from '../chunks/vidstack-BpOkecTJ.js';
import { signal, listenEvent, useDisposalBin, effect, onDispose, peek, isNil, DOMEvent, createScope, setAttribute, isString } from '../chunks/vidstack-fG_Sx3Q9.js';
import { RAFLoop, ListSymbol } from '../chunks/vidstack-BXMqlVv4.js';
import { getNumberOfDecimalPlaces } from '../chunks/vidstack-Dihypf8P.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 {
constructor(_media, _onChange) {
this._media = _media;
this._onChange = _onChange;
this._gainNode = null;
this._srcAudioNode = null;
}
get currentGain() {
return this._gainNode?.gain?.value ?? null;
}
get supported() {
return true;
}
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 {
constructor() {
this._state = signal(determinePageState());
this._visibility = signal(
document.visibilityState
);
}
connect() {
for (const eventType of PAGE_EVENTS) {
listenEvent(window, eventType, this._handlePageEvent.bind(this));
}
if (IS_SAFARI) {
listenEvent(window, "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 {
constructor(_provider, _ctx) {
this._provider = _provider;
this._ctx = _ctx;
this._disposal = useDisposalBin();
this._waiting = false;
this._attachedLoadStart = false;
this._attachedCanPlay = false;
this._timeRAF = new RAFLoop(this._onAnimationFrame.bind(this));
this._pageVisibility = new PageVisibility();
/**
* 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.
*/
this._lastSeenTime = 0;
this._seekedTo = -1;
this._handlers = /* @__PURE__ */ new Map() ;
this._handleDevEvent = this._onDevEvent.bind(this) ;
this._attachInitialListeners();
this._pageVisibility.connect();
effect(this._attachTimeUpdate.bind(this));
onDispose(this._onDispose.bind(this));
}
get _media() {
return this._provider.media;
}
get _notify() {
return this._ctx.delegate._notify;
}
_onDispose() {
this._attachedLoadStart = false;
this._attachedCanPlay = false;
this._timeRAF._stop();
this._disposal.empty();
}
_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._ctx.logger?.info("attaching initial listeners");
}
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);
this._ctx.logger?.debug("attached initial media event listeners");
}
_attachLoadStartListeners() {
if (this._attachedLoadStart) return;
{
this._ctx.logger?.info("attaching load start listeners");
}
this._disposal.add(
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._ctx.logger?.info("attaching can play listeners");
}
this._disposal.add(
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;
}
_attachEventListener(eventType, handler) {
this._handlers.set(eventType, handler);
return listenEvent(
this._media,
eventType,
this._handleDevEvent
);
}
_onDevEvent(event2) {
this._ctx.logger?.debugGroup(`\u{1F4FA} provider fired \`${event2.type}\``).labelledLog("Provider", this._provider).labelledLog("Event", event2).labelledLog("Media Store", { ...this._ctx.$state }).dispatch();
this._handlers.get(event2.type)?.call(this, event2);
}
_updateCurrentTime(time, trigger) {
const newTime = Math.min(time, this._ctx.$state.seekableEnd());
this._notify("time-change", newTime, trigger);
}
_onLoadStart(event2) {
if (this._media.networkState === 3) {
this._onAbort(event2);
return;
}
this._attachLoadStartListeners();
this._notify("load-start", void 0, event2);
}
_onAbort(event2) {
this._notify("abort", void 0, event2);
}
_onEmptied() {
this._notify("emptied", void 0, event);
}
_onLoadedData(event2) {
this._notify("loaded-data", void 0, event2);
}
_onLoadedMetadata(event2) {
this._lastSeenTime = 0;
this._seekedTo = -1;
this._attachCanPlayListeners();
this._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._notify("play", void 0, event2);
}
_onPause(event2) {
if (this._media.readyState === 1 && !this._waiting) return;
this._waiting = false;
this._timeRAF._stop();
this._notify("pause", void 0, event2);
}
_onCanPlay(event2) {
this._ctx.delegate._ready(this._getCanPlayDetail(), event2);
}
_onCanPlayThrough(event2) {
if (this._ctx.$state.started()) return;
this._notify("can-play-through", this._getCanPlayDetail(), event2);
}
_onPlaying(event2) {
if (this._media.paused) return;
this._waiting = false;
this._notify("playing", void 0, event2);
this._timeRAF._start();
}
_onStalled(event2) {
this._notify("stalled", void 0, event2);
if (this._media.readyState < 3) {
this._waiting = true;
this._notify("waiting", void 0, event2);
}
}
_onWaiting(event2) {
if (this._media.readyState < 3) {
this._waiting = true;
this._notify("waiting", void 0, event2);
}
}
_onEnded(event2) {
this._timeRAF._stop();
this._updateCurrentTime(this._media.duration, event2);
this._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._notify("duration-change", this._media.duration, event2);
}
_onVolumeChange(event2) {
const detail = {
volume: this._media.volume,
muted: this._media.muted
};
this._notify("volume-change", detail, event2);
}
_onSeeked(event2) {
this._seekedTo = this._media.currentTime;
this._updateCurrentTime(this._media.currentTime, event2);
this._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._notify("seeking", this._media.currentTime, event2);
}
_onProgress(event2) {
const detail = {
buffered: this._media.buffered,
seekable: this._media.seekable
};
this._notify("progress", detail, event2);
}
_onSuspend(event2) {
this._notify("suspend", void 0, event2);
}
_onRateChange(event2) {
this._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._notify("error", detail, event2);
}
}
class NativeAudioTracks {
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));
}
get _nativeTracks() {
return this._provider.media.audioTracks;
}
_onAddNativeTrack(event) {
const _track = event.track;
if (_track.label === "") return;
const id = _track.id.toString() || `native-audio-${this._ctx.audioTracks.length}`, audioTrack = {
id,
label: _track.label,
language: _track.language,
kind: _track.kind,
selected: false
};
this._ctx.audioTracks[ListSymbol._add](audioTrack, event);
if (_track.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.scope = createScope();
this._currentSrc = null;
this.audioGain = new AudioGain(this._media, (gain) => {
this._ctx.delegate._notify("audio-gain-change", gain);
});
}
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 "";
}
get media() {
return this._media;
}
get currentSrc() {
return this._currentSrc;
}
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) {
if (typeof src.src !== "string") return;
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 };