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
JavaScript
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