UNPKG

vidstack

Version:

Build awesome media experiences on the web.

675 lines (665 loc) 20 kB
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 };