diff --git a/docs/loading.md b/docs/loading.md new file mode 100644 index 0000000..c2a5828 --- /dev/null +++ b/docs/loading.md @@ -0,0 +1,23 @@ +# Resource loading and playback + +Playback becomes available only after the engine, packaged textures, every hit +sound, the chart, and all supplied music, timing configuration and cover sources +have finished downloading and decoding. Omitting optional sources still supports +silent charts and the stock stage. A supplied resource that fails keeps playback +disabled and displays an error; source failures can be retried in the player. +Engine or bundled-asset failures require remounting the component or reloading the page. + +Changing a source pauses playback immediately. A retired download cannot publish +`loaded` or trigger autoplay for a newer selection. Buttons, canvas clicks, +keyboard shortcuts, exposed `play`/`toggle`/`seek` methods and autoplay share the +same readiness check. Cancelling a play request while the browser resumes its +AudioContext also prevents it from starting later. + +Hosts fetching Blob sources themselves must pass `:pending="loading"` until their +entire source set is available. Set it before replacing the sources. Without this +prop the player cannot distinguish an omitted optional source from one that the +host has not yet provided. `ready` still means engine initialization; +`loaded` is emitted only after the current complete source set is ready. + +Run `npm test` for the loading, transport and native preview regressions, and +`npm run build:lib` to rebuild the distributable Vue component. diff --git a/docs/refresh-preview.md b/docs/refresh-preview.md new file mode 100644 index 0000000..71eaa9e --- /dev/null +++ b/docs/refresh-preview.md @@ -0,0 +1,24 @@ +# Refresh chart preview + +The viewer uses the `wds-editor` Refresh core pinned by `third_party/wds-editor` +(commit `6fc30e2`). It automatically recognizes extended CSV geometry without a +manual profile switch. Notes may overhang either side of the twelve-lane stage +or be wider than the stage, but must still overlap a playable lane. + +The same core supports positive SV rows (`type=-1`), nontail holds +(`1100/1101/1110/1111`), authored `900` judgments, native `.wdschart` v6 and the +editor's extended SUS metadata. The browser adapter passes each loaded chart's +SV timeline to stage geometry as well as the core snapshot builder. Loading an +ordinary chart clears the preceding chart's SV timeline. SV changes visual +scrolling, not music or judgment times; nontail holds do not acquire artificial +tail judgments or generated eighths. + +Build and exercise the actual browser WASM and packaged skins: + +```sh +npm run build:lib +npm run test:wasm +``` + +`SIRIUS_VIEWER_WASM_DIR` can point the tests at a host application's copied +`wasm/` directory, verifying the deployed integration uses the same core. diff --git a/native/src/viewer.cpp b/native/src/viewer.cpp index 60561f6..3861757 100644 --- a/native/src/viewer.cpp +++ b/native/src/viewer.cpp @@ -227,6 +227,11 @@ EMSCRIPTEN_KEEPALIVE int wv_load_chart(const char* chart_path, const char* music return 0; } v.last_error.clear(); + // ChartPreviewPanel wires the document's visual SV timeline into stage geometry. + // The snapshot builder already reads the profile and SVs from the new core; + // ribbons/arrows also need this timeline, including clearing it on chart changes. + v.preview.geometry().set_scroll_speed_points(v.engine.document().scroll_speed_points(), + v.engine.document().timing()); v.engine.seek(0); return 1; } diff --git a/package.json b/package.json index bddf5ed..510ca6e 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,15 @@ "./assets/*": "./dist-lib/assets/*" }, "scripts": { + "test:wasm": "node --test tests/wasm-preview.test.mjs", "sync:assets": "node ./scripts/sync-assets.mjs", "build:wasm": "node ./scripts/build-wasm.mjs", "dev": "npm run sync:assets && npm run build:wasm && vite", "build": "npm run sync:assets && npm run build:wasm && vue-tsc --noEmit && vite build", "typecheck": "vue-tsc --noEmit", "preview": "vite preview", - "build:lib": "npm run sync:assets && npm run build:wasm && node ./scripts/build-lib.mjs" + "build:lib": "npm run sync:assets && npm run build:wasm && node ./scripts/build-lib.mjs", + "test": "node --test tests/*.test.mjs" }, "peerDependencies": { "vue": "^3.4.0" diff --git a/src/components/SiriusChartViewer.vue b/src/components/SiriusChartViewer.vue index 7cfa6c6..2b52696 100644 --- a/src/components/SiriusChartViewer.vue +++ b/src/components/SiriusChartViewer.vue @@ -21,6 +21,8 @@ const props = withDefaults(defineProps<{ assetBase?: string /** Start playing as soon as everything is loaded (subject to browser autoplay rules). */ autoplay?: boolean + /** Host is still fetching sources; all playback stays blocked until false. */ + pending?: boolean /** fetch() options for URL sources (e.g. auth headers). */ fetchInit?: RequestInit }>(), { @@ -31,6 +33,7 @@ const props = withDefaults(defineProps<{ cover: null, assetBase: '/sirius-chart-viewer/', autoplay: false, + pending: false, fetchInit: undefined, }) @@ -62,6 +65,7 @@ let uiRaf = 0 let disposed = false const hasChart = computed(() => !!chartInfo.value) +const canPlay = computed(() => engineReady.value && hasChart.value && !loading.value && !props.pending && !error.value && !disposed) function formatTime(sec: number) { const sign = sec < 0 ? '-' : '' @@ -97,85 +101,69 @@ function fail(e: unknown) { emit('error', error.value) } -async function loadMusic() { - const v = viewer.value - if (!v) return - const bytes = props.music ? await readSource(props.music, props.fetchInit) : null - await v.loadMusic(bytes) -} - -async function loadChart() { - const v = viewer.value - if (!v) return - if (!props.chart) { - chartInfo.value = undefined - return - } - const [data, musicConfig] = await Promise.all([ - readSource(props.chart, props.fetchInit), - props.musicConfig ? readSource(props.musicConfig, props.fetchInit) : Promise.resolve(undefined), - ]) - const name = props.chartName || sourceName(props.chart) || 'chart.csv' - chartInfo.value = v.loadChart({ name, data, musicConfig }) - emit('loaded', chartInfo.value) -} - -async function loadCover() { - const v = viewer.value - if (!v) return - try { - await v.setCover(props.cover ? await readSource(props.cover, props.fetchInit) : null) - } catch (e) { - // A broken jacket must not block playback; keep the stock screens. - console.warn('[SiriusChartViewer] cover:', e) - } -} - -// Source changes are coalesced: a change arriving mid-load is picked up by the -// running loop (music before chart so the timeline knows the BGM length). -const dirty = { music: false, chart: false, cover: false } +// Every generation is a complete source set. A slow retired load can never +// unlock playback or publish a loaded event for a newer selection. +let generation = 0 let running = false +let request: AbortController | null = null -function reload(parts: Partial) { - dirty.music ||= !!parts.music - dirty.chart ||= !!parts.chart - dirty.cover ||= !!parts.cover - if (running || !viewer.value) return - running = true - void runReload().finally(() => (running = false)) +function reload() { + generation++ + request?.abort() + viewer.value?.transport.pause() + playing.value = false + loading.value = true + error.value = '' + if (!viewer.value || running || props.pending || disposed) return + void runReload() } async function runReload() { - loading.value = true - error.value = '' + running = true try { - while ((dirty.music || dirty.chart || dirty.cover) && !disposed) { - if (dirty.music) { - dirty.music = false - await loadMusic() - } else if (dirty.cover) { - dirty.cover = false - await loadCover() - } else { - dirty.chart = false - await loadChart() + while (!disposed && !props.pending) { + const version = generation + const v = viewer.value! + const controller = new AbortController() + request = controller + const current = () => !disposed && version === generation && !props.pending + const init = { ...props.fetchInit, signal: props.fetchInit?.signal + ? AbortSignal.any([controller.signal, props.fetchInit.signal]) : controller.signal } + const chartSource = props.chart + const name = props.chartName || sourceName(chartSource) || 'chart.csv' + try { + const [data, config, audio, jacket] = await Promise.all([ + chartSource ? readSource(chartSource, init) : null, + props.musicConfig ? readSource(props.musicConfig, init) : undefined, + props.music ? readSource(props.music, init) : null, + props.cover ? readSource(props.cover, init) : null, + ]) + if (!current()) continue + await v.loadMusic(audio) + if (!current()) continue + await v.setCover(jacket) + if (!current()) continue + chartInfo.value = data ? v.loadChart({ name, data, musicConfig: config }) : undefined + loading.value = false + if (chartInfo.value) emit('loaded', chartInfo.value) + if (current() && props.autoplay) await play() + } catch (e) { + if (current()) { loading.value = false; fail(e) } } + if (current()) break } - if (props.autoplay && hasChart.value && !disposed) await play() - } catch (e) { - if (!disposed) fail(e) } finally { - loading.value = false + running = false + request = null } } -watch(() => [props.chart, props.chartName, props.musicConfig], () => reload({ chart: true })) -watch(() => props.music, () => reload({ music: true })) -watch(() => props.cover, () => reload({ cover: true })) +watch(() => [props.chart, props.chartName, props.musicConfig, props.music, props.cover, props.pending, props.fetchInit], + reload, { flush: 'sync' }) async function play() { const v = viewer.value - if (!v || !hasChart.value) return + if (!v || !canPlay.value) return try { await v.transport.play() } catch (e) { @@ -193,7 +181,7 @@ function toggle() { } function seek(t: number) { - viewer.value?.transport.seek(t) + if (canPlay.value) viewer.value?.transport.seek(t) } function onSeekInput(e: Event) { @@ -244,10 +232,10 @@ let instance: ChartViewer | null = null onMounted(async () => { uiRaf = requestAnimationFrame(syncUi) - const v = new ChartViewer(canvas.value!) - instance = v - v.transport.onEnded = () => emit('ended') try { + const v = new ChartViewer(canvas.value!) + instance = v + v.transport.onEnded = () => emit('ended') await v.init(props.assetBase) if (disposed) return viewer.value = v @@ -255,7 +243,7 @@ onMounted(async () => { engineReady.value = true emit('ready') loading.value = false - reload({ music: !!props.music, chart: !!props.chart, cover: !!props.cover }) + reload() } catch (e) { loading.value = false if (!disposed) fail(e) @@ -264,6 +252,8 @@ onMounted(async () => { onBeforeUnmount(() => { disposed = true + generation++ + request?.abort() cancelAnimationFrame(uiRaf) instance?.dispose() instance = null @@ -278,28 +268,28 @@ defineExpose({ play, pause, toggle, seek, viewer })
-
+
- {{ engineReady ? '正在加载谱面…' : '正在加载预览引擎…' }} + {{ engineReady ? '正在加载谱面、音乐与画面资源…' : '正在加载预览引擎…' }}
没有谱面
- +