vidstack
Version:
Build awesome media experiences on the web.
675 lines (665 loc) • 20 kB
JavaScript
import { getScope, signal, effect, computed, provideContext, peek } from 'maverick.js';
import { ComponentController, Component, defineElement, prop, method } from 'maverick.js/element';
import { isObject, DOMEvent, isString, isUndefined, deferredPromise, listenEvent, setAttribute, uppercaseFirstChar, camelToKebabCase } from 'maverick.js/std';
import { m as mediaPlayerProps, M as MediaStoreFactory, j as MediaStoreSync, V as VideoQualityList, k as AudioTrackList, T as TextTrackList, l as TEXT_TRACK_CROSSORIGIN, n as TextRenderers, q as mediaContext, S as ScreenOrientationController, r as MediaKeyboardController, t as ThumbnailsLoader, v as MediaEventsLogger, w as MediaStateManager, x as MediaRequestManager, y as MediaPlayerDelegate, z as MediaLoadController, a as setAttributeIfEmpty, B as IS_IPHONE, i as isTrackCaptionKind, C as MEDIA_ATTRIBUTES, D as canFullscreen, E as MediaRemoteControl, F as MediaRequestContext } from './media-core.js';
import { w as round, x as FocusVisibleController, y as clampNumber } from './media-ui.js';
const GROUPED_LOG = Symbol("GROUPED_LOG" );
class GroupedLog {
constructor(logger, level, title, root, parent) {
this.logger = logger;
this.level = level;
this.title = title;
this.root = root;
this.parent = parent;
}
[GROUPED_LOG] = true;
logs = [];
log(...data) {
this.logs.push({ data });
return this;
}
labelledLog(label, ...data) {
this.logs.push({ label, data });
return this;
}
groupStart(title) {
return new GroupedLog(this.logger, this.level, title, this.root ?? this, this);
}
groupEnd() {
this.parent?.logs.push(this);
return this.parent ?? this;
}
dispatch() {
return this.logger.dispatch(this.level, this.root ?? this);
}
}
function isGroupedLog(data) {
return isObject(data) && data[GROUPED_LOG];
}
class Logger {
_target = null;
error(...data) {
return this.dispatch("error", ...data);
}
warn(...data) {
return this.dispatch("warn", ...data);
}
info(...data) {
return this.dispatch("info", ...data);
}
debug(...data) {
return this.dispatch("debug", ...data);
}
errorGroup(title) {
return new GroupedLog(this, "error", title);
}
warnGroup(title) {
return new GroupedLog(this, "warn", title);
}
infoGroup(title) {
return new GroupedLog(this, "info", title);
}
debugGroup(title) {
return new GroupedLog(this, "debug", title);
}
setTarget(newTarget) {
this._target = newTarget;
}
dispatch(level, ...data) {
return this._target?.dispatchEvent(
new DOMEvent("vds-log", {
bubbles: true,
composed: true,
detail: { level, data }
})
) || false;
}
}
const LOCAL_STORAGE_KEY = "@vidstack/log-colors";
const savedColors = init();
function getLogColor(key) {
return savedColors.get(key);
}
function saveLogColor(key, { color = generateColor(), overwrite = false } = {}) {
if (!savedColors.has(key) || overwrite) {
savedColors.set(key, color);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(Object.entries(savedColors)));
}
}
function generateColor() {
return `hsl(${Math.random() * 360}, 55%, 70%)`;
}
function init() {
let colors;
try {
colors = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY));
} catch {
}
return new Map(Object.entries(colors ?? {}));
}
const LogLevelValue = Object.freeze({
silent: 0,
error: 1,
warn: 2,
info: 3,
debug: 4
});
const LogLevelColor = Object.freeze({
silent: "white",
error: "hsl(6, 58%, 50%)",
warn: "hsl(51, 58%, 50%)",
info: "hsl(219, 58%, 50%)",
debug: "hsl(280, 58%, 50%)"
});
const s = 1e3;
const m = s * 60;
const h = m * 60;
const d = h * 24;
function ms(val) {
const msAbs = Math.abs(val);
if (msAbs >= d) {
return Math.round(val / d) + "d";
}
if (msAbs >= h) {
return Math.round(val / h) + "h";
}
if (msAbs >= m) {
return Math.round(val / m) + "m";
}
if (msAbs >= s) {
return Math.round(val / s) + "s";
}
return round(val, 2) + "ms";
}
class LogPrinter extends ComponentController {
_level = "warn" ;
_lastLogged;
/**
* The current log level.
*/
get logLevel() {
return this._level ;
}
set logLevel(level) {
this._level = level;
}
onConnect() {
this.listen("vds-log", (event) => {
event.stopPropagation();
const eventTargetName = (event.path?.[0] ?? event.target).tagName.toLowerCase();
const { level = "warn", data } = event.detail ?? {};
if (LogLevelValue[this._level] < LogLevelValue[level]) {
return;
}
saveLogColor(eventTargetName);
const hint = data?.length === 1 && isGroupedLog(data[0]) ? data[0].title : isString(data?.[0]) ? data[0] : "";
console.groupCollapsed(
`%c${level.toUpperCase()}%c ${eventTargetName}%c ${hint.slice(0, 50)}${hint.length > 50 ? "..." : ""}`,
`background: ${LogLevelColor[level]}; color: white; padding: 1.5px 2.2px; border-radius: 2px; font-size: 11px;`,
`color: ${getLogColor(eventTargetName)}; padding: 4px 0px; font-size: 11px;`,
"color: gray; font-size: 11px; padding-left: 4px;"
);
if (data?.length === 1 && isGroupedLog(data[0])) {
printGroup(level, data[0]);
} else if (data) {
print(level, ...data);
}
this._printTimeDiff();
printStackTrace();
console.groupEnd();
});
return () => {
this._lastLogged = void 0;
};
}
_printTimeDiff() {
labelledPrint("Time since last log", this._calcLastLogTimeDiff());
}
_calcLastLogTimeDiff() {
const time = performance.now();
const diff = time - (this._lastLogged ?? (this._lastLogged = performance.now()));
this._lastLogged = time;
return ms(diff);
}
}
function print(level, ...data) {
console[level](...data);
}
function labelledPrint(label, ...data) {
console.log(`%c${label}:`, "color: gray", ...data);
}
function printStackTrace() {
console.groupCollapsed("%cStack Trace", "color: gray");
console.trace();
console.groupEnd();
}
function printGroup(level, groupedLog) {
console.groupCollapsed(groupedLog.title);
for (const log of groupedLog.logs) {
if (isGroupedLog(log)) {
printGroup(level, log);
} else if ("label" in log && !isUndefined(log.label)) {
labelledPrint(log.label, ...log.data);
} else {
print(level, ...log.data);
}
}
console.groupEnd();
}
class RequestQueue {
_serving = false;
_pending = deferredPromise();
_queue = /* @__PURE__ */ new Map();
/**
* The number of callbacks that are currently in queue.
*/
get _size() {
return this._queue.size;
}
/**
* Whether items in the queue are being served immediately, otherwise they're queued to
* be processed later.
*/
get _isServing() {
return this._serving;
}
/**
* Waits for the queue to be flushed (ie: start serving).
*/
async _waitForFlush() {
if (this._serving)
return;
await this._pending.promise;
}
/**
* Queue the given `callback` to be invoked at a later time by either calling the `serve()` or
* `start()` methods. If the queue has started serving (i.e., `start()` was already called),
* then the callback will be invoked immediately.
*
* @param key - Uniquely identifies this callback so duplicates are ignored.
* @param callback - The function to call when this item in the queue is being served.
*/
_enqueue(key, callback) {
if (this._serving) {
callback();
return;
}
this._queue.delete(key);
this._queue.set(key, callback);
}
/**
* Invokes the callback with the given `key` in the queue (if it exists).
*/
_serve(key) {
this._queue.get(key)?.();
this._queue.delete(key);
}
/**
* Flush all queued items and start serving future requests immediately until `stop()` is called.
*/
_start() {
this._flush();
this._serving = true;
if (this._queue.size > 0)
this._flush();
}
/**
* Stop serving requests, they'll be queued until you begin processing again by calling `start()`.
*/
_stop() {
this._serving = false;
}
/**
* Stop serving requests, empty the request queue, and release any promises waiting for the
* queue to flush.
*/
_reset() {
this._stop();
this._queue.clear();
this._release();
}
_flush() {
for (const key of this._queue.keys())
this._serve(key);
this._release();
}
_release() {
this._pending.resolve();
this._pending = deferredPromise();
}
}
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i = decorators.length - 1, decorator; i >= 0; i--)
if (decorator = decorators[i])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result)
__defProp(target, key, result);
return result;
};
class Player extends Component {
static el = defineElement({
tagName: "media-player",
props: mediaPlayerProps,
store: MediaStoreFactory
});
_media;
_stateMgr;
_requestMgr;
_canPlayQueue = new RequestQueue();
get _provider() {
return this._media.$provider();
}
constructor(instance) {
super(instance);
this._initStore();
new MediaStoreSync(instance);
const context = {
player: null,
scope: getScope(),
qualities: new VideoQualityList(),
audioTracks: new AudioTrackList(),
$provider: signal(null),
$props: this.$props,
$store: this.$store
};
{
const logPrinter = new LogPrinter(instance);
effect(() => {
logPrinter.logLevel = this.$props.logLevel();
});
}
context.logger = new Logger();
context.remote = new MediaRemoteControl(context.logger );
context.$iosControls = computed(this._isIOSControls.bind(this));
context.textTracks = new TextTrackList();
context.textTracks[TEXT_TRACK_CROSSORIGIN] = this.$props.crossorigin;
context.textRenderers = new TextRenderers(context);
context.ariaKeys = {};
this._media = context;
provideContext(mediaContext, context);
this.orientation = new ScreenOrientationController(instance);
new FocusVisibleController(instance);
new MediaKeyboardController(instance, context);
new ThumbnailsLoader(instance);
new MediaEventsLogger(instance, context);
const request = new MediaRequestContext();
this._stateMgr = new MediaStateManager(instance, request, context);
this._requestMgr = new MediaRequestManager(instance, this._stateMgr, request, context);
context.delegate = new MediaPlayerDelegate(
this._stateMgr._handle.bind(this._stateMgr),
context
);
new MediaLoadController(instance, this.startLoading.bind(this));
}
onAttach(el) {
el.setAttribute("tabindex", "0");
setAttributeIfEmpty(el, "role", "region");
effect(this._watchTitle.bind(this));
effect(this._watchOrientation.bind(this));
effect(this._watchCanPlay.bind(this));
effect(this._watchMuted.bind(this));
effect(this._watchPaused.bind(this));
effect(this._watchVolume.bind(this));
effect(this._watchCurrentTime.bind(this));
effect(this._watchPlaysinline.bind(this));
effect(this._watchPlaybackRate.bind(this));
this._setMediaAttributes();
this._setMediaVars();
this._media.player = el;
this._media.remote.setTarget(el);
this._media.remote.setPlayer(el);
listenEvent(el, "find-media-player", this._onFindPlayer.bind(this));
}
onConnect(el) {
if (IS_IPHONE)
setAttribute(el, "data-iphone", "");
const pointerQuery = window.matchMedia("(pointer: coarse)");
this._onTouchChange(pointerQuery);
pointerQuery.onchange = this._onTouchChange.bind(this);
const resize = new ResizeObserver(this._onResize.bind(this));
resize.observe(el);
effect(this._onResize.bind(this));
this.dispatch("media-player-connect", {
detail: this.el,
bubbles: true,
composed: true
});
{
this._media.logger.setTarget(el);
return () => this._media.logger.setTarget(null);
}
}
_initStore() {
const providedProps = {
viewType: "providedViewType",
streamType: "providedStreamType"
};
for (const prop2 of Object.keys(this.$props)) {
this.$store[providedProps[prop2] ?? prop2]?.set(this.$props[prop2]());
}
effect(this._onProvidedTypesChange.bind(this));
this.$store.muted.set(this.$props.muted() || this.$props.volume() === 0);
}
_watchTitle() {
const { title } = this.$props, { live, viewType } = this.$store, isLive = live(), type = uppercaseFirstChar(viewType()), typeText = type !== "Unknown" ? `${isLive ? "Live " : ""}${type}` : isLive ? "Live" : "Media";
const newTitle = title();
if (newTitle) {
this.el?.setAttribute("data-title", newTitle);
this.el?.removeAttribute("title");
}
const currentTitle = this.el?.getAttribute("data-title") || "";
this.$store.title.set(currentTitle);
setAttribute(
this.el,
"aria-label",
currentTitle ? `${typeText} - ${currentTitle}` : typeText + " Player"
);
}
_watchOrientation() {
const orientation = this.orientation.landscape ? "landscape" : "portrait";
this.$store.orientation.set(orientation);
setAttribute(this.el, "data-orientation", orientation);
this._onResize();
}
_watchCanPlay() {
if (this.$store.canPlay() && this._provider)
this._canPlayQueue._start();
else
this._canPlayQueue._stop();
}
_onProvidedTypesChange() {
this.$store.providedViewType.set(this.$props.viewType());
this.$store.providedStreamType.set(this.$props.streamType());
}
_setMediaAttributes() {
const $attrs = {
"aspect-ratio": this.$props.aspectRatio,
"data-captions": () => {
const track = this.$store.textTrack();
return !!track && isTrackCaptionKind(track);
},
"data-ios-controls": this._media.$iosControls
};
const mediaAttrName = {
canPictureInPicture: "can-pip",
pictureInPicture: "pip"
};
for (const prop2 of MEDIA_ATTRIBUTES) {
const attrName = "data-" + (mediaAttrName[prop2] ?? camelToKebabCase(prop2));
$attrs[attrName] = this.$store[prop2];
}
delete $attrs.title;
this.setAttributes($attrs);
}
_setMediaVars() {
this.setCSSVars({
"--media-aspect-ratio": () => {
const ratio = this.$props.aspectRatio();
return ratio ? +ratio.toFixed(4) : null;
}
});
}
_onFindPlayer(event) {
event.detail(this.el);
}
_onResize() {
if (!this.el)
return;
const width = this.el.clientWidth, height = this.el.clientHeight, { smallBreakpointX, smallBreakpointY, largeBreakpointX, largeBreakpointY } = this.$props, bpx = width < smallBreakpointX() ? "sm" : width < largeBreakpointX() ? "md" : "lg", bpy = height < smallBreakpointY() ? "sm" : height < largeBreakpointY() ? "md" : "lg";
this.$store.breakpointX.set(bpx);
this.$store.breakpointY.set(bpy);
setAttribute(this.el, "data-bp-x", bpx);
setAttribute(this.el, "data-bp-y", bpy);
}
_onTouchChange(queryList) {
const isTouch = queryList.matches;
setAttribute(this.el, "data-touch", isTouch);
this.$store.touchPointer.set(isTouch);
this._onResize();
}
_isIOSControls() {
return !canFullscreen() && this.$store.mediaType() === "video" && (this.$store.controls() && !this.$props.playsinline() || this.$store.fullscreen());
}
get provider() {
return this._provider;
}
get user() {
return this._requestMgr._user;
}
orientation;
get qualities() {
return this._media.qualities;
}
get audioTracks() {
return this._media.audioTracks;
}
get textTracks() {
return this._media.textTracks;
}
get textRenderers() {
return this._media.textRenderers;
}
get paused() {
return this._provider?.paused ?? true;
}
set paused(paused) {
if (paused) {
this._canPlayQueue._enqueue("paused", () => this._requestMgr._pause());
} else
this._canPlayQueue._enqueue("paused", () => this._requestMgr._play());
}
_watchPaused() {
this.paused = this.$props.paused();
}
get muted() {
return this._provider?.muted ?? false;
}
set muted(muted) {
this._canPlayQueue._enqueue("muted", () => this._provider.muted = muted);
}
_watchMuted() {
this.muted = this.$props.muted();
}
get currentTime() {
return this._provider?.currentTime ?? 0;
}
set currentTime(time) {
this._canPlayQueue._enqueue("currentTime", () => {
const adapter = this._provider;
if (time !== adapter.currentTime) {
peek(() => {
const boundTime = Math.min(
Math.max(this.$store.seekableStart() + 0.1, time),
this.$store.seekableEnd() - 0.1
);
if (Number.isFinite(boundTime))
adapter.currentTime = boundTime;
});
}
});
}
_watchCurrentTime() {
this.currentTime = this.$props.currentTime();
}
get volume() {
return this._provider?.volume ?? 1;
}
set volume(volume) {
this._canPlayQueue._enqueue("volume", () => this._provider.volume = volume);
}
_watchVolume() {
this.volume = clampNumber(0, this.$props.volume(), 1);
}
get playsinline() {
return this._provider?.playsinline ?? false;
}
set playsinline(inline) {
this._canPlayQueue._enqueue("playsinline", () => this._provider.playsinline = inline);
}
_watchPlaysinline() {
this.playsinline = this.$props.playsinline();
}
get playbackRate() {
return this._provider?.playbackRate ?? 1;
}
set playbackRate(rate) {
this._canPlayQueue._enqueue("rate", () => this._provider.playbackRate = rate);
}
_watchPlaybackRate() {
this.playbackRate = this.$props.playbackRate();
}
async play() {
return this._requestMgr._play();
}
async pause() {
return this._requestMgr._pause();
}
async enterFullscreen(target) {
return this._requestMgr._enterFullscreen(target);
}
async exitFullscreen(target) {
return this._requestMgr._exitFullscreen(target);
}
enterPictureInPicture() {
return this._requestMgr._enterPictureInPicture();
}
exitPictureInPicture() {
return this._requestMgr._exitPictureInPicture();
}
seekToLiveEdge() {
this._requestMgr._seekToLiveEdge();
}
startLoading() {
this._media.delegate._dispatch("can-load");
}
destroy() {
this.dispatch("destroy");
}
}
__decorateClass([
prop
], Player.prototype, "provider", 1);
__decorateClass([
prop
], Player.prototype, "user", 1);
__decorateClass([
prop
], Player.prototype, "orientation", 2);
__decorateClass([
prop
], Player.prototype, "qualities", 1);
__decorateClass([
prop
], Player.prototype, "audioTracks", 1);
__decorateClass([
prop
], Player.prototype, "textTracks", 1);
__decorateClass([
prop
], Player.prototype, "textRenderers", 1);
__decorateClass([
prop
], Player.prototype, "paused", 1);
__decorateClass([
prop
], Player.prototype, "muted", 1);
__decorateClass([
prop
], Player.prototype, "currentTime", 1);
__decorateClass([
prop
], Player.prototype, "volume", 1);
__decorateClass([
prop
], Player.prototype, "playsinline", 1);
__decorateClass([
prop
], Player.prototype, "playbackRate", 1);
__decorateClass([
method
], Player.prototype, "play", 1);
__decorateClass([
method
], Player.prototype, "pause", 1);
__decorateClass([
method
], Player.prototype, "enterFullscreen", 1);
__decorateClass([
method
], Player.prototype, "exitFullscreen", 1);
__decorateClass([
method
], Player.prototype, "enterPictureInPicture", 1);
__decorateClass([
method
], Player.prototype, "exitPictureInPicture", 1);
__decorateClass([
method
], Player.prototype, "seekToLiveEdge", 1);
__decorateClass([
method
], Player.prototype, "startLoading", 1);
export { Player as P };