diff --git a/apps/web/package.json b/apps/web/package.json index a304f9e..1b9009e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "dev": "bunx --bun vite", "dev:prod": "bunx --bun vite --mode production", "build": "bunx --bun vite build", + "build:bili": "bunx --bun vite build --config vite.config.bili.ts", "preview": "bunx --bun vite preview", "lint": "bunx --bun eslint", "test": "bunx --bun vitest" @@ -15,6 +16,7 @@ "@remake/data": "workspace:*", "@remake/hooks": "workspace:*", "@remake/vitex": "workspace:*", + "@remake/thirdparty-bili-toy": "workspace:*", "jotai": "^2.20.2", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/apps/web/src/hooks/storage.ts b/apps/web/src/hooks/storage.ts index b31140f..fe25792 100644 --- a/apps/web/src/hooks/storage.ts +++ b/apps/web/src/hooks/storage.ts @@ -2,7 +2,7 @@ import { useEffect, useCallback, useTransition } from 'react' import { atom, useAtom } from 'jotai' import { useConfigInject, useProfileInject, useRawProfile } from '@remake/hooks' import { useUniqueInject, useUnique } from '@remake/hooks' -import { get, set } from '@/storage' +import { init, get, set } from '@/storage' import { config } from '@/config' const initedAtom = atom(false) @@ -15,7 +15,8 @@ export const useInit = () => { const loader = useCallback(async () => { if (inited) return configInject(config) - const { profile, unique } = await get(['profile', 'unique']) + await init() + const profile = await get('profile') const parsed = profile ? JSON.parse(profile) || {} : {} profileInject({ ...parsed, @@ -24,6 +25,7 @@ export const useInit = () => { events: new Set(parsed.events || []), talents: new Set(parsed.talents || []), }) + const unique = await get('unique') if (unique) uniqueInject(JSON.parse(unique)) setInited(true) }, [inited, configInject, profileInject, uniqueInject, setInited]) @@ -46,14 +48,14 @@ export const useWatcher = () => { talents: Array.from(profile.talents), }) saveProfile(async () => { - await set({ profile: str }) + await set('profile', str) }) }, [inited, profile, saveProfile]) useEffect(() => { if (!inited || !unique) return const str = JSON.stringify(unique) saveUnique(async () => { - await set({ unique: str }) + await set('unique', str) }) }, [inited, unique, saveUnique]) diff --git a/apps/web/src/storage/bili-toy.spec.ts b/apps/web/src/storage/bili-toy.spec.ts new file mode 100644 index 0000000..c1d83a1 --- /dev/null +++ b/apps/web/src/storage/bili-toy.spec.ts @@ -0,0 +1,15 @@ +import { test, expect, describe } from 'vitest' +import { talents, achievements, events } from '@remake/data' + +describe('Bili Toy Cloud Save Core', () => { + test('maxSizeProfile should pass ToyCloudSaveCore constraints', () => { + const json = JSON.stringify({ + times: 999999, + locked: Array.from(talents.keys()), + talents: Array.from(talents.keys()), + achievements: Array.from(achievements.keys()), + events: Array.from(events.keys()), + }) + expect(json.length).toBeLessThan((48 * 960 * 3) / 4) + }) +}) diff --git a/apps/web/src/storage/index.ts b/apps/web/src/storage/index.ts new file mode 100644 index 0000000..be14820 --- /dev/null +++ b/apps/web/src/storage/index.ts @@ -0,0 +1,23 @@ +import * as biliToy from '@remake/thirdparty-bili-toy' +import * as local from './local' + +interface Storage { + init(): Promise + get(key: string): Promise + set(key: string, value: string): Promise +} +const storage = {} as Storage + +if (import.meta.env.VITE_CHANNEL === 'bili') { + storage.init = biliToy.init + storage.get = biliToy.get + storage.set = biliToy.set +} else { + storage.init = async () => {} + storage.get = local.get + storage.set = local.set +} + +export const init = storage.init +export const get = storage.get +export const set = storage.set diff --git a/apps/web/src/storage.ts b/apps/web/src/storage/local.ts similarity index 79% rename from apps/web/src/storage.ts rename to apps/web/src/storage/local.ts index 6f1ed08..9b1ba26 100644 --- a/apps/web/src/storage.ts +++ b/apps/web/src/storage/local.ts @@ -28,15 +28,12 @@ if (localStorage.getItem('version') !== '3.0.0') { } localStorage.setItem('version', '3.0.0') } -export async function get(keys: Key[]) { - return Object.fromEntries(keys.map(k => [k, localStorage.getItem(k)])) as { - [K in Key]: string | null - } + +export async function get(key: string) { + return localStorage.getItem(key) } -export async function set(data: Record) { - for (const key in data) { - localStorage.setItem(key, data[key]) - } +export async function set(key: string, value: string) { + localStorage.setItem(key, value) return true } diff --git a/apps/web/src/styles/common.css b/apps/web/src/styles/common.css index b654389..90992fe 100644 --- a/apps/web/src/styles/common.css +++ b/apps/web/src/styles/common.css @@ -115,6 +115,15 @@ input { &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } } +input[type="number"] { + -moz-appearance: textfield; + appearance: textfield; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; /* 移除某些浏览器下的默认留白 */ + } +} @media (max-width: 32rem) { :root { font-size: 2.88vw; } } @media screen and (orientation: portrait) and (pointer: coarse) { diff --git a/apps/web/vite.config.bili.ts b/apps/web/vite.config.bili.ts new file mode 100644 index 0000000..e802d55 --- /dev/null +++ b/apps/web/vite.config.bili.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +const dataSplitRule = { + test: /[\\/]packages[\\/]data[\\/]|@remake\/data/, + priority: 40, + name(id: string) { + const name = id.split(/[\/\\]/).pop()! + const baseName = name.substring(0, name.lastIndexOf('.')) + if (baseName && baseName !== 'index') return `data-${baseName}` + return null + }, +} + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { tsconfigPaths: true }, + define: { 'import.meta.env.VITE_CHANNEL': '"bili"' }, + base: './', + build: { + outDir: 'dist/bili/remake', + chunkSizeWarningLimit: 1500, + rolldownOptions: { + output: { + codeSplitting: { + minSize: 1024, + groups: [dataSplitRule], + }, + }, + }, + }, +}) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cbfa2f2..c0216aa 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,6 +16,7 @@ const dataSplitRule = { export default defineConfig({ plugins: [react()], resolve: { tsconfigPaths: true }, + base: './', build: { chunkSizeWarningLimit: 1500, rolldownOptions: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ff9c38..94fad6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@remake/hooks': specifier: workspace:* version: link:../../packages/hooks + '@remake/thirdparty-bili-toy': + specifier: workspace:* + version: link:../../thirdparty/bili-toy '@remake/vitex': specifier: workspace:* version: link:../../packages/vitex diff --git a/thirdparty/bili-toy/index.ts b/thirdparty/bili-toy/index.ts index 4c39c19..2bd5aed 100644 --- a/thirdparty/bili-toy/index.ts +++ b/thirdparty/bili-toy/index.ts @@ -1,166 +1 @@ -const SDK = '//s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js' -const CHUNK_SIZE = 1024 -const PRELOAD_CHUNKS_LIMIT = 20 - -let inited = false - -export async function loadSDK() { - const script = document.createElement('script') - script.src = SDK - script.async = true - document.body.appendChild(script) - return new Promise((resolve, reject) => { - script.onload = () => { - resolve(window.toy) - } - script.onerror = () => { - reject(new Error('Failed to load SDK')) - } - }) -} - -export async function init() { - try { - await loadSDK() - inited = true - } catch (error) { - console.error('Failed to load Bili Toy SDK:', error) - return - } - - await Promise.all([ - checkAbility('getCloudStorage'), - checkAbility('setCloudStorage'), - checkAbility('removeCloudStorage'), - ]) -} - -async function checkAbility(ability: toy.Ability) { - if (!(await toy.isSupport(ability))) - throw new Error( - `Bili Toy SDK does not support ${ability}. Please check the SDK version.`, - ) -} - -interface ChunkMeta { - total: number - size: number -} - -function splitIntoChunks( - str: string, - chunkSize: number = CHUNK_SIZE, -): string[] { - const base64Str = new TextEncoder().encode(str).toBase64() - const chunks: string[] = [] - let offset = 0 - while (offset < base64Str.length) { - chunks.push(base64Str.substring(offset, offset + chunkSize)) - offset += chunkSize - } - return chunks -} - -function mergeChunks(chunks: string[]): string { - const combinedBase64 = chunks.join('') - return new TextDecoder().decode(Uint8Array.from(combinedBase64)) -} - -export async function getCloudStore( - keys: [...K], -): Promise> { - if (!inited) - throw new Error('Bili Toy SDK not initialized. Call init() first.') - if (!keys.length) return {} as any - const firstBatchKeys: string[] = [] - keys.forEach(key => { - firstBatchKeys.push(`${key}.meta`) - for (let i = 0; i < PRELOAD_CHUNKS_LIMIT; i++) { - firstBatchKeys.push(`${key}.chunk.${i}`) - } - }) - const rawKV = await toy.getCloudStorage(firstBatchKeys) - const result = {} as Record - await Promise.all( - keys.map(async key => { - const metaStr = rawKV[`${key}.meta`] - if (!metaStr) { - result[key as K[number]] = undefined - return - } - - const meta: ChunkMeta = JSON.parse(metaStr) - const collectedChunks: string[] = [] - for (let i = 0; i < meta.total; i++) { - const chunkVal = rawKV[`${key}.chunk.${i}`] - if (chunkVal !== undefined && chunkVal !== null) { - collectedChunks[i] = chunkVal - } - } - if (meta.total > PRELOAD_CHUNKS_LIMIT) { - const missingChunkKeys: string[] = [] - for (let i = PRELOAD_CHUNKS_LIMIT; i < meta.total; i++) { - missingChunkKeys.push(`${key}.chunk.${i}`) - } - const secondaryKV = await toy.getCloudStorage(missingChunkKeys) - for (let i = PRELOAD_CHUNKS_LIMIT; i < meta.total; i++) { - collectedChunks[i] = secondaryKV[`${key}.chunk.${i}`]! - } - } - if (collectedChunks.filter(Boolean).length !== meta.total) { - console.warn( - `⚠️ [vTransform Storage] 检测到数据键 [${key}] 的云端切片发生残缺丢失,已自动放弃合并。`, - ) - result[key as K[number]] = undefined - return - } - result[key as K[number]] = mergeChunks(collectedChunks) - }), - ) - return result -} - -export async function setCloudStore( - items: Record, -): Promise { - if (!inited) - throw new Error('Bili Toy SDK not initialized. Call init() first.') - const payloadKV: Record = {} - for (const key in items) { - const originalValue = items[key] - if (originalValue === null || originalValue === undefined) continue - const chunkList = splitIntoChunks(originalValue, CHUNK_SIZE) - const meta: ChunkMeta = { - total: chunkList.length, - size: originalValue.length, - } - payloadKV[`${key}.meta`] = JSON.stringify(meta) - chunkList.forEach((chunkContent, index) => { - payloadKV[`${key}.chunk.${index}`] = chunkContent - }) - } - return await toy.setCloudStorage(payloadKV) -} - -export async function removeCloudStore(keys: string[]): Promise { - if (!inited) - throw new Error('Bili Toy SDK not initialized. Call init() first.') - if (!keys.length) return - - const metaKeys = keys.map(k => `${k}.meta`) - const rawMetaKV = await toy.getCloudStorage(metaKeys) - const deleteKeysList: string[] = [] - - keys.forEach(key => { - deleteKeysList.push(`${key}.meta`) - const metaStr = rawMetaKV[`${key}.meta`] - - if (metaStr) { - const meta: ChunkMeta = JSON.parse(metaStr) - for (let i = 0; i < meta.total; i++) { - deleteKeysList.push(`${key}.chunk.${i}`) - } - } - }) - return await toy.removeCloudStorage(deleteKeysList) -} +export * from './wrapper' diff --git a/thirdparty/bili-toy/lib/cloud-save-core.d.ts b/thirdparty/bili-toy/lib/cloud-save-core.d.ts new file mode 100644 index 0000000..af0550d --- /dev/null +++ b/thirdparty/bili-toy/lib/cloud-save-core.d.ts @@ -0,0 +1,172 @@ +/** + * 哔哩哔哩官方 ToyCloudSaveCore 核心分片存储算法组件环境类型声明文件 (Ambient Declaration) + * + * 说明:本文件专为官方无类型的 ToyCloudSaveCore.js 设计。 + * 放入项目后,TypeScript 会自动在全局识别该组件,红线报错将彻底消除。 + */ + +declare namespace ToyCloudSaveCoreSDK { + /** 外部必须提供给该核心组件的底层持久化存储驱动接口契约 */ + interface IToyStorageDriver { + /** 异步获取指定键值对记录 */ + get(keys: string[]): Promise | null | undefined> + /** 异步设置并持久化键值对记录,附带当前的存储上下文 */ + set( + record: Record, + context: IToyStorageContext, + ): Promise + } + + /** 存储操作的阶段上下文 */ + 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, + ) => void | Promise + + /** 创建云存储实例所需的入参配置项 */ + 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 + /** 读取云存档 */ + load(): Promise + /** 导出内存调试快照 */ + 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 diff --git a/thirdparty/bili-toy/lib/cloud-save-core.js b/thirdparty/bili-toy/lib/cloud-save-core.js new file mode 100644 index 0000000..1edafaa --- /dev/null +++ b/thirdparty/bili-toy/lib/cloud-save-core.js @@ -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, + }); +})); diff --git a/thirdparty/bili-toy/lib/example.html b/thirdparty/bili-toy/lib/example.html new file mode 100644 index 0000000..68cf603 --- /dev/null +++ b/thirdparty/bili-toy/lib/example.html @@ -0,0 +1,37 @@ + + + + + + Toy 分片云存档接入示例 + + + +
+

Toy 分片云存档接入示例

+

示例始终先保存本地档;Toy SDK 可用且玩家已登录时,再提交云端。

+ + +
+ + + +
+

正在初始化…

+
+ + + + + + + diff --git a/thirdparty/bili-toy/lib/example.js b/thirdparty/bili-toy/lib/example.js new file mode 100644 index 0000000..667bfdd --- /dev/null +++ b/thirdparty/bili-toy/lib/example.js @@ -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); + } + }); + }); +})(); diff --git a/thirdparty/bili-toy/toy.d.ts b/thirdparty/bili-toy/lib/toy.d.ts similarity index 67% rename from thirdparty/bili-toy/toy.d.ts rename to thirdparty/bili-toy/lib/toy.d.ts index 057097b..be15f4d 100644 --- a/thirdparty/bili-toy/toy.d.ts +++ b/thirdparty/bili-toy/lib/toy.d.ts @@ -59,9 +59,38 @@ declare namespace ToySDK { localPath: string } - interface ReportActionReq { - /** UP 主自定义的动作标识。 */ - userEventId: string + // --------------------------------------------------------------------------- + // 分享与二维码 + // --------------------------------------------------------------------------- + + interface ShareReq { + /** + * 相对当前 Toy 页面根(`/toy//`)的路径,可带 query,如 `result.html?score=100`。 + * + * 完整分享链接由平台生成,Toy 不能自行指定完整 URL。路径只能落在当前 Toy 内: + * 传绝对 URL 或用 `../` 越界到其他 Toy / 外域会抛 `invalid_param`。 + */ + path: string + } + + interface QrCodeReq { + /** + * 相对当前 Toy 页面根(`/toy//`)的路径,可带 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 } // --------------------------------------------------------------------------- @@ -73,6 +102,8 @@ declare namespace ToySDK { avatar: string /** 昵称。 */ nickname: string + /** 当前登录用户在当前 Toy 内的稳定假名标识,不是鉴权凭证;功能未启用时可能不返回。 */ + toyOpenId?: string } // --------------------------------------------------------------------------- @@ -87,7 +118,7 @@ declare namespace ToySDK { * - `denied`: 用户拒绝了数据使用确认 * - `unsupported`: 当前环境不支持(如外部手机浏览器,SDK 会引导打开 B站 App) * - `toy_context_unavailable`: 拿不到当前 toy 上下文 - * - `author_mismatch`: 请求的资源不属于当前 Toy 作者 + * - `author_mismatch`: 请求的资源不属于当前 Toy 作者(视频既非其投稿,也未以联合投稿身份参与创作) * - `video_not_found`: 视频不存在 * - `video_invisible`: 视频对当前用户不可见 * - `unavailable`: 依赖服务不可用 @@ -235,7 +266,7 @@ declare namespace ToySDK { /** 回显本次请求传入的引用,用于与请求项对应。 */ ref: AuthorVideoRef status: ToyItemStatus - /** `status` 非 `ok` 时缺省(如非当前作者、视频不可见)。 */ + /** `status` 非 `ok` 时缺省(如作者未参与该视频创作、视频不可见)。 */ data?: AuthorVideo } @@ -359,11 +390,17 @@ declare namespace ToySDK { } // --------------------------------------------------------------------------- - // 媒体能力 + // 媒体能力(摄像头 / 麦克风) // --------------------------------------------------------------------------- + /** + * 申请摄像头的可选项,仅 `requestCamera` 使用。 + * + * 只接受 `facingMode` 一个字段:传入其他字段、或传非普通对象(数组 / 字符串 / null 等) + * 时 SDK 本地抛错。`requestMicrophone` 不接受任何参数。 + */ interface MediaRelayOptions { - /** 摄像头朝向,不传默认使用前置摄像头。 */ + /** 摄像头朝向:`'user'` 前置、`'environment'` 后置。不传默认前置。 */ facingMode?: 'user' | 'environment' } @@ -376,7 +413,8 @@ declare namespace ToySDK { * 判断当前环境是否支持指定能力。传能力名(如 `'saveImageToAlbum'`)即可, * 带不带 `toy.` 前缀都能匹配。 * - * 端外 Web 不支持 `saveImageToAlbum` / `closeBrowser`,其余能力两端一致。 + * 端外 Web 不支持 `saveImageToAlbum` / `share` / `closeBrowser`,其余能力两端一致 + * (二维码 `getQrCode` 两端均可用)。 */ isSupport(ability: string): Promise @@ -384,7 +422,7 @@ declare namespace ToySDK { * 跳转到指定页面。 * * 必须在用户手势事件(如 click)中调用:SDK 会检查 `navigator.userActivation`, - * 无有效用户激活时抛错。端内走 JSB 原生跳转,端外用 `window.open` 新开标签页。 + * 无有效用户激活时抛错。端内为原生跳转,端外新开标签页。 */ navigate(req: NavigateReq): Promise @@ -395,20 +433,41 @@ declare namespace ToySDK { */ saveImageToAlbum(req: SaveImageReq): Promise + /** + * 拉起 B站 App 的分享面板。**仅 B站 App 内可用**,Web 端调用直接抛错。 + * + * 只传相对当前 Toy 的 `path`,完整分享链接由平台生成, + * 避免分享链接被伪造指向其他 Toy 或外部站点。`path` 越界抛 `invalid_param`, + * 页面不在 `/toy//` 路径下时抛 `unsupported`。 + */ + share(req: ShareReq): Promise + + /** + * 生成指向当前 Toy 内某个页面的二维码,返回可直接用作 `img.src` 的 PNG base64 图片。 + * **App 端和 Web 端都可用**。 + * + * 用途不限于分享:跨设备接力(PC 上扫码到手机继续玩)、结算页海报、线下展示都适用。 + * 区别于 `share`,本方法不拉起分享面板,而是把链接编码成二维码交给 Toy 自行展示。 + * 两个入参都可省略:`toy.getQrCode()` 即当前 Toy 首页的二维码。 + * + * 只能编码当前 Toy 内的页面链接,不能编码任意文本:二维码内容同样由平台生成, + * Toy 不能自行指定完整 URL。如需为任意字符串生成二维码,请自行在 Toy 内 + * 打包二维码库。`path` 越界或 `size` 非法抛 `invalid_param`, + * 页面不在 `/toy//` 路径下时抛 `unsupported`。 + */ + getQrCode(req?: QrCodeReq): Promise + /** 关闭当前 WebView 容器。**仅 B站 App 内可用**,Web 端调用直接抛错。 */ closeBrowser(): Promise /** - * 获取当前登录用户的头像与昵称。 + * 获取当前登录用户的头像、昵称与当前 Toy 内的稳定假名标识。 * - * 首次调用需由用户手势触发,并由平台展示固定的用户数据确认弹窗(Toy 不能自定义弹窗内容); - * 用户拒绝、未登录或在外部手机浏览器中调用时 Promise reject(外部浏览器会先引导打开 B站 App)。 + * OpenID 模式启用后,已有未过期的 profile v1/v2 授权会直接复用,不重复弹窗;没有有效授权时,首次调用需由用户手势触发,并由平台展示“获取你的昵称、头像和当前 Toy 内用户标识”固定的用户数据确认弹窗(Toy 不能自定义弹窗内容),接受后写入 v2 授权。模式关闭时省略 toyOpenId。 + * 用户拒绝、未登录或在外部手机浏览器中调用时 Promise reject(外部浏览器会先引导打开 B站 App)。用户资料确认统一使用正文“你的 B站昵称、头像和仅用于当前 Toy 的用户标识,将用于当前 Toy 内展示和关联数据;不会向 Toy 提供你的 UID,也不能用于跨 Toy 识别。”,不区分 v1/v2 文案配置。`toyOpenId` 仅用于当前 Toy 内关联用户,不得写入埋点或公开日志。 */ getUserProfile(): Promise - /** 上报 UP 主自定义的用户动作。 */ - reportAction(req: ReportActionReq): Promise - /** * 获取当前 Toy 作者的公开资料、账号统计、稿件数、充电聚合与粉丝勋章配置。 * 不能指定作者,固定取当前 Toy 的作者。 @@ -416,8 +475,8 @@ declare namespace ToySDK { getAuthorProfile(): Promise /** - * 批量获取当前 Toy 作者的视频公开信息。 - * 非当前作者或对当前用户不可见的视频,对应 item 只返回 `status`,不含 `data`。 + * 批量获取当前 Toy 作者的视频公开信息,含作者以联合投稿(共同创作)身份参与的视频。 + * 作者未参与创作、或对当前用户不可见的视频,对应 item 只返回 `status`,不含 `data`。 */ getAuthorVideos(req: AuthorVideosReq): Promise @@ -431,7 +490,7 @@ declare namespace ToySDK { getAuthorRelation(): Promise /** - * 获取当前访问用户对当前作者视频的点赞、投币、收藏状态。 + * 获取当前访问用户对当前作者视频(含作者以联合投稿身份参与的视频)的点赞、投币、收藏状态。 * * 只校验登录态,不触发用户数据确认弹窗。外部手机浏览器返回 * `status: 'unsupported'` 且 `items` 为空数组。 @@ -476,18 +535,53 @@ declare namespace ToySDK { getMyRank(req?: MyRankReq): Promise /** - * 申请摄像头业务授权和系统权限,并返回中继的媒体流。 - * 必须在用户手势事件中调用。 + * 申请摄像头,返回浏览器原生的实时 `MediaStream`(视频轨),可直接赋给 + * `