From d98146d76b9339221899e893765c708445585003 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 16:03:42 +0900 Subject: [PATCH 1/7] test(embed): scope the play-mode spec's Pause button to the controls bar The picture is a Pause button too, so the HUD-wide locator matched two elements and failed in strict mode. --- packages/app-play/e2e/embed.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app-play/e2e/embed.spec.ts b/packages/app-play/e2e/embed.spec.ts index cf6478b2d..99eac7bc4 100644 --- a/packages/app-play/e2e/embed.spec.ts +++ b/packages/app-play/e2e/embed.spec.ts @@ -89,7 +89,9 @@ test('?mode=play lets the viewer play: no seeking, and M is a lane rather than m await expect(hud.getByRole('button', { name: 'Unmute' })).toBeAttached(); await page.keyboard.press('KeyM'); await play.click(); - await expect(hud.getByRole('button', { name: 'Pause', exact: true })).toBeAttached({ timeout: 30_000 }); + // The picture is a Pause button too: ask the controls bar's. + const controls = hud.locator('be-music-playback-controls'); + await expect(controls.getByRole('button', { name: 'Pause', exact: true })).toBeAttached({ timeout: 30_000 }); await expect(hud.getByRole('slider', { name: 'Seek' })).toBeDisabled(); await page.keyboard.press('KeyM'); await expect(hud.getByRole('button', { name: 'Mute', exact: true })).toBeAttached(); From afcbc07d6a6efad1714ad1466cafdfe8369e299e Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 17:14:49 +0900 Subject: [PATCH 2/7] fix(worker-play): let the browser revalidate proxied archives instead of downloading them again /__url-load-proxy answered no-store and dropped the upstream's validators, so every embed ?track= (and player ?music=) load from another origin downloaded the whole archive. Move the proxy into url-load-proxy.ts, answer no-cache, and pass ETag / Last-Modified and If-None-Match / If-Modified-Since through, so an unchanged archive comes back as a 304. --- .changeset/url-load-proxy-revalidate.md | 5 ++ docs/player-web.ja.md | 2 +- docs/player-web.md | 2 +- packages/worker-play/src/index.ts | 51 +------------ .../worker-play/src/url-load-proxy.test.ts | 74 ++++++++++++++++++ packages/worker-play/src/url-load-proxy.ts | 75 +++++++++++++++++++ 6 files changed, 157 insertions(+), 52 deletions(-) create mode 100644 .changeset/url-load-proxy-revalidate.md create mode 100644 packages/worker-play/src/url-load-proxy.test.ts create mode 100644 packages/worker-play/src/url-load-proxy.ts diff --git a/.changeset/url-load-proxy-revalidate.md b/.changeset/url-load-proxy-revalidate.md new file mode 100644 index 000000000..6fd373c09 --- /dev/null +++ b/.changeset/url-load-proxy-revalidate.md @@ -0,0 +1,5 @@ +--- +'@be-music/worker-play': patch +--- + +An embed `?track=` or player `?music=` archive from another origin was downloaded in full on every load, because `/__url-load-proxy` answered `no-store` and dropped the host's validators. The proxy now answers `no-cache` and passes `ETag` / `Last-Modified` and the browser's `If-None-Match` / `If-Modified-Since` through, so an unchanged archive comes back as a 304 from the browser's cache. diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md index 8c6d471a2..042cc97bb 100644 --- a/docs/player-web.ja.md +++ b/docs/player-web.ja.md @@ -875,7 +875,7 @@ PixiJS scene wiring は browser rendering や WebAudio resource に依存する ## 埋め込みプレビュー -`/embed/?track=`(`play.be-music.app`、プレビュー環境は `play.preview.be-music.app`)は、譜面のアーカイブを iframe の中でプレビューする。`@be-music/app-play` の 2 つ目のエントリ(`embed/index.html`、`src/embed/`)で、同じ Worker が配信する。Worker は iframe では自動サインインを始めない。アーカイブは `?music=` と同じく、同じオリジンからは直接、別の https オリジンからは `/__url-load-proxy` を経由して取得する。 +`/embed/?track=`(`play.be-music.app`、プレビュー環境は `play.preview.be-music.app`)は、譜面のアーカイブを iframe の中でプレビューする。`@be-music/app-play` の 2 つ目のエントリ(`embed/index.html`、`src/embed/`)で、同じ Worker が配信する。Worker は iframe では自動サインインを始めない。アーカイブは `?music=` と同じく、同じオリジンからは直接、別の https オリジンからは `/__url-load-proxy` を経由して取得する。ブラウザはアーカイブを保存し、読み込むたびに再検証する。プロキシは `no-cache` を付け、配信元の `ETag` / `Last-Modified` とブラウザの条件付きリクエストを中継するので、変わっていないアーカイブはダウンロードし直さない。どちらの検証子も送らない配信元からは毎回ダウンロードする。 ```html ``` -既定ではプレビューは AUTO で再生し、入力を判定しない。`mode=play` を付けると、閲覧者がキーボードで譜面をプレイできる。既定のレーンキーで通常のプレイと同じく判定し、シークはできない。このとき M と F はレーンなので、ミュートと全画面の切り替えはプレイ開始前だけ効く。プレイ中はコントロールバーを使う。ステージのクリックやタップでも一時停止しない。一時停止はコントロールバーかプレイ自体の一時停止キーで行う。埋め込みではタッチレーンは使えない。どちらのモードでもリプレイとスコアは記録しない。 +既定ではプレビューは AUTO で再生し、入力を判定しない。`mode=play` を付けると、閲覧者がキーボードで譜面をプレイできる。既定のレーンキーで通常のプレイと同じく判定し、シークはできない。このとき M と F はレーンなので、ミュートと全画面の切り替えはプレイ開始前だけ効く。プレイ中はコントロールバーを使う。ステージのクリックやタップでも一時停止しない。一時停止はコントロールバーかプレイ自体の一時停止キーで行う。埋め込みではタッチレーンは使えない。どちらのモードでもリプレイとスコアは記録しない。閲覧者は URL を変えずにモードを切り替えることもできる。譜面一覧のヘッダと、単一の譜面の再生ボタンの下にある **AUTO** チェックボックスは、`preview` ではオン、`play` ではオフで始まる。再生ボタンの下で切り替えると、もう一方のモードでプレイを読み込み直す。 | パラメーター | 意味 | | ------------ | ------------------------------------------------------------------------ | diff --git a/docs/player-web.md b/docs/player-web.md index b24846ac6..4910da6d0 100644 --- a/docs/player-web.md +++ b/docs/player-web.md @@ -889,7 +889,7 @@ PixiJS scene wiring can remain in `player-browser` when the behavior depends on > ``` -By default the preview plays in AUTO and judges no input. Add `mode=play` to let the viewer play the chart with the keyboard instead: the default lane keys judge it as a normal play, without seeking. M and F are lanes then, so they mute and toggle full screen only before the play starts; use the controls bar while playing. A click or tap on the stage does not pause the play either: pause with the controls bar or the play's own pause key. Touch lanes are not available in the embed. Neither mode records a replay or a score. +By default the preview plays in AUTO and judges no input. Add `mode=play` to let the viewer play the chart with the keyboard instead: the default lane keys judge it as a normal play, without seeking. M and F are lanes then, so they mute and toggle full screen only before the play starts; use the controls bar while playing. A click or tap on the stage does not pause the play either: pause with the controls bar or the play's own pause key. Touch lanes are not available in the embed. Neither mode records a replay or a score. The viewer can also switch modes without changing the URL: the **AUTO** checkbox in the chart list's header, and under the play button of a single chart, starts checked for `preview` and cleared for `play`; changing it under the play button loads the play again in the other mode. | Parameter | Meaning | | --------- | ----------------------------------------------------------------------------- | diff --git a/packages/app-play/e2e/embed.spec.ts b/packages/app-play/e2e/embed.spec.ts index 8582493f8..b1e9645c7 100644 --- a/packages/app-play/e2e/embed.spec.ts +++ b/packages/app-play/e2e/embed.spec.ts @@ -106,6 +106,24 @@ test('?mode=play lets the viewer play: no seeking, and M is a lane rather than m ).toBeAttached(); }); +test("the AUTO checkbox switches between the AUTO preview and the viewer's play without a ?mode=", async ({ page }) => { + await openEmbed(page, ['songs/fixA']); + const start = page.locator('be-music-preview-start'); + const auto = start.getByRole('checkbox', { name: 'AUTO' }); + await expect(start.getByRole('button', { name: 'Play', exact: true })).toBeEnabled({ timeout: 60_000 }); + await expect(auto).toBeChecked(); + await auto.uncheck(); + const play = start.getByRole('button', { name: 'Play', exact: true }); + await expect(play).toBeEnabled({ timeout: 60_000 }); + await expect(auto).not.toBeChecked(); + await play.click(); + // The viewer's play: no seeking, and no tap-to-pause layer over the stage. + const hud = page.locator('be-music-preview-hud'); + await expect(hud.getByRole('slider', { name: 'Seek' })).toBeDisabled({ timeout: 30_000 }); + await expect(hud.locator('.stage-hit')).toHaveCount(0); + expect(new URL(page.url()).searchParams.get('mode')).toBeNull(); +}); + test('Back on a paused play leaves it for the replay button, the way back a touch screen has', async ({ page }) => { await openEmbed(page, ['songs/fixA'], undefined, 'play'); await page.getByRole('button', { name: 'Play', exact: true }).click({ timeout: 60_000 }); diff --git a/packages/app-play/src/embed/preview-app.ts b/packages/app-play/src/embed/preview-app.ts index e468d78f3..7950edad3 100644 --- a/packages/app-play/src/embed/preview-app.ts +++ b/packages/app-play/src/embed/preview-app.ts @@ -1,5 +1,5 @@ // The embed's preview player: loads the `?track=` archive, lists its charts when it has several, and plays the one -// picked from the preview skin, with video controls: in AUTO, or played by the viewer with `?mode=play`. `?course=` selects an LR2 / beatoraja course file; +// picked from the preview skin, with video controls: in AUTO, or played by the viewer with `?mode=play` or the AUTO checkbox cleared. `?course=` selects an LR2 / beatoraja course file; // staged courses advance automatically, NONSTOP courses use the shared stitching pipeline. `?chart=` naming a chart // (`findEmbedChart`) plays it on its own, as a track of one chart does. A play ends back on the list, or — for a chart played on its own — on a // replay button. Nothing is recorded. @@ -35,7 +35,7 @@ import { basecoatTheme } from '@be-music/player-ui/themes/basecoat'; import { previewArtworkPath } from './artwork.ts'; import { findEmbedChart } from './chart-match.ts'; import { createPreviewSkin, type PreviewChrome, type PreviewChromeState } from './preview-skin.ts'; -import { embedStageTapPauses, type EmbedMode, type EmbedTrack } from './track-url.ts'; +import { embedModeOfAutoPlay, embedStageTapPauses, type EmbedMode, type EmbedTrack } from './track-url.ts'; /** How far ← / → seek. */ const SEEK_STEP_SECONDS = 5; @@ -64,7 +64,7 @@ export class PreviewApp { private readonly host = new PixiSceneHost(); private readonly audio: AudioService = createAudioService(); private readonly chrome: PreviewChromeController; - /** AUTO (`'preview'`), or the viewer's own play (`'play'`). */ + /** AUTO (`'preview'`), or the viewer's own play (`'play'`): `?mode=` at first, then the AUTO checkbox. */ private mode: EmbedMode = 'preview'; private collection: BrowserChartCollection | undefined; /** The track's name, from its archive's file name. */ @@ -145,6 +145,10 @@ export class PreviewApp { list.charts = this.courses === undefined ? this.charts : []; list.courses = this.courses; list.heading = this.trackName; + list.autoPlay = this.mode === 'preview'; + list.addEventListener('auto-play-change', (event) => { + this.mode = embedModeOfAutoPlay((event as BeMusicUIEvent<'auto-play-change'>).detail.autoPlay); + }); const images = new Map(); list.images = images; list.addEventListener('activate-request', (event) => { @@ -226,8 +230,13 @@ export class PreviewApp { let start: HTMLElement | undefined; if (button !== undefined) { start = document.createElement('be-music-preview-start'); - Object.assign(start, { mode: button, disabled: true }); + Object.assign(start, { mode: button, disabled: true, autoPlay: this.mode === 'preview' }); start.addEventListener('play-request', () => this.begin()); + // The scene was made for the other mode: make it again, behind the same button. + start.addEventListener('auto-play-change', (event) => { + this.mode = embedModeOfAutoPlay((event as BeMusicUIEvent<'auto-play-change'>).detail.autoPlay); + void this.tryPlay(() => this.prepare(chart, button, overrides)); + }); this.showOverlay(start); } await scene.prepare(); diff --git a/packages/app-play/src/embed/track-url.test.ts b/packages/app-play/src/embed/track-url.test.ts index 4d17cbcd9..b9dbc3b84 100644 --- a/packages/app-play/src/embed/track-url.test.ts +++ b/packages/app-play/src/embed/track-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { embedStageTapPauses, parseEmbedTrack } from './track-url.ts'; +import { embedModeOfAutoPlay, embedStageTapPauses, parseEmbedTrack } from './track-url.ts'; const PAGE = 'https://play.be-music.app/embed/'; @@ -39,6 +39,11 @@ describe('parseEmbedTrack', () => { expect(parseEmbedTrack(`${PAGE}?track=/tracks/demo.zip`)?.mode).toBe('preview'); }); + it("turns the AUTO checkbox into the AUTO preview or the viewer's play", () => { + expect(embedModeOfAutoPlay(true)).toBe('preview'); + expect(embedModeOfAutoPlay(false)).toBe('play'); + }); + it("lets a tap on the stage pause the AUTO preview but not the viewer's own play", () => { expect(embedStageTapPauses('preview')).toBe(true); expect(embedStageTapPauses('play')).toBe(false); diff --git a/packages/app-play/src/embed/track-url.ts b/packages/app-play/src/embed/track-url.ts index 632639ef0..a19938e32 100644 --- a/packages/app-play/src/embed/track-url.ts +++ b/packages/app-play/src/embed/track-url.ts @@ -3,6 +3,11 @@ import { resolveURLLoadFetchURL } from '../url-load.ts'; /** How the embed plays a chart: `'preview'` plays it in AUTO like a video, `'play'` lets the viewer play it. */ export type EmbedMode = 'preview' | 'play'; +/** The mode the embed's AUTO checkbox picks: checked plays in AUTO (`'preview'`), cleared lets the viewer play. */ +export function embedModeOfAutoPlay(autoPlay: boolean): EmbedMode { + return autoPlay ? 'preview' : 'play'; +} + /** * Whether a click or tap on the stage pauses the play, as it does a video. Not while the viewer plays (`'play'`): a tap * there is part of playing, not a request to stop, so only the controls bar and the play's own keys pause it. diff --git a/packages/player-ui/src/components/auto-play-check.ts b/packages/player-ui/src/components/auto-play-check.ts new file mode 100644 index 000000000..50de1e7df --- /dev/null +++ b/packages/player-ui/src/components/auto-play-check.ts @@ -0,0 +1,23 @@ +import { html, nothing, type TemplateResult } from 'lit'; + +/** + * The AUTO checkbox of the preview's list and start screens: checked, the preview plays the chart by itself; cleared, + * the viewer plays it. Nothing while `autoPlay` is unset; `disabled` while the play it would change is loading. Style it with `.auto-play`. + */ +export function renderAutoPlayCheck( + autoPlay: boolean | undefined, + onChange: (autoPlay: boolean) => void, + disabled = false, +): TemplateResult | typeof nothing { + if (autoPlay === undefined) return nothing; + return html``; +} diff --git a/packages/player-ui/src/components/preview-select.ts b/packages/player-ui/src/components/preview-select.ts index 6a4424e98..901fe7332 100644 --- a/packages/player-ui/src/components/preview-select.ts +++ b/packages/player-ui/src/components/preview-select.ts @@ -3,12 +3,16 @@ import { repeat } from 'lit/directives/repeat.js'; import type { LibraryChart, LibraryCourse } from '@be-music/player-skin-sdk'; import { BeMusicElement } from '../base.ts'; import { structureStyles } from '../styles/structure.ts'; +import { renderAutoPlayCheck } from './auto-play-check.ts'; type PreviewRow = Pick & { disabled: boolean; }; -/** The charts or courses of a previewed track; picking one requests it (`activate-request`, by index). */ +/** + * The charts or courses of a previewed track; picking one requests it (`activate-request`, by index). With `autoPlay` + * set, the header has an AUTO checkbox, whose change requests `auto-play-change`. + */ export class BeMusicPreviewSelectElement extends BeMusicElement { static override properties: PropertyDeclarations = { charts: { attribute: false }, @@ -16,6 +20,8 @@ export class BeMusicPreviewSelectElement extends BeMusicElement { heading: {}, /** Object URLs of each chart's `#STAGEFILE`, or its `#BANNER` when it has no stage file. Keyed by chart id. */ images: { attribute: false }, + /** AUTO plays the pick by itself; off, the viewer plays it. No checkbox while unset. */ + autoPlay: { attribute: false }, }; static override styles: CSSResultGroup = [ structureStyles, @@ -31,6 +37,28 @@ export class BeMusicPreviewSelectElement extends BeMusicElement { height: 100%; padding: 16px; } + header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + /* Ends where the rows do, short of the list's scrollbar gutter. */ + padding-right: 12px; + } + h2 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .auto-play { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + white-space: nowrap; + cursor: pointer; + } ul { display: grid; align-content: start; @@ -100,6 +128,7 @@ export class BeMusicPreviewSelectElement extends BeMusicElement { declare charts: readonly LibraryChart[] | undefined; declare heading: string | undefined; declare images: ReadonlyMap | undefined; + declare autoPlay: boolean | undefined; protected override render(): TemplateResult { const courses = this.courses; @@ -116,7 +145,10 @@ export class BeMusicPreviewSelectElement extends BeMusicElement { })) : (this.charts ?? []).map((chart) => ({ ...chart, disabled: false })); return html`
-

${this.heading ?? (courses !== undefined ? 'Choose a course' : 'Choose a chart')}

+
+

${this.heading ?? (courses !== undefined ? 'Choose a course' : 'Choose a chart')}

+ ${renderAutoPlayCheck(this.autoPlay, (autoPlay) => this.emit('auto-play-change', { autoPlay }))} +
    ${repeat( rows, diff --git a/packages/player-ui/src/components/preview-start.ts b/packages/player-ui/src/components/preview-start.ts index d1d1452bd..fddb5c539 100644 --- a/packages/player-ui/src/components/preview-start.ts +++ b/packages/player-ui/src/components/preview-start.ts @@ -1,16 +1,27 @@ import { css, html, svg, type CSSResultGroup, type PropertyDeclarations, type TemplateResult } from 'lit'; import { BeMusicElement } from '../base.ts'; import { structureStyles } from '../styles/structure.ts'; +import { renderAutoPlayCheck } from './auto-play-check.ts'; -/** The large play button over a preview before it plays (`mode="play"`) or after it ended (`mode="replay"`). */ +/** + * The large play button over a preview before it plays (`mode="play"`) or after it ended (`mode="replay"`). With + * `autoPlay` set, an AUTO checkbox sits under it, whose change requests `auto-play-change`. + */ export class BeMusicPreviewStartElement extends BeMusicElement { - static override properties: PropertyDeclarations = { mode: {}, disabled: { type: Boolean } }; + static override properties: PropertyDeclarations = { + mode: {}, + disabled: { type: Boolean }, + /** AUTO plays the chart by itself; off, the viewer plays it. No checkbox while unset. */ + autoPlay: { attribute: false }, + }; static override styles: CSSResultGroup = [ structureStyles, css` :host { display: grid; - place-items: center; + place-content: center; + justify-items: center; + gap: 14px; position: absolute; inset: 0; background: #05070b80; @@ -24,6 +35,14 @@ export class BeMusicPreviewStartElement extends BeMusicElement { padding: 18px 26px; font-size: 15px; } + .auto-play { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + white-space: nowrap; + cursor: pointer; + } svg { width: 56px; height: 56px; @@ -33,6 +52,7 @@ export class BeMusicPreviewStartElement extends BeMusicElement { ]; declare mode: 'play' | 'replay'; declare disabled: boolean; + declare autoPlay: boolean | undefined; constructor() { super(); @@ -43,16 +63,17 @@ export class BeMusicPreviewStartElement extends BeMusicElement { protected override render(): TemplateResult { const replay = this.mode === 'replay'; return html``; + class="btn" + data-size="lg" + part="button" + ?disabled=${this.disabled} + @click=${() => this.emit('play-request', { autoPlay: true })} + > + + ${replay ? 'Replay' : 'Play'} + + ${renderAutoPlayCheck(this.autoPlay, (autoPlay) => this.emit('auto-play-change', { autoPlay }), this.disabled)}`; } } diff --git a/packages/player-ui/src/events.ts b/packages/player-ui/src/events.ts index bfadf7513..aa97ce706 100644 --- a/packages/player-ui/src/events.ts +++ b/packages/player-ui/src/events.ts @@ -15,6 +15,7 @@ export interface BeMusicUIEventMap { 'readme-request': undefined; 'gesture-request': undefined; 'play-request': { autoPlay: boolean }; + 'auto-play-change': { autoPlay: boolean }; 'selection-change': { index: number }; 'activate-request': { index: number }; 'search-change': { value: string }; From b9bc65c1bc659cee0a391712f8a37f536cda0fe5 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 17:30:56 +0900 Subject: [PATCH 5/7] fix(embed): reach the lanes down to the seek bar without running under it On a phone held upright the lanes stopped well above the controls, and on a phone held sideways (SP or DP) the judgement line sat under the seek bar. Keep clear only the controls' 54 CSS px as tall as they get on a 320 px phone and a 16 px gap (previewJudgementInset), in both orientations. The preview HUD puts the controls along the bottom of the screen rather than the stage, so a DP chart letterboxed on a phone held upright keeps them off its lanes. --- .changeset/embed-portrait-lanes.md | 5 + .changeset/preview-portrait-lanes.md | 5 + docs/player-web.ja.md | 2 +- docs/player-web.md | 2 +- .../app-play/src/embed/preview-skin.test.ts | 2 +- packages/app-play/src/embed/preview-skin.ts | 5 +- .../player-ui/src/components/preview-hud.ts | 153 +++++++++--------- packages/player-ui/src/index.ts | 2 + packages/player-ui/src/preview-layout.test.ts | 26 ++- packages/player-ui/src/preview-layout.ts | 25 ++- scripts/bench/packages/player-ui.ts | 6 + 11 files changed, 143 insertions(+), 90 deletions(-) create mode 100644 .changeset/embed-portrait-lanes.md create mode 100644 .changeset/preview-portrait-lanes.md diff --git a/.changeset/embed-portrait-lanes.md b/.changeset/embed-portrait-lanes.md new file mode 100644 index 000000000..14caecd94 --- /dev/null +++ b/.changeset/embed-portrait-lanes.md @@ -0,0 +1,5 @@ +--- +'@be-music/app-play': patch +--- + +The embed's lanes now reach down to just above the seek bar without running under it: on a phone held upright they stopped well short, on a phone held sideways the judgement line sat under the seek bar, and a DP chart on a phone held upright had the controls over its lanes. diff --git a/.changeset/preview-portrait-lanes.md b/.changeset/preview-portrait-lanes.md new file mode 100644 index 000000000..d4bf9a2ea --- /dev/null +++ b/.changeset/preview-portrait-lanes.md @@ -0,0 +1,5 @@ +--- +'@be-music/player-ui': minor +--- + +The preview's lanes now reach down to just above the video controls without running under them: on a phone held upright they stopped well short, and on a phone held sideways (SP or DP) the judgement line sat under the seek bar. `resolvePreviewPortrait` and the new `previewJudgementInset` keep clear only the controls' height on the smallest (320 px) phone and a 16 px gap; `PREVIEW_CONTROLS_CSS_HEIGHT` is exported with it. `be-music-preview-hud` also puts the controls along the bottom of the screen rather than the stage, so a stage letterboxed into the screen keeps them off its lanes. diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md index 198438996..ba121a10b 100644 --- a/docs/player-web.ja.md +++ b/docs/player-web.ja.md @@ -924,7 +924,7 @@ stateDiagram-v2 - **選曲:** 譜面が複数あるトラックは一覧を表示し(`be-music-preview-select`)、各行にその譜面の `#STAGEFILE`、なければ `#BANNER` を出す。選んだ譜面はすぐに再生する。 - **再生待ち:** 譜面が 1 つのときは、プレイ画面の上に再生ボタン(`be-music-preview-start`)を表示する。再生後はリプレイボタンになる。リザルト画面はない。 -- **再生中:** プレビュー用スキン(`src/embed/preview-skin.ts`)は、左ペインにレーン、右ペインに BGA を置く。縦長の画面では BGA を上、レーンをその下に置く。譜面の情報・スコア・コンボ・ゲージ・判定は BGA 側に置き(`be-music-preview-hud`)、下端に動画プレイヤー風のコントロール(`be-music-playback-controls`)を置く。細いシークバー、再生/一時停止、ミュートの横に開く音量、時間、全画面である。画面をクリックすると再生/一時停止する。ただし閲覧者がプレイしている間(`mode=play`、`embedStageTapPauses`)は一時停止しない。一時停止中は再生ボタンとその下に **Back** を表示する。Back は Escape と同じくプレイを抜けて一覧(または Replay ボタン)に戻る。スマートフォンやタブレットではこれが戻る手段になる。Space で再生/一時停止、← / → で 5 秒シーク、M でミュート、F で全画面を切り替える。 +- **再生中:** プレビュー用スキン(`src/embed/preview-skin.ts`)は、左ペインにレーン、右ペインに BGA を置く。縦長の画面では BGA を上、レーンをその下に置く。レーンは縦横どちらでも動画コントロールのすぐ上まで伸ばす(`previewJudgementInset`:短辺 320 px のスマートフォンでのコントロールの高さ 54 CSS px 分と 16 px)。コントロールはステージではなく画面の下端に置くので、縦に持ったスマートフォンで横長ステージを縮めて表示する DP 譜面でも、コントロールがレーンに重ならない。譜面の情報・スコア・コンボ・ゲージ・判定は BGA 側に置き(`be-music-preview-hud`)、下端に動画プレイヤー風のコントロール(`be-music-playback-controls`)を置く。細いシークバー、再生/一時停止、ミュートの横に開く音量、時間、全画面である。画面をクリックすると再生/一時停止する。ただし閲覧者がプレイしている間(`mode=play`、`embedStageTapPauses`)は一時停止しない。一時停止中は再生ボタンとその下に **Back** を表示する。Back は Escape と同じくプレイを抜けて一覧(または Replay ボタン)に戻る。スマートフォンやタブレットではこれが戻る手段になる。Space で再生/一時停止、← / → で 5 秒シーク、M でミュート、F で全画面を切り替える。 シーク(`PlayScene.seek`)は、動いているエンジンにシークのコマンドを送る([ADR 0016](./adr/0016-seek-play-loop-command.ja.md))。プレイを始め直すことはない。エンジンは譜面の頭からシークした秒までプレイを組み直すので、スコア・ゲージ・コンボ・最大コンボ・グラフ・タイミングはそこまでプレイしたときと同じになる。その時点で鳴っている音は途中から鳴らし(`AudioSession.triggerEventAt`)、Web の音声セッションは鳴っていた音を止め、音量の変化を戻し、NONSTOP の譜面のフェードを動かす(`WebAudioSession.seek`)。シークバーと時間は新しい世代のフレームに従い、一時停止中のプレイは一時停止したままになる。セッションの側は「プレイの部品の置き場所」を参照。 diff --git a/docs/player-web.md b/docs/player-web.md index 4910da6d0..d14223392 100644 --- a/docs/player-web.md +++ b/docs/player-web.md @@ -927,7 +927,7 @@ stateDiagram-v2 - **List:** a track with several charts lists them (`be-music-preview-select`), each row showing the chart's `#STAGEFILE` or, without one, its `#BANNER`; picking one plays it at once. - **Ready:** a single chart shows its play screen behind a play button (`be-music-preview-start`), which becomes Replay after the play. There is no result screen. -- **Playing:** the preview skin (`src/embed/preview-skin.ts`) lays the lanes in the left pane and the BGA in the right, and on a tall screen the BGA across the top with the lanes below it. The chart's facts, score, combo, gauge and judgments sit with the BGA (`be-music-preview-hud`), and video-player controls run along the bottom (`be-music-playback-controls`): a thin seek bar, play / pause, a volume slider that opens beside mute, the time, and full screen. Clicking the picture plays or pauses, except while the viewer plays (`mode=play`, `embedStageTapPauses`). A paused play shows a play button and, under it, **Back**, which leaves the play for the list (or the replay button) the way Escape does: the way back on a phone or tablet. Space plays or pauses, ← / → seek 5 seconds, M mutes and F toggles full screen. +- **Playing:** the preview skin (`src/embed/preview-skin.ts`) lays the lanes in the left pane and the BGA in the right, and on a tall screen the BGA across the top with the lanes below it. The lanes reach down to just above the video controls in either orientation (`previewJudgementInset`: the controls' 54 CSS px as tall as they get on a phone 320 px across its short side, and 16 px). The controls run along the bottom of the screen rather than the stage, so a DP chart on a phone held upright, which plays on the landscape stage letterboxed into the screen, keeps them off its lanes. The chart's facts, score, combo, gauge and judgments sit with the BGA (`be-music-preview-hud`), and video-player controls run along the bottom (`be-music-playback-controls`): a thin seek bar, play / pause, a volume slider that opens beside mute, the time, and full screen. Clicking the picture plays or pauses, except while the viewer plays (`mode=play`, `embedStageTapPauses`). A paused play shows a play button and, under it, **Back**, which leaves the play for the list (or the replay button) the way Escape does: the way back on a phone or tablet. Space plays or pauses, ← / → seek 5 seconds, M mutes and F toggles full screen. Seeking (`PlayScene.seek`) sends the running engine a seek command ([ADR 0016](./adr/0016-seek-play-loop-command.md)); the play is not started again. The engine rebuilds the play from the chart's start to the second sought, so the score, gauge, combo, max combo, graphs and timings read as if the play had run there; the sounds still ringing there start partway (`AudioSession.triggerEventAt`), and the web audio session stops what played, resets the dynamic volumes and moves a NONSTOP chart's fades (`WebAudioSession.seek`). The seek bar and the time follow the frames of the new generation, and a paused play stays paused. See "Where the play's parts live" for the session's side. diff --git a/packages/app-play/src/embed/preview-skin.test.ts b/packages/app-play/src/embed/preview-skin.test.ts index 91448c657..078480f5b 100644 --- a/packages/app-play/src/embed/preview-skin.test.ts +++ b/packages/app-play/src/embed/preview-skin.test.ts @@ -33,7 +33,7 @@ describe('createPreviewStage', () => { const layout = resolveGameplayLayout({ laneChannels: SP7, playVariant: '7' }, stage); expect(layout.stage).toEqual({ width, height }); expect(layout.playfield.right).toBeLessThanOrEqual(width / 2); - expect(layout.playfield.judgementY).toBe(height - 60); + expect(layout.playfield.judgementY).toBe(height - 97); expect(layout.bga!.x).toBeGreaterThanOrEqual(layout.playfield.right); expect(layout.bga!.x + layout.bga!.w).toBeLessThanOrEqual(width - 16); expect(layout.bga!.w).toBeGreaterThan(0); diff --git a/packages/app-play/src/embed/preview-skin.ts b/packages/app-play/src/embed/preview-skin.ts index bb1d481aa..f763b3ae8 100644 --- a/packages/app-play/src/embed/preview-skin.ts +++ b/packages/app-play/src/embed/preview-skin.ts @@ -17,6 +17,7 @@ import { defineBeMusicElements, paintPlayfieldParts, PREVIEW_GUTTER, + previewJudgementInset, resolvePreviewBGARect as resolvePreviewBGARectOnStage, resolvePreviewPortrait, resolvePlayfieldParts, @@ -25,8 +26,6 @@ import { import { basecoatTheme } from '@be-music/player-ui/themes/basecoat'; import { ControlsIdleController } from './controls-idle.ts'; -/** The controls bar along the bottom of the stage takes this much below the judge line. */ -const CONTROLS_BELOW_JUDGEMENT = 60; /** Between HUD redraws (12 a second); a stage, view or seekability change redraws at once. */ const HUD_INTERVAL_MS = 80; @@ -74,7 +73,7 @@ export function createPreviewStage(size: { width: number; height: number }): BeM resize: createPreviewStage, }; } - const playfield = { top: 0, judgementY: height - CONTROLS_BELOW_JUDGEMENT, maxRight: width / 2 - PREVIEW_GUTTER }; + const playfield = { top: 0, judgementY: height - previewJudgementInset(size), maxRight: width / 2 - PREVIEW_GUTTER }; return { width, height, diff --git a/packages/player-ui/src/components/preview-hud.ts b/packages/player-ui/src/components/preview-hud.ts index aa830d351..4ee893ddf 100644 --- a/packages/player-ui/src/components/preview-hud.ts +++ b/packages/player-ui/src/components/preview-hud.ts @@ -7,8 +7,9 @@ import { structureStyles } from '../styles/structure.ts'; /** * The HUD of a preview: the chart's facts, score, combo, gauge and judgments beside or under the BGA - * (`resolvePreviewInfoBox`), and video-player controls - * along the bottom of the stage. Lay it over `be-music-playfield`; the volume, mute and full-screen state are the + * (`resolvePreviewInfoBox`), and video-player controls along the bottom of the screen (the host), not of the stage, so + * a stage letterboxed into a screen turned the other way (a DP chart on a phone held upright) keeps its lanes clear of + * them. Lay it over `be-music-playfield`; the volume, mute and full-screen state are the * host's, set as properties, and so is hiding the controls (`controlsHidden`) while the viewer is idle. A click on the * stage pauses and resumes the play unless the host turns it off (`stageTapPauses`), as it does while the viewer plays. * A paused play shows a centred play button and, under it, Back (`quit-request`), which leaves the play. @@ -174,80 +175,81 @@ export class BeMusicPreviewHUDElement extends FrameElement 0 ? `${((score.exScore / score.maximum) * 100).toFixed(2)}%` : '—'; const playing = f.stage !== 'paused' && f.stage !== 'ending'; return html`
    stage.width ? 'portrait' : 'landscape'} - style=${`--stage-ratio:${stage.width / stage.height};--portrait-font-ratio:${PREVIEW_PORTRAIT_FONT_RATIO}`} - > - ${ - this.stageTapPauses - ? html`` - : nothing - } -
    + class="stage" + part="stage" + data-orientation=${stage.height > stage.width ? 'portrait' : 'landscape'} + style=${`--stage-ratio:${stage.width / stage.height};--portrait-font-ratio:${PREVIEW_PORTRAIT_FONT_RATIO}`} + > ${ - f.course === undefined - ? nothing - : html`

    - ${f.course.title} · - ${f.course.stageIndex === undefined ? `${f.course.stages.length} stages` : `Stage ${f.course.stageIndex + 1} / ${f.course.stages.length}`} -

    ` - } -

    ${title}

    -

    ${f.chart?.artist ?? ''}

    -

    ${[facts, bpm].filter(Boolean).join(' · ')}

    -

    - EX SCORE ${score.exScore} · ${score.rank ?? '—'} · ${rate} · Combo ${score.combo} / - ${score.maxCombo} -

    - -
    - ${( - [ - ['PG', judgments.perfect], - ['GR', judgments.great], - ['GD', judgments.good], - ['BD', judgments.bad], - ['PR', judgments.poor + judgments.emptyPoor], - ['FAST', judgments.fast], - ['SLOW', judgments.slow], - ] as const - ).map( - ([label, value]) => - html`
    -
    ${label}
    -
    ${value}
    -
    `, - )} -
    -
    - ${ - f.stage === 'paused' - ? html` - ` - : nothing - } + >` + : nothing + } +
    + ${ + f.course === undefined + ? nothing + : html`

    + ${f.course.title} · + ${f.course.stageIndex === undefined ? `${f.course.stages.length} stages` : `Stage ${f.course.stageIndex + 1} / ${f.course.stages.length}`} +

    ` + } +

    ${title}

    +

    ${f.chart?.artist ?? ''}

    +

    ${[facts, bpm].filter(Boolean).join(' · ')}

    +

    + EX SCORE ${score.exScore} · ${score.rank ?? '—'} · ${rate} · Combo ${score.combo} / + ${score.maxCombo} +

    + +
    + ${( + [ + ['PG', judgments.perfect], + ['GR', judgments.great], + ['GD', judgments.good], + ['BD', judgments.bad], + ['PR', judgments.poor + judgments.emptyPoor], + ['FAST', judgments.fast], + ['SLOW', judgments.slow], + ] as const + ).map( + ([label, value]) => + html`
    +
    ${label}
    +
    ${value}
    +
    `, + )} +
    +
    + ${ + f.stage === 'paused' + ? html` + ` + : nothing + } +
    - `; + >`; } } diff --git a/packages/player-ui/src/index.ts b/packages/player-ui/src/index.ts index 3fd7f413e..f354c3400 100644 --- a/packages/player-ui/src/index.ts +++ b/packages/player-ui/src/index.ts @@ -28,6 +28,7 @@ export { BeMusicPreviewSelectElement } from './components/preview-select.ts'; export { BeMusicPreviewStartElement } from './components/preview-start.ts'; export { formatPlaybackTime, resolveSeekTarget } from './playback.ts'; export { + PREVIEW_CONTROLS_CSS_HEIGHT, PREVIEW_CONTROLS_RATIO, PREVIEW_GUTTER, PREVIEW_INFO_BELOW_HEIGHT, @@ -35,6 +36,7 @@ export { PREVIEW_PORTRAIT_FONT_RATIO, PREVIEW_PORTRAIT_INFO_HEIGHT, previewContentBottom, + previewJudgementInset, resolvePreviewBGARect, resolvePreviewInfoBox, resolvePreviewPortrait, diff --git a/packages/player-ui/src/preview-layout.test.ts b/packages/player-ui/src/preview-layout.test.ts index db79fdfdc..617352106 100644 --- a/packages/player-ui/src/preview-layout.test.ts +++ b/packages/player-ui/src/preview-layout.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vite-plus/test'; -import { resolvePreviewBGARect, resolvePreviewInfoBox, resolvePreviewPortrait } from './preview-layout.ts'; +import { + PREVIEW_CONTROLS_CSS_HEIGHT, + previewJudgementInset, + resolvePreviewBGARect, + resolvePreviewInfoBox, + resolvePreviewPortrait, +} from './preview-layout.ts'; const stage = { width: 854, height: 480 }; @@ -37,7 +43,7 @@ describe('resolvePreviewPortrait', () => { bga: { x: 55, y: 16, w: 370, h: 370 }, info: { left: 16, top: 398, right: 464, bottom: 618 }, laneTop: 634, - judgementY: 858, + judgementY: 942, }); expect(resolvePreviewBGARect(stage, 427)).toEqual({ x: 55, y: 16, w: 370, h: 370 }); }); @@ -48,7 +54,7 @@ describe('resolvePreviewPortrait', () => { bga: { x: 16, y: 16, w: 224, h: 224 }, info: { left: 264, top: 16, right: 464, bottom: 240 }, laneTop: 256, - judgementY: 514, + judgementY: 543, }); }); @@ -60,10 +66,22 @@ describe('resolvePreviewPortrait', () => { if (!besideBGA) expect(bga.y + bga.h).toBeLessThan(info.top); expect(Math.max(bga.y + bga.h, info.bottom)).toBeLessThan(laneTop); expect(laneTop).toBeLessThan(judgementY); - expect(judgementY).toBeLessThan(Math.round(height * 0.86)); + // Lower than the 36 px above the controls' 14 % share of the height the lanes used to stop at. + expect(judgementY).toBeGreaterThan(Math.round(height * 0.86) - 36); + // On every phone from 320 CSS px wide up, the lanes end at least 16 stage px above the seek bar. + for (const cssWidth of [320, 375, 430, 768]) { + const controlsTop = height - (PREVIEW_CONTROLS_CSS_HEIGHT * 480) / cssWidth; + expect(judgementY).toBeLessThanOrEqual(controlsTop - 16); + } } }); + it('keeps the lanes clear of the controls by their height on the smallest phone, and a gap, either way up', () => { + // 54 CSS px × 480 / 320 = 81 stage px, and 16 more. + expect(previewJudgementInset({ width: 480, height: 1039 })).toBe(97); + expect(previewJudgementInset({ width: 1039, height: 480 })).toBe(97); + }); + it('is unused on a landscape stage', () => { expect(resolvePreviewPortrait({ width: 854, height: 480 })).toBeUndefined(); }); diff --git a/packages/player-ui/src/preview-layout.ts b/packages/player-ui/src/preview-layout.ts index 40493f557..238e87f30 100644 --- a/packages/player-ui/src/preview-layout.ts +++ b/packages/player-ui/src/preview-layout.ts @@ -21,8 +21,25 @@ const PORTRAIT_LANE_MIN = 260; const PORTRAIT_LANE_GAP = 16; /** Gap between the BGA and the facts under it. */ const PORTRAIT_INFO_GAP = 12; -/** The judgement line sits this far above the video controls. */ -const PORTRAIT_JUDGEMENT_INSET = 36; +/** The video controls' height in CSS px (`be-music-playback-controls`: the 16 px seek bar, 32 px buttons, 6 px below). */ +export const PREVIEW_CONTROLS_CSS_HEIGHT = 54; +/** + * The shortest side of the smallest phone a stage is drawn on, in CSS px. A stage keeps its 480 px short side across + * that of the screen, so the controls take the most stage pixels there. + */ +const NARROWEST_CSS_SHORT_SIDE = 320; +/** Between the bottom of the lanes and the top of the seek bar on the smallest phone, in stage px. */ +const CONTROLS_GAP = 16; + +/** + * How far the bottom of the lanes (the judgement line) sits above the bottom of a stage that fills the screen: the + * video controls as tall as they get in stage pixels on the smallest phone, and a gap, so the lanes reach down to the + * controls without running under them in either orientation. + */ +export function previewJudgementInset(stage: PreviewLayout['stage']): number { + const shortSide = Math.min(stage.width, stage.height); + return Math.ceil((PREVIEW_CONTROLS_CSS_HEIGHT * shortSide) / NARROWEST_CSS_SHORT_SIDE) + CONTROLS_GAP; +} /** * Where a tall stage puts the BGA, the chart's facts and the lanes: the BGA square on top with the facts under it or @@ -57,7 +74,7 @@ export function previewContentBottom(stageHeight: number): number { /** * A stage taller than it is wide: the BGA square across the top, the chart's facts in a band under it or in a column - * beside it, whichever leaves the larger BGA, and the lanes under both, above the video controls. A stage close to + * beside it, whichever leaves the larger BGA, and the lanes under both, down to just above the video controls. A stage close to * square (a tablet held upright) gets the facts beside the BGA; a phone's gets them under it. `undefined` on a * landscape stage. */ @@ -71,7 +88,7 @@ export function resolvePreviewPortrait(stage: PreviewLayout['stage']): PreviewPo const roomHeight = contentBottom - top - PORTRAIT_LANE_MIN - PORTRAIT_LANE_GAP; const under = Math.max(0, Math.min(roomWidth, roomHeight - PORTRAIT_INFO_GAP - PREVIEW_PORTRAIT_INFO_HEIGHT)); const beside = Math.max(0, Math.min(roomWidth - PREVIEW_GUTTER - PREVIEW_INFO_MIN_WIDTH, roomHeight)); - const judgementY = contentBottom - PORTRAIT_JUDGEMENT_INSET; + const judgementY = stage.height - previewJudgementInset(stage); if (under >= beside) { const infoTop = top + under + PORTRAIT_INFO_GAP; const infoBottom = infoTop + PREVIEW_PORTRAIT_INFO_HEIGHT; diff --git a/scripts/bench/packages/player-ui.ts b/scripts/bench/packages/player-ui.ts index 320cd8a3c..eaca00f58 100644 --- a/scripts/bench/packages/player-ui.ts +++ b/scripts/bench/packages/player-ui.ts @@ -122,6 +122,7 @@ const NON_ELEMENT_EXPORTS = new Set([ 'resolveSeekTarget', 'resolveGameplayPanels', 'previewContentBottom', + 'previewJudgementInset', 'resolvePreviewBGARect', 'resolvePreviewInfoBox', 'resolvePreviewPortrait', @@ -159,6 +160,11 @@ export const playerUIBenchmarkPackage: BenchmarkPackageDefinition = { ui.previewContentBottom(previewStage.height); }, }); + define('player-ui.previewJudgementInset', { + run: () => { + ui.previewJudgementInset(previewStage); + }, + }); define('player-ui.resolvePreviewBGARect', { run: () => { ui.resolvePreviewBGARect(previewStage, 184); From c6437bcb9dc14913087404f9827b9f737451c743 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 17:34:09 +0900 Subject: [PATCH 6/7] feat(embed): wait for Load, with the archive size, before downloading on a phone A phone or tablet (or a browser asking to save data) no longer downloads the track as soon as the embed opens. It shows a Load button (be-music-preview-start's new load mode and caption) with the file name and the size a HEAD request reports; /__url-load-proxy now answers HEAD with the upstream's Content-Length. --- .changeset/embed-load-on-mobile.md | 5 ++++ .changeset/preview-start-load.md | 5 ++++ .changeset/url-load-proxy-head-size.md | 5 ++++ docs/player-web.ja.md | 2 +- docs/player-web.md | 2 +- packages/app-play/e2e/embed.spec.ts | 18 +++++++++++ .../app-play/src/embed/download-size.test.ts | 25 ++++++++++++++++ packages/app-play/src/embed/download-size.ts | 22 ++++++++++++++ packages/app-play/src/embed/preview-app.ts | 28 ++++++++++++++++- packages/app-play/src/embed/track-url.test.ts | 9 +++++- packages/app-play/src/embed/track-url.ts | 8 +++++ .../player-ui/src/components/preview-start.ts | 30 +++++++++++++++---- .../worker-play/src/url-load-proxy.test.ts | 11 +++++++ packages/worker-play/src/url-load-proxy.ts | 8 +++++ 14 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 .changeset/embed-load-on-mobile.md create mode 100644 .changeset/preview-start-load.md create mode 100644 .changeset/url-load-proxy-head-size.md create mode 100644 packages/app-play/src/embed/download-size.test.ts create mode 100644 packages/app-play/src/embed/download-size.ts diff --git a/.changeset/embed-load-on-mobile.md b/.changeset/embed-load-on-mobile.md new file mode 100644 index 000000000..73fc90c58 --- /dev/null +++ b/.changeset/embed-load-on-mobile.md @@ -0,0 +1,5 @@ +--- +'@be-music/app-play': minor +--- + +On a phone or tablet, and wherever the browser asks to save data, the embed no longer starts downloading the track as soon as it opens: it shows a **Load** button with the archive's file name and size, and downloads once it is pressed. diff --git a/.changeset/preview-start-load.md b/.changeset/preview-start-load.md new file mode 100644 index 000000000..b5f3cc066 --- /dev/null +++ b/.changeset/preview-start-load.md @@ -0,0 +1,5 @@ +--- +'@be-music/player-ui': minor +--- + +`be-music-preview-start` gains a `load` mode (a download icon and "Load") and a `caption` property, a line of small type under the label such as the file the button loads. diff --git a/.changeset/url-load-proxy-head-size.md b/.changeset/url-load-proxy-head-size.md new file mode 100644 index 000000000..d083da9bd --- /dev/null +++ b/.changeset/url-load-proxy-head-size.md @@ -0,0 +1,5 @@ +--- +'@be-music/worker-play': patch +--- + +`/__url-load-proxy` now answers a `HEAD` request with the archive's `Content-Length` (unless it travels compressed), so the embed can show the download size before downloading. diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md index ba121a10b..d9dd0708a 100644 --- a/docs/player-web.ja.md +++ b/docs/player-web.ja.md @@ -875,7 +875,7 @@ PixiJS scene wiring は browser rendering や WebAudio resource に依存する ## 埋め込みプレビュー -`/embed/?track=`(`play.be-music.app`、プレビュー環境は `play.preview.be-music.app`)は、譜面のアーカイブを iframe の中でプレビューする。`@be-music/app-play` の 2 つ目のエントリ(`embed/index.html`、`src/embed/`)で、同じ Worker が配信する。Worker は iframe では自動サインインを始めない。アーカイブは `?music=` と同じく、同じオリジンからは直接、別の https オリジンからは `/__url-load-proxy` を経由して取得する。ブラウザはアーカイブを保存し、読み込むたびに再検証する。プロキシは `no-cache` を付け、配信元の `ETag` / `Last-Modified` とブラウザの条件付きリクエストを中継するので、変わっていないアーカイブはダウンロードし直さない。どちらの検証子も送らない配信元からは毎回ダウンロードする。 +`/embed/?track=`(`play.be-music.app`、プレビュー環境は `play.preview.be-music.app`)は、譜面のアーカイブを iframe の中でプレビューする。`@be-music/app-play` の 2 つ目のエントリ(`embed/index.html`、`src/embed/`)で、同じ Worker が配信する。Worker は iframe では自動サインインを始めない。アーカイブは `?music=` と同じく、同じオリジンからは直接、別の https オリジンからは `/__url-load-proxy` を経由して取得する。ブラウザはアーカイブを保存し、読み込むたびに再検証する。プロキシは `no-cache` を付け、配信元の `ETag` / `Last-Modified` とブラウザの条件付きリクエストを中継するので、変わっていないアーカイブはダウンロードし直さない。どちらの検証子も送らない配信元からは毎回ダウンロードする。スマートフォンやタブレットと、ブラウザがデータ節約を求めている場合は、すぐにはダウンロードしない。アーカイブのファイル名とサイズ(`HEAD` リクエストで取得する。プロキシは配信元の `Content-Length` を返す)を添えた **Load** ボタンを表示し、押されてからダウンロードする。 ```html