UNPKG

minigame-std

Version:

Cross-platform standard library for WeChat minigame and web browsers with unified APIs for crypto, fs, fetch, storage, and more.

858 lines (857 loc) 22.7 kB
import { Err, Lazy, Ok, tryAsyncResult } from "happy-rusty"; import { ROOT_DIR, readBlobFile } from "happy-opfs"; import { normalize } from "./path.mjs"; //#region src/macros/env.ts /** * 如果在小游戏环境中返回 true,否则返回 false。 */ const IS_MINA = __MINIGAME_STD_MINA__; //#endregion //#region src/std/video/web_video.ts /** * @internal * Web 平台的视频播放实现。 */ /** * Web平台创建视频播放器。 * @param options - 视频配置选项。 * @returns Video对象,模拟WechatMinigame.Video接口。 */ function createVideo$1(options) { const { autoplay = false, backgroundColor, controls = false, height, initialTime, loop = false, muted = false, objectFit, playbackRate = 1, poster, src, width, x, y } = options; const video = document.createElement("video"); video.src = src; video.autoplay = autoplay; video.controls = controls; video.loop = loop; video.muted = muted; video.playbackRate = playbackRate; video.setAttribute("playsinline", "true"); video.setAttribute("webkit-playsinline", "true"); if (poster) video.poster = poster; if (height !== void 0) video.height = height; if (width !== void 0) video.width = width; if (backgroundColor) video.style.backgroundColor = backgroundColor; if (objectFit) video.style.objectFit = objectFit; if (x !== void 0 || y !== void 0) { video.style.position = "absolute"; if (x !== void 0) video.style.left = `${x}px`; if (y !== void 0) video.style.top = `${y}px`; } document.body.appendChild(video); if (initialTime !== void 0) video.addEventListener("loadedmetadata", () => { video.currentTime = initialTime; }, { once: true }); const listeners = { ended: /* @__PURE__ */ new Set(), error: /* @__PURE__ */ new Set(), pause: /* @__PURE__ */ new Set(), play: /* @__PURE__ */ new Set(), progress: /* @__PURE__ */ new Set(), timeupdate: /* @__PURE__ */ new Set(), waiting: /* @__PURE__ */ new Set() }; video.addEventListener("ended", () => { listeners.ended.forEach((cb) => { cb(); }); }); video.addEventListener("error", () => { const errMsg = video.error?.message ?? "MEDIA_ERR_UNKNOWN"; listeners.error.forEach((cb) => cb({ errMsg })); }); video.addEventListener("pause", () => { listeners.pause.forEach((cb) => { cb(); }); }); video.addEventListener("play", () => { listeners.play.forEach((cb) => { cb(); }); }); video.addEventListener("progress", () => { const buffered = video.buffered; const { duration } = video; const bufferedPercent = buffered.length > 0 && duration > 0 ? buffered.end(buffered.length - 1) / duration * 100 : 0; listeners.progress.forEach((cb) => cb({ buffered: bufferedPercent })); }); video.addEventListener("timeupdate", () => { listeners.timeupdate.forEach((cb) => cb({ position: video.currentTime, duration: video.duration })); }); video.addEventListener("waiting", () => { listeners.waiting.forEach((cb) => { cb(); }); }); return { get autoplay() { return video.autoplay; }, set autoplay(value) { video.autoplay = value; }, get backgroundColor() { return video.style.backgroundColor; }, set backgroundColor(value) { video.style.backgroundColor = value; }, get controls() { return video.controls; }, set controls(value) { video.controls = value; }, get enablePlayGesture() { return false; }, set enablePlayGesture(_value) {}, get enableProgressGesture() { return false; }, set enableProgressGesture(_value) {}, get height() { return video.height; }, set height(value) { video.height = value; }, get initialTime() { return initialTime ?? 0; }, set initialTime(value) { video.currentTime = value; }, get live() { return false; }, set live(_value) {}, get loop() { return video.loop; }, set loop(value) { video.loop = value; }, get muted() { return video.muted; }, set muted(value) { video.muted = value; }, get obeyMuteSwitch() { return false; }, set obeyMuteSwitch(_value) {}, get objectFit() { return video.style.objectFit; }, set objectFit(value) { video.style.objectFit = value; }, get playbackRate() { return video.playbackRate; }, set playbackRate(value) { video.playbackRate = value; }, get poster() { return video.poster; }, set poster(value) { video.poster = value; }, get showCenterPlayBtn() { return false; }, set showCenterPlayBtn(_value) {}, get showProgress() { return video.controls; }, set showProgress(_value) {}, get showProgressInControlMode() { return video.controls; }, set showProgressInControlMode(_value) {}, get src() { return video.src; }, set src(value) { video.src = value; }, get width() { return video.width; }, set width(value) { video.width = value; }, get x() { return parseFloat(video.style.left) || 0; }, set x(value) { video.style.position = "absolute"; video.style.left = `${value}px`; }, get y() { return parseFloat(video.style.top) || 0; }, set y(value) { video.style.position = "absolute"; video.style.top = `${value}px`; }, get onended() { return video.onended; }, set onended(value) { video.onended = value; }, get onerror() { return video.onerror; }, set onerror(value) { video.onerror = value; }, get onpause() { return video.onpause; }, set onpause(value) { video.onpause = value; }, get onplay() { return video.onplay; }, set onplay(value) { video.onplay = value; }, get onprogress() { return video.onprogress; }, set onprogress(value) { video.onprogress = value; }, get ontimeupdate() { return video.ontimeupdate; }, set ontimeupdate(value) { video.ontimeupdate = value; }, get onwaiting() { return video.onwaiting; }, set onwaiting(value) { video.onwaiting = value; }, play() { return video.play(); }, pause() { video.pause(); return Promise.resolve(); }, stop() { video.pause(); video.currentTime = 0; return Promise.resolve(); }, seek(time) { video.currentTime = time; return Promise.resolve(); }, async requestFullScreen(direction) { await video.requestFullscreen(); const orientationApi = screen.orientation; if (orientationApi?.lock) { let orientation; switch (direction) { case 0: orientation = "portrait"; break; case 90: orientation = "landscape-secondary"; break; case -90: orientation = "landscape-primary"; break; default: return; } try { await orientationApi.lock(orientation); } catch {} } }, exitFullScreen() { document.exitFullscreen(); return Promise.resolve(); }, destroy() { video.pause(); video.src = ""; video.load(); video.remove(); Object.values(listeners).forEach((set) => set.clear()); }, onEnded(listener) { listeners.ended.add(listener); }, offEnded(listener) { if (listener) listeners.ended.delete(listener); else listeners.ended.clear(); }, onError(listener) { listeners.error.add(listener); }, offError(listener) { if (listener) listeners.error.delete(listener); else listeners.error.clear(); }, onPause(listener) { listeners.pause.add(listener); }, offPause(listener) { if (listener) listeners.pause.delete(listener); else listeners.pause.clear(); }, onPlay(listener) { listeners.play.add(listener); }, offPlay(listener) { if (listener) listeners.play.delete(listener); else listeners.play.clear(); }, onProgress(listener) { listeners.progress.add(listener); }, offProgress(listener) { if (listener) listeners.progress.delete(listener); else listeners.progress.clear(); }, onTimeUpdate(listener) { listeners.timeupdate.add(listener); }, offTimeUpdate(listener) { if (listener) listeners.timeupdate.delete(listener); else listeners.timeupdate.clear(); }, onWaiting(listener) { listeners.waiting.add(listener); }, offWaiting(listener) { if (listener) listeners.waiting.delete(listener); else listeners.waiting.clear(); } }; } //#endregion //#region src/std/fs/mina_fs_shared.ts /** * @internal * 同步/异步的公共代码。 */ /** * 用户数据根目录,`wxfile://usr` 或 `http://usr`。 * */ const usrPath = /*#__PURE__*/ Lazy(() => wx.env.USER_DATA_PATH); /** * 根路径,`wxfile://` 或 `http://`。 * */ const rootPath = /*#__PURE__*/ Lazy(() => { return `${usrPath.force().split("://")[0]}://`; }); /** * 验证并标准化路径,返回绝对路径。 * * 支持两种输入格式: * 1. 完整路径:以 `wxfile://` 或 `http://` 开头(如 `wxfile://usr/test`) * 2. 相对路径:以 `/` 开头(如 `/test`),会自动拼接 `wx.env.USER_DATA_PATH` * * @param path - 待验证的路径。 * @returns 验证成功返回标准化后的绝对路径,失败返回错误信息。 */ function validateAbsolutePath(path) { const typeError = validatePathType(path); if (typeError) return typeError; let isFullPath = false; if (path.startsWith(rootPath.force())) { isFullPath = true; path = path.slice(rootPath.force().length); if (!path) return Err(/* @__PURE__ */ new Error("Path must not be root directory")); } const normalized = normalize(path); path = normalized.length > 1 && normalized[normalized.length - 1] === ROOT_DIR ? normalized.slice(0, -1) : normalized; if (isFullPath) { if (path === ROOT_DIR) return Err(/* @__PURE__ */ new Error("Path must not be root directory")); if (path[0] === ROOT_DIR) path = path.slice(1); return Ok(rootPath.force() + path); } if (path[0] !== ROOT_DIR) return Err(/* @__PURE__ */ new Error(`Path must be absolute (start with '/'): '${path}'`)); return Ok(usrPath.force() + path); } /** * 验证可读路径(用于只读操作:readFile、stat、readDir)。 * * 支持三种输入格式: * 1. 完整路径:以 `wxfile://` 或 `http://` 开头(如 `wxfile://usr/test`) * 2. 用户数据相对路径:以 `/` 开头(如 `/test`),会自动拼接 `wx.env.USER_DATA_PATH` * 3. 代码包路径:不以 `./`、`../` 开头的相对路径(如 `images/logo.png`),直接返回原路径 * * @param path - 待验证的路径。 * @returns 验证成功返回标准化后的路径,失败返回错误信息。 */ function validateReadablePath(path) { const typeError = validatePathType(path); if (typeError) return typeError; if (path.startsWith(rootPath.force()) || path.startsWith(ROOT_DIR)) return validateAbsolutePath(path); if (path.startsWith("./") || path.startsWith("../")) return Err(/* @__PURE__ */ new Error(`Invalid path: '${path}'. Code package paths must not start with './' or '../'`)); return Ok(normalize(path)); } /** * 验证 path 是否为字符串类型。 * @param path - 待验证的路径。 * @returns 如果不是字符串返回错误,否则返回 undefined。 */ function validatePathType(path) { if (typeof path !== "string") return Err(/* @__PURE__ */ new TypeError(`Path must be a string but received ${typeof path}`)); } //#endregion //#region src/std/video/frame-source/mina.ts /** * @internal * 小游戏平台的视频帧源实现。 */ /** * 判断小游戏平台是否支持视频帧源。 * * @returns 是否支持视频帧源。 */ function isVideoFrameSourceSupported$2() { return typeof wx.createVideoDecoder === "function"; } /** * 创建小游戏平台视频帧源。 * * @param options - 视频帧源创建选项。 * @returns 视频帧源创建结果。 */ function createVideoFrameSource$2(options) { if (!isVideoFrameSourceSupported$2()) return Err(/* @__PURE__ */ new Error("VideoFrameSource is not supported on this minigame platform")); const decoder = wx.createVideoDecoder(); let state = "idle"; let lastFrame = null; let currentTime = 0; let width = options.width ?? 0; let height = options.height ?? 0; const frameListeners = /* @__PURE__ */ new Set(); const endedListeners = /* @__PURE__ */ new Set(); const errorListeners = /* @__PURE__ */ new Set(); const pullFrame = () => { const frameData = decoder.getFrameData(); if (frameData == null) return null; const frame = toPixelFrame(frameData); currentTime = frame.timestamp; width = frame.width; height = frame.height; return frame; }; const handleEnded = () => { if (state !== "destroyed") state = "ended"; endedListeners.forEach((listener) => listener()); }; decoder.on("ended", handleEnded); return Ok({ get state() { return state; }, get currentTime() { return currentTime; }, get duration() { return NaN; }, get width() { return width; }, get height() { return height; }, async play() { return (await tryAsyncResult(decoder.start({ source: options.source }))).inspect(() => { state = "playing"; }); }, async pause() { return (await tryAsyncResult(() => decoder.stop())).inspect(() => { state = "paused"; }); }, async stop() { return (await tryAsyncResult(() => decoder.stop())).inspect(() => { state = "paused"; currentTime = 0; }); }, async seek(time) { return (await tryAsyncResult(() => decoder.seek(time * 1e3))).inspect(() => { currentTime = time; }); }, getFrame() { lastFrame = pullFrame(); if (lastFrame != null) frameListeners.forEach((listener) => listener(lastFrame)); const frame = lastFrame; lastFrame = null; return Ok(frame); }, onFrame(listener) { frameListeners.add(listener); }, offFrame(listener) { if (listener) frameListeners.delete(listener); else frameListeners.clear(); }, onEnded(listener) { endedListeners.add(listener); }, offEnded(listener) { if (listener) endedListeners.delete(listener); else endedListeners.clear(); }, onError(listener) { errorListeners.add(listener); }, offError(listener) { if (listener) errorListeners.delete(listener); else errorListeners.clear(); }, destroy() { if (state === "destroyed") return; state = "destroyed"; decoder.off("ended", handleEnded); tryAsyncResult(decoder.remove()); frameListeners.clear(); endedListeners.clear(); errorListeners.clear(); } }); } /** * 从小游戏本地文件创建视频帧源。 * * @param filePath - 视频文件路径。 * @param options - 视频帧源创建选项。 * @returns 视频帧源创建结果。 */ function createVideoFrameSourceFromFile$2(filePath, options) { return validateReadablePath(filePath).andThen((source) => createVideoFrameSource$2({ ...options, source })); } function toPixelFrame(frameData) { return { kind: "pixels", format: "rgba", data: new Uint8Array(frameData.data), width: frameData.width, height: frameData.height, timestamp: frameData.pkPts / 1e3, release() {} }; } //#endregion //#region src/std/video/frame-source/web.ts /** * @internal * Web 平台的视频帧源实现。 */ /** * 判断 Web 平台是否支持视频帧源。 * * @returns 是否支持视频帧源。 */ function isVideoFrameSourceSupported$1() { return typeof document !== "undefined" && typeof document.createElement === "function"; } /** * 创建 Web 平台视频帧源。 * * @param options - 视频帧源创建选项。 * @returns 视频帧源。 */ function createVideoFrameSource$1(options) { const video = document.createElement("video"); let state = "idle"; let hasNewFrame = false; let frameCallbackId; let rafId; let lastFrameTime = -1; const frameListeners = /* @__PURE__ */ new Set(); const endedListeners = /* @__PURE__ */ new Set(); const errorListeners = /* @__PURE__ */ new Set(); const createFrame = () => ({ kind: "element", element: video, width: video.videoWidth || video.width, height: video.videoHeight || video.height, timestamp: video.currentTime, release() {} }); const emitFrame = () => { if (state === "destroyed") return; hasNewFrame = true; const frame = createFrame(); frameListeners.forEach((listener) => listener(frame)); }; const requestNextFrame = () => { if (state === "destroyed") return; if (typeof video.requestVideoFrameCallback === "function") { frameCallbackId = video.requestVideoFrameCallback((_now, metadata) => { lastFrameTime = metadata.mediaTime; emitFrame(); requestNextFrame(); }); return; } rafId = requestAnimationFrame(() => { if (video.currentTime !== lastFrameTime) { lastFrameTime = video.currentTime; emitFrame(); } requestNextFrame(); }); }; const cancelFrameRequest = () => { if (frameCallbackId != null && typeof video.cancelVideoFrameCallback === "function") { video.cancelVideoFrameCallback(frameCallbackId); frameCallbackId = void 0; } if (rafId != null) { cancelAnimationFrame(rafId); rafId = void 0; } }; video.src = options.source; video.loop = options.loop ?? false; video.muted = options.muted ?? false; video.autoplay = options.autoplay ?? false; video.preload = "auto"; video.setAttribute("playsinline", "true"); video.setAttribute("webkit-playsinline", "true"); if (options.crossOrigin !== void 0) video.crossOrigin = options.crossOrigin; if (options.width !== void 0) video.width = options.width; if (options.height !== void 0) video.height = options.height; video.style.display = "none"; document.body.appendChild(video); state = "loading"; video.addEventListener("loadedmetadata", () => { if (state !== "destroyed") state = "ready"; }); video.addEventListener("ended", () => { if (state !== "destroyed") state = "ended"; endedListeners.forEach((listener) => listener()); }); video.addEventListener("error", () => { if (state !== "destroyed") state = "error"; const error = new Error(video.error?.message ?? "Video frame source error"); errorListeners.forEach((listener) => listener(error)); }); requestNextFrame(); const source = { get state() { return state; }, get currentTime() { return video.currentTime; }, get duration() { return video.duration; }, get width() { return video.videoWidth || video.width; }, get height() { return video.videoHeight || video.height; }, async play() { return (await tryAsyncResult(video.play())).inspect(() => { state = "playing"; }); }, async pause() { return tryAsyncResult(() => { video.pause(); state = "paused"; }); }, async stop() { return tryAsyncResult(() => { video.pause(); video.currentTime = 0; state = "paused"; }); }, async seek(time) { return tryAsyncResult(() => { video.currentTime = time; }); }, getFrame() { if (!hasNewFrame || state === "destroyed") return Ok(null); hasNewFrame = false; return Ok(createFrame()); }, onFrame(listener) { frameListeners.add(listener); }, offFrame(listener) { if (listener) frameListeners.delete(listener); else frameListeners.clear(); }, onEnded(listener) { endedListeners.add(listener); }, offEnded(listener) { if (listener) endedListeners.delete(listener); else endedListeners.clear(); }, onError(listener) { errorListeners.add(listener); }, offError(listener) { if (listener) errorListeners.delete(listener); else errorListeners.clear(); }, destroy() { if (state === "destroyed") return; state = "destroyed"; cancelFrameRequest(); video.pause(); video.src = ""; video.load(); video.remove(); frameListeners.clear(); endedListeners.clear(); errorListeners.clear(); } }; if (options.autoplay) source.play(); return source; } /** * 从 Web 本地文件创建视频帧源。 * * @param filePath - 视频文件路径。 * @param options - 视频帧源创建选项。 * @returns 视频帧源创建结果。 */ async function createVideoFrameSourceFromFile$1(filePath, options) { return (await readBlobFile(filePath)).andThen((blob) => { const url = URL.createObjectURL(blob); const source = createVideoFrameSource$1({ ...options, source: url }); const destroy = source.destroy.bind(source); source.destroy = () => { destroy(); URL.revokeObjectURL(url); }; return Ok(source); }); } //#endregion //#region src/std/video/frame-source/mod.ts /** * 判断当前平台是否支持视频帧源。 * * @returns 是否支持创建 VideoFrameSource。 * @since 2.2.0 * @example * ```ts * if (video.isVideoFrameSourceSupported()) { * const sourceRes = video.createVideoFrameSource({ source: 'https://example.com/video.mp4' }); * } * ``` */ function isVideoFrameSourceSupported() { return IS_MINA ? isVideoFrameSourceSupported$2() : isVideoFrameSourceSupported$1(); } /** * 创建视频帧源,用于将视频帧上传到 RenderTexture/WebGL 纹理。 * * @param options - 视频帧源创建选项。 * @returns 视频帧源创建结果。 * @since 2.2.0 * @example * ```ts * const sourceRes = video.createVideoFrameSource({ * source: 'https://example.com/video.mp4', * muted: true, * loop: true, * }); * if (sourceRes.isOk()) { * const source = sourceRes.unwrap(); * await source.play(); * } * ``` */ function createVideoFrameSource(options) { return IS_MINA ? createVideoFrameSource$2(options) : Ok(createVideoFrameSource$1(options)); } /** * 从本地文件创建视频帧源。 * * 小游戏平台会直接使用文件路径;Web 平台会从 OPFS 读取文件并创建 Blob URL。 * * @param filePath - 视频文件路径。 * @param options - 视频帧源创建选项。 * @returns 视频帧源创建结果。 * @since 2.2.0 * @example * ```ts * const sourceRes = await video.createVideoFrameSourceFromFile('/videos/demo.mp4', { * muted: true, * }); * if (sourceRes.isOk()) { * const source = sourceRes.unwrap(); * await source.play(); * } * ``` */ function createVideoFrameSourceFromFile(filePath, options) { return IS_MINA ? Promise.resolve(createVideoFrameSourceFromFile$2(filePath, options)) : createVideoFrameSourceFromFile$1(filePath, options); } //#endregion //#region src/std/video/mod.ts /** * 视频播放模块,提供创建和控制视频播放器的功能。 * @module video */ /** * 创建视频播放器。 * @param options - 视频配置选项。 * @returns Video对象。 * @since 2.0.0 * @example * ```ts * const video = createVideo({ * src: 'https://example.com/video.mp4', * width: 640, * height: 360, * autoplay: false, * }); * video.play(); * // 销毁视频 * video.destroy(); * ``` */ function createVideo(options) { return (IS_MINA ? wx.createVideo : createVideo$1)(options); } //#endregion export { createVideo, createVideoFrameSource, createVideoFrameSourceFromFile, isVideoFrameSourceSupported }; //# sourceMappingURL=video.mjs.map