update: bili-toy

This commit is contained in:
Vick Scarlet
2026-08-16 18:26:59 +08:00
parent 30b4791813
commit 6c803d4eef
17 changed files with 1212 additions and 201 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* 哔哩哔哩官方 ToyCloudSaveCore 核心分片存储算法组件环境类型声明文件 (Ambient Declaration)
*
* 说明:本文件专为官方无类型的 ToyCloudSaveCore.js 设计。
* 放入项目后,TypeScript 会自动在全局识别该组件,红线报错将彻底消除。
*/
declare namespace ToyCloudSaveCoreSDK {
/** 外部必须提供给该核心组件的底层持久化存储驱动接口契约 */
interface IToyStorageDriver {
/** 异步获取指定键值对记录 */
get(keys: string[]): Promise<Record<string, any> | null | undefined>
/** 异步设置并持久化键值对记录,附带当前的存储上下文 */
set(
record: Record<string, string>,
context: IToyStorageContext,
): Promise<void>
}
/** 存储操作的阶段上下文 */
interface IToyStorageContext {
/** 当前操作的存储槽区 */
bank: 'a' | 'b'
/** 递增的存储世代号 */
generation: number
/** 当前落盘的具体物理阶段 */
phase: 'chunks' | 'meta' | 'head'
}
/** 存档操作成功后返回的物理数据摘要 */
interface IToySaveSummary {
/** 写入的存储槽区 */
bank: 'a' | 'b'
/** 递增的世代代数 */
generation: number
/** 本次存盘的实际 UTF-8 字节数 */
bytes: number
/** 分片总块数 */
chunks: number
/** 数据流的 CRC32 校验和十六进制字符串 */
checksum: string
}
/** 状态机向外部观察者派发的所有生命周期事件 */
interface IToySaveEvent {
type:
| 'save:start'
| 'save:phase'
| 'save:verified'
| 'save:success'
| 'save:error'
| 'save:rejected'
| 'save:queued'
| 'load:start'
| 'load:success'
| 'load:empty'
at: number
phase?: 'chunks' | 'meta' | 'head' | 'verify'
bank?: 'a' | 'b'
generation?: number
bytes?: number
chunks?: number
checksum?: string
fallback?: boolean
recovered?: boolean
source?: 'head' | 'scan' | 'fallback'
pending?: number
code?:
| 'STORAGE_ERROR'
| 'VERIFICATION_FAILED'
| 'PAYLOAD_TOO_LARGE'
| 'INVALID_PAYLOAD'
| string
found?: boolean
}
/** 观察者事件回调函数 */
type IToySaveEventHandler = (
event: Readonly<IToySaveEvent>,
) => void | Promise<void>
/** 创建云存储实例所需的入参配置项 */
interface IToyCloudSaveOptions {
/** 必填:提供底层读写能力的本地适配驱动 */
storage: IToyStorageDriver
/** 必填:用于防冲突的唯一键值前缀 */
prefix: string
/** 选填:核心生命周期事件的全量观察者钩子 */
onEvent?: IToySaveEventHandler
}
/** 分片明细列表项 */
interface IToyChunkInspect {
index: number
chars: number
preview: string
}
/** 全盘负载深度审阅快照返回值 */
interface IToyPayloadInspection {
json: string
bytes: number
base64Chars: number
chunkCount: number
checksum: string
exceedsLimit: boolean
chunks: IToyChunkInspect[]
}
/** 调试状态快照 */
interface IToyDebugState {
prefix: string
pendingSaves: number
activeSave: boolean
head: {
bank?: 'a' | 'b'
generation?: number
valid: boolean
status?: string
} | null
banks: {
a: {
bank?: 'a' | 'b'
generation?: number
valid: boolean
status?: string
} | null
b: {
bank?: 'a' | 'b'
generation?: number
valid: boolean
status?: string
} | null
}
lastSave: IToySaveSummary | null
lastLoad: any
events: IToySaveEvent[]
}
/** 经由 createCloudSave 创建的物理隔离处理服务实例 API */
interface IToyCloudSaveInstance {
/** 写入保存云存档 */
save(json: string): Promise<IToySaveSummary>
/** 读取云存档 */
load(): Promise<string | null>
/** 导出内存调试快照 */
getDebugState(): IToyDebugState
}
/** 对应官方核心 JS 脚本导出的顶级大对象契约 */
interface CoreObject {
/** 单个分卷分片的最大安全 Base64 字符容量边界(960 */
readonly CHUNK_CHARS: 960
/** 双槽双缓冲设计允许承载的单次数据最大分片切片物理上限(48 块) */
readonly MAX_CHUNKS: 48
/** 计算指定数据的标准 CRC32 校验和 */
crc32Hex(input: string | ArrayBuffer | ArrayBufferView): string
/** 前置透视并审阅即将进行云存的文件负载 */
inspectPayload(json: string): IToyPayloadInspection
/** 核心工厂构建器:构建具备高可靠双槽原子性保护的云存档处理服务 */
createCloudSave(options: IToyCloudSaveOptions): IToyCloudSaveInstance
}
}
// ----------------- 全局注入声明 -----------------
interface Window {
/** 哔哩哔哩官方无类型的云存档核心分片存储组件。由本地或网络脚本执行后自动挂载。 */
ToyCloudSaveCore: ToyCloudSaveCoreSDK.CoreObject
}
/** 哔哩哔哩官方无类型的云存档核心分片存储组件顶级全局变量。 */
declare const ToyCloudSaveCore: ToyCloudSaveCoreSDK.CoreObject
+577
View File
@@ -0,0 +1,577 @@
(function attachToyCloudSaveCore(root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
debugger
root.ToyCloudSaveCore = factory();
}
}(typeof globalThis !== 'undefined' ? globalThis : this, function buildToyCloudSaveCore() {
'use strict';
const CHUNK_CHARS = 960;
const MAX_CHUNKS = 48;
const EVENT_LIMIT = 60;
const BANKS = ['a', 'b'];
const CRC32_TABLE = buildCrc32Table();
function buildCrc32Table() {
const table = new Uint32Array(256);
for (let index = 0; index < table.length; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
}
table[index] = value >>> 0;
}
return table;
}
function utf8Encode(value) {
if (typeof TextEncoder !== 'undefined') {
return new TextEncoder().encode(value);
}
if (typeof Buffer !== 'undefined') {
return Uint8Array.from(Buffer.from(value, 'utf8'));
}
const binary = unescape(encodeURIComponent(value));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function utf8Decode(bytes) {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}
if (typeof Buffer !== 'undefined') {
const value = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8');
if (!sameBytes(utf8Encode(value), bytes)) {
throw new Error('Invalid UTF-8');
}
return value;
}
let binary = '';
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return decodeURIComponent(escape(binary));
}
function sameBytes(left, right) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
function toBytes(input) {
if (typeof input === 'string') return utf8Encode(input);
if (typeof ArrayBuffer !== 'undefined' && input instanceof ArrayBuffer) {
return new Uint8Array(input);
}
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(input)) {
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
}
throw new TypeError('crc32Hex expects a string, ArrayBuffer, or typed array');
}
function crc32Hex(input) {
const bytes = toBytes(input);
let crc = 0xffffffff;
for (let index = 0; index < bytes.length; index += 1) {
crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
}
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
}
function bytesToBase64(bytes) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
}
if (typeof btoa !== 'function') {
throw new Error('Base64 encoding is unavailable');
}
let binary = '';
const batch = 0x8000;
for (let offset = 0; offset < bytes.length; offset += batch) {
const slice = bytes.subarray(offset, offset + batch);
for (let index = 0; index < slice.length; index += 1) {
binary += String.fromCharCode(slice[index]);
}
}
return btoa(binary);
}
function base64ToBytes(value) {
if (!isCanonicalBase64(value)) throw new Error('Invalid Base64');
if (typeof Buffer !== 'undefined') {
return Uint8Array.from(Buffer.from(value, 'base64'));
}
if (typeof atob !== 'function') {
throw new Error('Base64 decoding is unavailable');
}
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function isCanonicalBase64(value) {
if (value === '') return true;
if (value.length % 4 !== 0) return false;
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
}
function preparePayload(json) {
if (typeof json !== 'string') {
throw new TypeError('Cloud save payload must be a JSON string');
}
try {
JSON.parse(json);
} catch (parseError) {
const error = new SyntaxError('Cloud save payload must contain valid JSON');
error.code = 'INVALID_JSON';
throw error;
}
const bytes = utf8Encode(json);
const base64 = bytesToBase64(bytes);
const chunks = [];
if (base64.length === 0) {
chunks.push('');
} else {
for (let offset = 0; offset < base64.length; offset += CHUNK_CHARS) {
chunks.push(base64.slice(offset, offset + CHUNK_CHARS));
}
}
return {
json,
bytes: bytes.length,
base64Chars: base64.length,
chunks,
checksum: crc32Hex(bytes),
};
}
function inspectPayload(json) {
const payload = preparePayload(json);
return {
json: payload.json,
bytes: payload.bytes,
base64Chars: payload.base64Chars,
chunkCount: payload.chunks.length,
checksum: payload.checksum,
exceedsLimit: payload.chunks.length > MAX_CHUNKS,
chunks: payload.chunks.map((value, index) => ({
index,
chars: value.length,
preview: value.length > 24 ? `${value.slice(0, 24)}` : value,
})),
};
}
function createCloudSave(options) {
const config = options || {};
const storage = config.storage;
const prefix = config.prefix;
const onEvent = config.onEvent;
if (!storage || typeof storage.get !== 'function' || typeof storage.set !== 'function') {
throw new TypeError('storage must provide async get(keys) and set(record, context) methods');
}
if (typeof prefix !== 'string' || prefix.length === 0) {
throw new TypeError('prefix must be a non-empty string');
}
if (onEvent !== undefined && typeof onEvent !== 'function') {
throw new TypeError('onEvent must be a function');
}
const keys = {
head: `${prefix}_head`,
meta(bank) {
return `${prefix}_${bank}_meta`;
},
chunk(bank, index) {
return `${prefix}_${bank}_${String(index).padStart(3, '0')}`;
},
};
const state = {
pendingSaves: 0,
activeSave: false,
head: null,
banks: { a: null, b: null },
lastSave: null,
lastLoad: null,
events: [],
};
let saveTail = Promise.resolve();
function emit(type, details) {
const allowed = [
'bank', 'generation', 'bytes', 'chunks', 'checksum', 'phase',
'source', 'fallback', 'recovered', 'pending', 'code', 'found',
];
const event = { type, at: Date.now() };
const source = details || {};
for (const key of allowed) {
if (source[key] !== undefined) event[key] = source[key];
}
const frozen = Object.freeze(event);
state.events.push({ ...event });
if (state.events.length > EVENT_LIMIT) state.events.shift();
if (onEvent) {
try {
const result = onEvent(frozen);
if (result && typeof result.catch === 'function') result.catch(function ignoreEventError() {});
} catch (error) {
// Observers cannot affect persistence.
}
}
}
async function getRecord(requestedKeys) {
const record = await storage.get(requestedKeys);
return record && typeof record === 'object' ? record : {};
}
function own(record, key) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function parseObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
if (typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch (error) {
return null;
}
}
function parseHead(value) {
const head = parseObject(value);
if (!head || head.v !== 1 || !BANKS.includes(head.bank)) return null;
if (!Number.isSafeInteger(head.generation) || head.generation < 1) return null;
return { v: 1, bank: head.bank, generation: head.generation };
}
function parseMeta(value) {
const meta = parseObject(value);
if (!meta || meta.v !== 1) return null;
if (!Number.isSafeInteger(meta.generation) || meta.generation < 1) return null;
if (!Number.isSafeInteger(meta.chunks) || meta.chunks < 1 || meta.chunks > MAX_CHUNKS) return null;
if (!Number.isSafeInteger(meta.bytes) || meta.bytes < 0) return null;
if (typeof meta.checksum !== 'string' || !/^[0-9a-f]{8}$/.test(meta.checksum)) return null;
return {
v: 1,
generation: meta.generation,
chunks: meta.chunks,
bytes: meta.bytes,
checksum: meta.checksum,
};
}
function summary(bank, meta) {
return {
bank,
generation: meta.generation,
bytes: meta.bytes,
chunks: meta.chunks,
checksum: meta.checksum,
};
}
function invalidBank(bank, status) {
state.banks[bank] = { bank, valid: false, status };
return { valid: false, bank, status };
}
function validateBankRecord(bank, record, expectedGeneration) {
const metaKey = keys.meta(bank);
const meta = own(record, metaKey) ? parseMeta(record[metaKey]) : null;
if (!meta) return invalidBank(bank, own(record, metaKey) ? 'invalid' : 'missing');
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
return invalidBank(bank, 'generation-mismatch');
}
const chunks = [];
for (let index = 0; index < meta.chunks; index += 1) {
const key = keys.chunk(bank, index);
const value = record[key];
if (typeof value !== 'string' || value.length > CHUNK_CHARS) {
return invalidBank(bank, 'invalid-chunk');
}
if (index < meta.chunks - 1 && value.length !== CHUNK_CHARS) {
return invalidBank(bank, 'invalid-chunk');
}
chunks.push(value);
}
try {
const bytes = base64ToBytes(chunks.join(''));
if (bytes.length !== meta.bytes || crc32Hex(bytes) !== meta.checksum) {
return invalidBank(bank, 'checksum-mismatch');
}
const json = utf8Decode(bytes);
try {
JSON.parse(json);
} catch (parseError) {
return invalidBank(bank, 'invalid-json');
}
const result = { valid: true, bank, json, meta, summary: summary(bank, meta) };
state.banks[bank] = { ...result.summary, valid: true };
return result;
} catch (error) {
return invalidBank(bank, 'decode-failed');
}
}
async function readHead() {
const record = await getRecord([keys.head]);
const head = own(record, keys.head) ? parseHead(record[keys.head]) : null;
state.head = head
? { bank: head.bank, generation: head.generation, valid: true }
: { valid: false, status: own(record, keys.head) ? 'invalid' : 'missing' };
return head;
}
async function readBank(bank, expectedGeneration) {
const metaKey = keys.meta(bank);
const metaRecord = await getRecord([metaKey]);
const meta = own(metaRecord, metaKey) ? parseMeta(metaRecord[metaKey]) : null;
if (!meta) return invalidBank(bank, own(metaRecord, metaKey) ? 'invalid' : 'missing');
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
return invalidBank(bank, 'generation-mismatch');
}
const chunkKeys = [];
for (let index = 0; index < meta.chunks; index += 1) {
chunkKeys.push(keys.chunk(bank, index));
}
const chunkRecord = await getRecord(chunkKeys);
return validateBankRecord(bank, { ...metaRecord, ...chunkRecord }, expectedGeneration);
}
function otherBank(bank) {
return bank === 'a' ? 'b' : 'a';
}
function newestValid(results) {
return results
.filter((result) => result.valid)
.sort((left, right) => right.meta.generation - left.meta.generation)[0] || null;
}
async function chooseSaveTarget() {
const head = await readHead();
let first;
let second;
let base = null;
if (head) {
first = await readBank(head.bank, head.generation);
second = await readBank(otherBank(head.bank));
base = first.valid ? first : (second.valid ? second : null);
} else {
first = await readBank('a');
second = await readBank('b');
base = newestValid([first, second]);
}
const highestGeneration = Math.max(
head ? head.generation : 0,
first.valid ? first.meta.generation : 0,
second.valid ? second.meta.generation : 0,
);
return {
bank: base ? otherBank(base.bank) : (head ? otherBank(head.bank) : 'a'),
generation: highestGeneration + 1,
};
}
function makeError(message, code, ErrorType) {
const error = new (ErrorType || Error)(message);
error.code = code;
return error;
}
async function savePrepared(payload) {
state.activeSave = true;
let transaction;
try {
transaction = await chooseSaveTarget();
const bank = transaction.bank;
const generation = transaction.generation;
const meta = {
v: 1,
generation,
chunks: payload.chunks.length,
bytes: payload.bytes,
checksum: payload.checksum,
};
const savedSummary = summary(bank, meta);
const context = { bank, generation };
emit('save:start', savedSummary);
const chunkRecord = {};
for (let index = 0; index < payload.chunks.length; index += 1) {
chunkRecord[keys.chunk(bank, index)] = payload.chunks[index];
}
await storage.set(chunkRecord, { ...context, phase: 'chunks' });
emit('save:phase', { ...savedSummary, phase: 'chunks' });
await storage.set({ [keys.meta(bank)]: JSON.stringify(meta) }, { ...context, phase: 'meta' });
emit('save:phase', { ...savedSummary, phase: 'meta' });
const verifyKeys = [keys.meta(bank), ...Object.keys(chunkRecord)];
const readback = await getRecord(verifyKeys);
const verified = validateBankRecord(bank, readback, generation);
if (
!verified.valid
|| verified.json !== payload.json
|| verified.meta.bytes !== meta.bytes
|| verified.meta.chunks !== meta.chunks
|| verified.meta.checksum !== meta.checksum
) {
throw makeError('Cloud save readback verification failed', 'VERIFICATION_FAILED');
}
emit('save:verified', { ...savedSummary, phase: 'verify' });
const head = { v: 1, bank, generation };
await storage.set({ [keys.head]: JSON.stringify(head) }, { ...context, phase: 'head' });
state.head = { bank, generation, valid: true };
state.lastSave = { ...savedSummary };
emit('save:success', savedSummary);
return savedSummary;
} catch (error) {
emit('save:error', {
bank: transaction && transaction.bank,
generation: transaction && transaction.generation,
code: error && error.code ? error.code : 'STORAGE_ERROR',
});
throw error;
} finally {
state.activeSave = false;
}
}
function save(json) {
let payload;
try {
payload = preparePayload(json);
if (payload.chunks.length > MAX_CHUNKS) {
throw makeError(
`Cloud save payload needs ${payload.chunks.length} chunks; maximum is ${MAX_CHUNKS}`,
'PAYLOAD_TOO_LARGE',
RangeError,
);
}
} catch (error) {
emit('save:rejected', {
bytes: payload && payload.bytes,
chunks: payload && payload.chunks.length,
code: error && error.code ? error.code : 'INVALID_PAYLOAD',
});
return Promise.reject(error);
}
state.pendingSaves += 1;
emit('save:queued', {
bytes: payload.bytes,
chunks: payload.chunks.length,
checksum: payload.checksum,
pending: state.pendingSaves,
});
const operation = saveTail.then(function runQueuedSave() {
return savePrepared(payload);
});
const completed = operation.finally(function finishQueuedSave() {
state.pendingSaves -= 1;
});
saveTail = completed.catch(function keepQueueAlive() {});
return completed;
}
function finishLoad(result, flags) {
const loadSummary = {
...result.summary,
fallback: Boolean(flags.fallback),
recovered: Boolean(flags.recovered),
source: flags.recovered ? 'scan' : (flags.fallback ? 'fallback' : 'head'),
};
state.lastLoad = loadSummary;
emit('load:success', loadSummary);
return result.json;
}
async function load() {
const savesQueuedBeforeLoad = saveTail;
emit('load:start');
await savesQueuedBeforeLoad;
const head = await readHead();
if (head) {
const active = await readBank(head.bank, head.generation);
if (active.valid) return finishLoad(active, { fallback: false, recovered: false });
const fallback = await readBank(otherBank(head.bank));
if (fallback.valid && fallback.meta.generation < head.generation) {
return finishLoad(fallback, { fallback: true, recovered: false });
}
if (fallback.valid) {
state.banks[fallback.bank] = {
...fallback.summary,
valid: false,
status: 'uncommitted',
};
}
state.lastLoad = { found: false, fallback: true, recovered: false };
emit('load:empty', state.lastLoad);
return null;
}
const bankA = await readBank('a');
const bankB = await readBank('b');
const recovered = newestValid([bankA, bankB]);
if (recovered) return finishLoad(recovered, { fallback: false, recovered: true });
state.lastLoad = { found: false, fallback: false, recovered: true };
emit('load:empty', state.lastLoad);
return null;
}
function getDebugState() {
return JSON.parse(JSON.stringify({
prefix,
pendingSaves: state.pendingSaves,
activeSave: state.activeSave,
head: state.head,
banks: state.banks,
lastSave: state.lastSave,
lastLoad: state.lastLoad,
events: state.events,
}));
}
return Object.freeze({ save, load, getDebugState });
}
return Object.freeze({
createCloudSave,
inspectPayload,
crc32Hex,
CHUNK_CHARS,
MAX_CHUNKS,
});
}));
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Toy 分片云存档接入示例</title>
<style>
:root { color-scheme: light; font-family: system-ui, sans-serif; }
body { max-width: 760px; margin: 0 auto; padding: 24px; color: #18191c; background: #f6f7f8; }
main { display: grid; gap: 16px; padding: 24px; border: 1px solid #e3e5e7; border-radius: 12px; background: #fff; }
textarea { width: 100%; min-height: 220px; box-sizing: border-box; padding: 12px; font: 14px/1.6 ui-monospace, monospace; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
button { min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; color: #fff; background: #00aeec; cursor: pointer; }
button:disabled { cursor: wait; opacity: .55; }
#status { min-height: 24px; margin: 0; color: #61666d; }
</style>
</head>
<body>
<main>
<h1>Toy 分片云存档接入示例</h1>
<p>示例始终先保存本地档;Toy SDK 可用且玩家已登录时,再提交云端。</p>
<label for="save-json">存档 JSON</label>
<textarea id="save-json" spellcheck="false"></textarea>
<div class="actions">
<button id="make-example" type="button">生成示例</button>
<button id="save" type="button">保存</button>
<button id="load" type="button">读取</button>
</div>
<p id="status" role="status" aria-live="polite">正在初始化…</p>
</main>
<!-- 顺序不可颠倒:Toy SDK → 通用核心 → 业务脚本。 -->
<script src="https://s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js"></script>
<script src="./cloud-save-core.js"></script>
<script src="./example.js"></script>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
(function () {
'use strict';
var PREFIX = 'your_game_save_v1';
var LOCAL_KEY = PREFIX + '_local';
function ToyStorageAdapter(toySdk) {
if (
!toySdk ||
typeof toySdk.getCloudStorage !== 'function' ||
typeof toySdk.setCloudStorage !== 'function'
) {
throw new Error('Toy 云存储 SDK 不可用');
}
this.toy = toySdk;
}
ToyStorageAdapter.prototype.get = async function (keys) {
var result = await this.toy.getCloudStorage(keys);
if (result && result.data && typeof result.data === 'object') {
return result.data;
}
return result || {};
};
ToyStorageAdapter.prototype.set = async function (record) {
await this.toy.setCloudStorage(record);
};
function createExampleState() {
return {
schemaVersion: 1,
updatedAt: Date.now(),
progress: { stage: 1, score: 120 },
settings: { music: true, language: 'zh-CN' },
};
}
function summarizeError(error) {
var message = error && error.message ? error.message : String(error || '未知错误');
return message.slice(0, 160);
}
document.addEventListener('DOMContentLoaded', function () {
var editor = document.getElementById('save-json');
var status = document.getElementById('status');
var saveButton = document.getElementById('save');
var loadButton = document.getElementById('load');
var exampleButton = document.getElementById('make-example');
var cloudSave = null;
function setStatus(message) {
status.textContent = message;
}
function applyLoadedState(json) {
// 替换点:把 JSON 交给项目原有的存档应用逻辑。
JSON.parse(json);
editor.value = json;
}
function loadLocalFirst() {
var localJson = localStorage.getItem(LOCAL_KEY);
if (!localJson) {
editor.value = JSON.stringify(createExampleState(), null, 2);
return false;
}
try {
applyLoadedState(localJson);
return true;
} catch (error) {
localStorage.removeItem(LOCAL_KEY);
editor.value = JSON.stringify(createExampleState(), null, 2);
return false;
}
}
function parseEditor() {
var value = editor.value.trim();
JSON.parse(value);
return value;
}
function setBusy(isBusy) {
saveButton.disabled = isBusy;
loadButton.disabled = isBusy;
}
var hadLocalSave = loadLocalFirst();
try {
if (!window.ToyCloudSaveCore) {
throw new Error('cloud-save-core.js 未加载');
}
var storage = new ToyStorageAdapter(window.toy || window.Toy);
cloudSave = window.ToyCloudSaveCore.createCloudSave({
storage: storage,
prefix: 'your_game_save_v1',
onEvent: function (event) {
// 只记录阶段摘要,禁止在这里打印完整 JSON。
if (event && event.phase) {
status.dataset.phase = event.phase;
}
},
});
setStatus(hadLocalSave ? '已加载本地档;可读取云端进行校验。' : '云存档已就绪。');
} catch (error) {
setStatus('当前仅使用本地存档:' + summarizeError(error));
}
exampleButton.addEventListener('click', function () {
editor.value = JSON.stringify(createExampleState(), null, 2);
setStatus('已生成小型示例,尚未保存。');
});
saveButton.addEventListener('click', async function () {
var json;
try {
json = parseEditor();
} catch (error) {
setStatus('JSON 无效,请修正后再保存:' + summarizeError(error));
return;
}
localStorage.setItem(LOCAL_KEY, json);
if (!cloudSave) {
setStatus('本地保存成功;当前环境无法写入 Toy 云端。');
return;
}
setBusy(true);
setStatus('本地保存成功,正在提交云端…');
try {
var result = await cloudSave.save(json);
setStatus('云保存成功:Bank ' + result.bank.toUpperCase() + '' + result.chunks + ' 片。');
} catch (error) {
setStatus('本地已保存,但云保存失败:' + summarizeError(error));
} finally {
setBusy(false);
}
});
loadButton.addEventListener('click', async function () {
var localFound = loadLocalFirst();
if (!cloudSave) {
setStatus(localFound ? '已读取本地档;当前环境无法读取 Toy 云端。' : '本地和云端均无可用存档。');
return;
}
setBusy(true);
setStatus(localFound ? '已应用本地档,正在校验云端…' : '正在读取云端…');
try {
var cloudJson = await cloudSave.load();
if (cloudJson === null) {
setStatus(localFound ? '云端为空,继续使用本地档。' : '云端和本地均为空。');
} else {
applyLoadedState(cloudJson);
localStorage.setItem(LOCAL_KEY, cloudJson);
setStatus('云档校验通过并已同步到本地。');
}
} catch (error) {
setStatus((localFound ? '继续使用本地档;' : '') + '云读取失败:' + summarizeError(error));
} finally {
setBusy(false);
}
});
});
})();
+597
View File
@@ -0,0 +1,597 @@
/**
* Toy JS SDK 类型声明(面向创作者)
*
* 用法:把本文件放进你的 Toy 项目(如 `types/toy.d.ts`),TypeScript 会自动加载,
* 无需 import 即可获得 `window.toy` 的类型检查与补全。
*
* 前置:页面 `<head>` 中引入
* <script src="//s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js"></script>
* 加载后 SDK 把实例挂到全局 `window.toy`,所有方法返回 Promise。
*
* 通用约定:
* - 所有方法抛出的 Errormessage 统一带 `[ToySDK]` 前缀。
* - 数据类能力(用户 / 作者 / 视频互动)不抛错时通过 `status` 字段表达结果,
* 需要先判断 `status === 'ok'` 再读 `data` / `items`。
* - 云存储与排行榜失败时 Promise reject,需要 try/catch。
*/
declare namespace ToySDK {
// ---------------------------------------------------------------------------
// 页面跳转
// ---------------------------------------------------------------------------
/** 站内跳转的目标页面类型。 */
type NavigateType = 'video' | 'space' | 'search' | 'opus' | 'tribee' | 'toy'
interface NavigateReq {
/** 目标页面类型。 */
type: NavigateType
/**
* 资源标识,随 `type` 变化:
* - video: BV 号,如 `BV1Hh411S7Ys`
* - space: 用户 mid
* - search: 搜索关键词(SDK 内部会做 URL 编码)
* - opus: 图文 / 动态 id
* - tribee: 小站 id
* - toy: toy id
*/
id: string
/** 额外查询参数,拼接到目标 URL 上透传给目标页面。 */
extra?: Record<string, string>
}
// ---------------------------------------------------------------------------
// 保存图片
// ---------------------------------------------------------------------------
/** `url` 与 `base64Data` 二选一,两者都不传则由客户端返回错误。 */
interface SaveImageReq {
/** 网络图片地址。 */
url?: string
/** base64 图片数据。 */
base64Data?: string
/** 申请相册权限时展示给用户的提示文案。 */
hintMsg?: string
}
interface SaveImageResp {
/** 保存后的本地文件路径,由客户端返回。 */
localPath: string
}
// ---------------------------------------------------------------------------
// 分享与二维码
// ---------------------------------------------------------------------------
interface ShareReq {
/**
* 相对当前 Toy 页面根(`/toy/<slug>/`)的路径,可带 query,如 `result.html?score=100`。
*
* 完整分享链接由平台生成,Toy 不能自行指定完整 URL。路径只能落在当前 Toy 内:
* 传绝对 URL 或用 `../` 越界到其他 Toy / 外域会抛 `invalid_param`。
*/
path: string
}
interface QrCodeReq {
/**
* 相对当前 Toy 页面根(`/toy/<slug>/`)的路径,可带 query。
* 不传(或传空串)时指向当前 Toy 首页 `index.html`。
*
* 传了则与 `ShareReq.path` 同一约定:二维码内容由平台生成,
* Toy 不能自行指定完整 URL;传绝对 URL 或用 `../` 越界到其他 Toy / 外域会抛 `invalid_param`。
*/
path?: string
/** 二维码边长(像素),取值区间 `[80, 1024]` 的整数。不传默认 `320`,越界或非整数抛 `invalid_param`。 */
size?: number
}
interface QrCodeResp {
/** PNG 图片的完整 data URL`data:image/png;base64,...`),可直接赋给 `img.src`。 */
base64: string
/** 二维码实际编码的完整链接,由平台生成。 */
url: string
}
// ---------------------------------------------------------------------------
// 用户信息
// ---------------------------------------------------------------------------
interface UserProfileResp {
/** 头像地址,SDK 已统一归一化为 https 与 p0 CDN 域名。 */
avatar: string
/** 昵称。 */
nickname: string
/** 当前登录用户在当前 Toy 内的稳定假名标识,不是鉴权凭证;功能未启用时可能不返回。 */
toyOpenId?: string
}
// ---------------------------------------------------------------------------
// 数据类能力的状态枚举
// ---------------------------------------------------------------------------
/**
* 数据类能力的整体状态。
* - `ok`: 成功
* - `partial`: 批量请求部分成功,逐项状态见各 item 的 `status`
* - `unauthorized`: 未登录
* - `denied`: 用户拒绝了数据使用确认
* - `unsupported`: 当前环境不支持(如外部手机浏览器,SDK 会引导打开 B站 App)
* - `toy_context_unavailable`: 拿不到当前 toy 上下文
* - `author_mismatch`: 请求的资源不属于当前 Toy 作者(视频既非其投稿,也未以联合投稿身份参与创作)
* - `video_not_found`: 视频不存在
* - `video_invisible`: 视频对当前用户不可见
* - `unavailable`: 依赖服务不可用
* - `invalid_argument`: 参数非法
*/
type ToyDataStatus =
| 'ok'
| 'partial'
| 'unauthorized'
| 'denied'
| 'unsupported'
| 'toy_context_unavailable'
| 'author_mismatch'
| 'video_not_found'
| 'video_invisible'
| 'unavailable'
| 'invalid_argument'
/** 批量结果中单项的状态:与 `ToyDataStatus` 相同,但不会是 `partial`。 */
type ToyItemStatus = Exclude<ToyDataStatus, 'partial'>
// ---------------------------------------------------------------------------
// 作者资料
// ---------------------------------------------------------------------------
/** 作者认证信息。`role` / `type` 为后端数字枚举,SDK 未定义其取值含义。 */
interface AuthorCertification {
role: number
title: string
description: string
type: number
}
/** 充电聚合信息。 */
interface ChargingSummary {
/** 充电人数。 */
count: number
display?: {
show: boolean
text?: string
}
}
/** 粉丝勋章配置。 */
interface FanMedalConfig {
/** 勋章名称。 */
name: string
/** 是否已开启粉丝勋章。 */
enabled: boolean
/** 各等级的亲密度区间;最高等级无上限时 `maxIntimacy` 缺省。 */
levels: Array<{
level: number
minIntimacy: number
maxIntimacy?: number
}>
}
interface AuthorProfile {
nickname: string
avatar: string
/** 个性签名。 */
sign: string
/** 未认证时缺省。 */
certification?: AuthorCertification
/** 关注数。 */
following: number
/** 粉丝数。 */
follower: number
/** 稿件数。 */
archiveCount: number
/** 未开通充电时缺省。 */
charging?: ChargingSummary
/** 未配置粉丝勋章时缺省。 */
fanMedal?: FanMedalConfig
/** 生日,时间戳;未公开时缺省。 */
birthday?: number
}
interface AuthorProfileResp {
status: ToyDataStatus
/** `status` 非 `ok` 时缺省。 */
data?: AuthorProfile
}
// ---------------------------------------------------------------------------
// 作者视频
// ---------------------------------------------------------------------------
/** 视频引用:每项只能传 `aid` 或 `bvid` 之一,同时传或都不传会本地抛错。 */
type AuthorVideoRef =
| { aid: number; bvid?: never }
| { bvid: string; aid?: never }
interface AuthorVideosReq {
/** 150 项;SDK 会按 aid/bvid 去重并保留首次出现顺序。 */
videos: AuthorVideoRef[]
}
interface AuthorVideo {
aid: number
bvid: string
title: string
/** 封面地址,SDK 已归一化 CDN 域名。 */
cover: string
description: string
/** 发布时间,时间戳。 */
publishTime: number
/** 总时长,单位由后端定义。 */
duration: number
/** 分区名称。 */
partition?: string
/** 分 P 列表。 */
pages: Array<{
page: number
title: string
duration: number
}>
/** 稿件统计数据。 */
stat: {
view: number
like: number
coin: number
favorite: number
share: number
comment: number
danmaku: number
}
/** 付费相关标记。 */
pay: {
/** 是否充电专属。 */
chargingPay: boolean
/** 是否付费稿件。 */
paid: boolean
}
/** 所属合集 id;不属于任何合集时缺省。 */
seasonId?: number
/** 排行信息;无排行数据时缺省。 */
rank?: {
now: number
highest: number
}
}
interface AuthorVideoItem {
/** 回显本次请求传入的引用,用于与请求项对应。 */
ref: AuthorVideoRef
status: ToyItemStatus
/** `status` 非 `ok` 时缺省(如作者未参与该视频创作、视频不可见)。 */
data?: AuthorVideo
}
interface AuthorVideosResp {
/** 整体状态;部分项失败时为 `partial`。 */
status: ToyDataStatus
items: AuthorVideoItem[]
}
// ---------------------------------------------------------------------------
// 作者互动关系
// ---------------------------------------------------------------------------
interface AuthorRelation {
/** 当前访问用户是否已关注该作者。 */
isFollowing: boolean
/** 当前访问用户是否就是该 Toy 作者本人。 */
isAuthor: boolean
/** 是否为老粉。 */
isOldFan: boolean
/** 是否持有该作者的粉丝勋章。 */
hasFanMedal: boolean
/** 勋章名称;无勋章时缺省。 */
fanMedalName?: string
/** 勋章等级;无勋章时缺省。 */
fanMedalLevel?: number
/** 勋章是否处于点亮状态;无勋章时缺省。 */
isFanMedalActive?: boolean
/** 当前是否正在对该作者进行包月充电。 */
isCharging: boolean
/** 关注时间,时间戳;未关注时缺省。 */
followTime?: number
}
interface AuthorRelationResp {
status: ToyDataStatus
/** `status` 非 `ok` 时缺省。 */
data?: AuthorRelation
}
// ---------------------------------------------------------------------------
// 视频互动数据
// ---------------------------------------------------------------------------
interface VideoUserActionsReq {
/** 150 个正整数 aid;SDK 会去重并保留首次出现顺序。 */
aids: number[]
}
interface VideoUserActionItem {
aid: number
status: ToyItemStatus
/** 是否已点赞;`status` 非 `ok` 时缺省。 */
liked?: boolean
/** 已投币数;`status` 非 `ok` 时缺省。 */
coinCount?: number
/** 是否已收藏;`status` 非 `ok` 时缺省。 */
favorited?: boolean
}
interface VideoUserActionsResp {
/** 整体状态;部分项失败时为 `partial`。 */
status: ToyDataStatus
items: VideoUserActionItem[]
}
// ---------------------------------------------------------------------------
// 排行榜
// ---------------------------------------------------------------------------
/** 榜单周期:总榜(永久)/ 月 / 周 / 日。 */
type RankPeriod = 'all' | 'month' | 'week' | 'day'
interface SubmitScoreReq {
/** 榜位,固定 1 / 2 / 3(含义由 toy 自定义),不传默认 1;非法值本地抛错。 */
board?: number
/**
* 本次成绩的绝对分数,不是增量。整数,取值范围 -16777216 ~ 16777215
* 允许 0 与负数;超出范围本地抛错。服务端只保留该榜位的历史最高分。
*/
score: number
}
interface SubmitScoreResp {
/** 我的总榜(all)历史最高分,已合并本次提交。 */
score: number
}
interface RankListReq {
/** 榜位,固定 1 / 2 / 3,不传默认 1。 */
board?: number
/** 周期,不传按总榜 `all`。 */
period?: RankPeriod
/** 返回名次数量;不传或超上限按后端默认(≤100)。 */
limit?: number
}
/** 榜单单行:名次 + 历史最高分 + 展示用昵称/头像,不含 uid。 */
interface RankItem {
rank: number
score: number
nickname: string
/** 头像地址,SDK 已归一化 CDN 域名。 */
avatar: string
}
interface MyRankReq {
/** 榜位,固定 1 / 2 / 3,不传默认 1。 */
board?: number
/** 周期,不传按总榜 `all`。 */
period?: RankPeriod
}
interface MyRankResp {
/** 是否已上榜。分数允许 0 / 负,判断是否上榜必须用本字段,不能用 `score`。 */
ranked: boolean
/** 我的名次,从 1 起,唯一不并列(同分先达成者靠前);未上榜为 0。 */
rank: number
/** 我的历史最高分;未上榜为 0。 */
score: number
}
// ---------------------------------------------------------------------------
// 媒体能力(摄像头 / 麦克风)
// ---------------------------------------------------------------------------
/**
* 申请摄像头的可选项,仅 `requestCamera` 使用。
*
* 只接受 `facingMode` 一个字段:传入其他字段、或传非普通对象(数组 / 字符串 / null 等)
* 时 SDK 本地抛错。`requestMicrophone` 不接受任何参数。
*/
interface MediaRelayOptions {
/** 摄像头朝向:`'user'` 前置、`'environment'` 后置。不传默认前置。 */
facingMode?: 'user' | 'environment'
}
// ---------------------------------------------------------------------------
// window.toy 的公开 API 面
// ---------------------------------------------------------------------------
interface Toy {
/**
* 判断当前环境是否支持指定能力。传能力名(如 `'saveImageToAlbum'`)即可,
* 带不带 `toy.` 前缀都能匹配。
*
* 端外 Web 不支持 `saveImageToAlbum` / `share` / `closeBrowser`,其余能力两端一致
* (二维码 `getQrCode` 两端均可用)。
*/
isSupport(ability: string): Promise<boolean>
/**
* 跳转到指定页面。
*
* 必须在用户手势事件(如 click)中调用:SDK 会检查 `navigator.userActivation`
* 无有效用户激活时抛错。端内为原生跳转,端外新开标签页。
*/
navigate(req: NavigateReq): Promise<void>
/**
* 保存图片到系统相册。**仅 B站 App 内可用**,Web 端调用直接抛错。
*
* Web 端请改用标准浏览器下载能力(`<a download>` 或 canvas blob URL,需用户点击触发)。
*/
saveImageToAlbum(req: SaveImageReq): Promise<SaveImageResp>
/**
* 拉起 B站 App 的分享面板。**仅 B站 App 内可用**,Web 端调用直接抛错。
*
* 只传相对当前 Toy 的 `path`,完整分享链接由平台生成,
* 避免分享链接被伪造指向其他 Toy 或外部站点。`path` 越界抛 `invalid_param`
* 页面不在 `/toy/<slug>/` 路径下时抛 `unsupported`。
*/
share(req: ShareReq): Promise<void>
/**
* 生成指向当前 Toy 内某个页面的二维码,返回可直接用作 `img.src` 的 PNG base64 图片。
* **App 端和 Web 端都可用**。
*
* 用途不限于分享:跨设备接力(PC 上扫码到手机继续玩)、结算页海报、线下展示都适用。
* 区别于 `share`,本方法不拉起分享面板,而是把链接编码成二维码交给 Toy 自行展示。
* 两个入参都可省略:`toy.getQrCode()` 即当前 Toy 首页的二维码。
*
* 只能编码当前 Toy 内的页面链接,不能编码任意文本:二维码内容同样由平台生成,
* Toy 不能自行指定完整 URL。如需为任意字符串生成二维码,请自行在 Toy 内
* 打包二维码库。`path` 越界或 `size` 非法抛 `invalid_param`
* 页面不在 `/toy/<slug>/` 路径下时抛 `unsupported`。
*/
getQrCode(req?: QrCodeReq): Promise<QrCodeResp>
/** 关闭当前 WebView 容器。**仅 B站 App 内可用**,Web 端调用直接抛错。 */
closeBrowser(): Promise<void>
/**
* 获取当前登录用户的头像、昵称与当前 Toy 内的稳定假名标识。
*
* OpenID 模式启用后,已有未过期的 profile v1/v2 授权会直接复用,不重复弹窗;没有有效授权时,首次调用需由用户手势触发,并由平台展示“获取你的昵称、头像和当前 Toy 内用户标识”固定的用户数据确认弹窗(Toy 不能自定义弹窗内容),接受后写入 v2 授权。模式关闭时省略 toyOpenId。
* 用户拒绝、未登录或在外部手机浏览器中调用时 Promise reject(外部浏览器会先引导打开 B站 App)。用户资料确认统一使用正文“你的 B站昵称、头像和仅用于当前 Toy 的用户标识,将用于当前 Toy 内展示和关联数据;不会向 Toy 提供你的 UID,也不能用于跨 Toy 识别。”,不区分 v1/v2 文案配置。`toyOpenId` 仅用于当前 Toy 内关联用户,不得写入埋点或公开日志。
*/
getUserProfile(): Promise<UserProfileResp>
/**
* 获取当前 Toy 作者的公开资料、账号统计、稿件数、充电聚合与粉丝勋章配置。
* 不能指定作者,固定取当前 Toy 的作者。
*/
getAuthorProfile(): Promise<AuthorProfileResp>
/**
* 批量获取当前 Toy 作者的视频公开信息,含作者以联合投稿(共同创作)身份参与的视频。
* 作者未参与创作、或对当前用户不可见的视频,对应 item 只返回 `status`,不含 `data`。
*/
getAuthorVideos(req: AuthorVideosReq): Promise<AuthorVideosResp>
/**
* 获取当前访问用户与当前 Toy 作者的关注、老粉、粉丝勋章状态,
* 以及当前是否正在对该作者进行包月充电。
*
* 只校验登录态,不触发用户数据确认弹窗。外部手机浏览器返回 `status: 'unsupported'`
* 并引导打开 B站 App。
*/
getAuthorRelation(): Promise<AuthorRelationResp>
/**
* 获取当前访问用户对当前作者视频(含作者以联合投稿身份参与的视频)的点赞、投币、收藏状态。
*
* 只校验登录态,不触发用户数据确认弹窗。外部手机浏览器返回
* `status: 'unsupported'` 且 `items` 为空数组。
*/
getVideoUserActions(req: VideoUserActionsReq): Promise<VideoUserActionsResp>
/**
* 读取云存储。不传或传空数组读取当前用户在该 Toy 下的全部数据;
* 未命中的 key 不出现在结果中。
*
* 需用户已登录,按「登录用户 + Toy」双维度隔离,不触发用户数据确认。
* key 不满足 `[a-zA-Z0-9_-]{1,128}` 时本地抛错。
*/
getCloudStorage(keys?: string[]): Promise<Record<string, string>>
/**
* 批量写入云存储(upsert),同 key 覆盖旧值。
*
* `items` 必须是普通对象(传数组 / 字符串 / null 会本地抛错)。
* key 只能含字母、数字、下划线、短横线且 ≤128 字节;value 为字符串,
* 字节上限由服务端校验(存对象请自行 `JSON.stringify`)。
* 单个 Toy 的 key 数量上限由服务端拦截。
*/
setCloudStorage(items: Record<string, string>): Promise<void>
/** 批量删除云存储中指定的 key。key 格式非法时本地抛错。 */
removeCloudStorage(keys: string[]): Promise<void>
/**
* 上报分数到排行榜。需用户已登录,首次提交前由平台完成用户数据确认。
* 按「toy + 榜位 + 周期」隔离,同榜位只保留历史最高分,本次更低不覆盖。
*/
submitScore(req: SubmitScoreReq): Promise<SubmitScoreResp>
/**
* 读取榜单,游客可读。返回前 `limit` 名,固定从高到低;
* 同分时先达成者靠前,名次唯一、不并列。
*/
getRankList(req?: RankListReq): Promise<RankItem[]>
/** 查询我在指定榜单的排名。需用户已登录;是否上榜必须用 `ranked` 判断。 */
getMyRank(req?: MyRankReq): Promise<MyRankResp>
/**
* 申请摄像头,返回浏览器原生的实时 `MediaStream`(视频轨),可直接赋给
* `<video>` 的 `srcObject` 渲染,或交给 canvas / WebGL 逐帧处理。
*
* **必须由用户点击等手势事件直接触发**:SDK 会检查 `navigator.userActivation`
* 无有效用户激活时抛错。首次申请由平台展示摄像头业务授权弹窗,同意后按
* 「登录用户 + Toy + 设备」记录,后续不重复展示;随后仍会由 App 或浏览器
* 展示系统权限框。
*
* `options` 只接受 `facingMode`,多传字段或传非普通对象会本地抛错。
* 用户拒绝业务授权、系统拒绝、设备缺失或被占用时 Promise reject
* `error.name` 为 `BusinessDenied` / `NotAllowedError` / `NotFoundError` /
* `NotReadableError` / `AbortError` 等标准错误名。
*
* 使用结束后必须调用 `toy.stopMedia(stream)` 释放设备,否则摄像头保持占用。
*/
requestCamera(options?: MediaRelayOptions): Promise<MediaStream>
/**
* 申请麦克风,返回浏览器原生的**实时** `MediaStream`(音频轨)。
*
* 注意返回值的性质:它是可持续读取的实时音频流,**不是录音文件**
* (没有 Blob / 本地路径),**也不是音量数值**。要得到录音文件或音量,
* 用浏览器原生 API 在这个流上自行处理,SDK 不提供这两项能力:
* - **录音**:用原生 `MediaRecorder` 接收本流并收集数据,自行编码成文件。
* - **获取声音大小**:用原生 `AudioContext` 创建 `AnalyserNode`,把本流接入后
* 读取频域 / 时域数据自行换算音量。
*
* **必须由用户点击等手势事件直接触发**:SDK 会检查 `navigator.userActivation`
* 无有效用户激活时抛错。首次申请由平台展示麦克风业务授权弹窗(与摄像头分别
* 独立授权),同意后按「登录用户 + Toy + 设备」记录;随后仍会由 App 或浏览器
* 展示系统权限框。
*
* 不接受任何参数,传参会本地抛错。被拒 / 采集失败时的 `error.name` 同
* `requestCamera`。使用结束后必须调用 `toy.stopMedia(stream)` 释放设备。
*/
requestMicrophone(): Promise<MediaStream>
/**
* 释放摄像头 / 麦克风:停止该流对应的会话,并关闭采集设备
* (摄像头指示灯熄灭)。
*
* 参数必须是 `requestCamera` / `requestMicrophone` 返回的那个 `MediaStream`
* 实例(SDK 以流对象为句柄定位会话),传入其他值本地抛错;传入已释放或非本
* SDK 产生的流不会报错,直接静默返回。
*
* 摄像头与麦克风各自的流需要分别调用释放。
*/
stopMedia(stream: MediaStream): Promise<void>
}
}
// 全局声明。本文件是 ambient 声明文件(没有顶层 import / export),
// 放进项目后 TypeScript 自动加载,`window.toy` 与 `ToySDK.*` 均可直接使用。
interface Window {
/** Toy JS SDK 实例,由 toy-sdk.js 加载后挂载到全局。 */
toy: ToySDK.Toy
}
/** Toy JS SDK 实例(等价于 `window.toy`)。 */
declare const toy: ToySDK.Toy