UNPKG

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