diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md index 8c6d471a2..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` を経由して取得する。 +`/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 ``` -既定ではプレビューは AUTO で再生し、入力を判定しない。`mode=play` を付けると、閲覧者がキーボードで譜面をプレイできる。既定のレーンキーで通常のプレイと同じく判定し、シークはできない。このとき M と F はレーンなので、ミュートと全画面の切り替えはプレイ開始前だけ効く。プレイ中はコントロールバーを使う。ステージのクリックやタップでも一時停止しない。一時停止はコントロールバーかプレイ自体の一時停止キーで行う。埋め込みではタッチレーンは使えない。どちらのモードでもリプレイとスコアは記録しない。 +既定ではプレビューは AUTO で再生し、入力を判定しない。`mode=play` を付けると、閲覧者がキーボードで譜面をプレイできる。既定のレーンキーで通常のプレイと同じく判定し、シークはできない。このとき M と F はレーンなので、ミュートと全画面の切り替えはプレイ開始前だけ効く。プレイ中はコントロールバーを使う。ステージのクリックやタップでも一時停止しない。一時停止はコントロールバーかプレイ自体の一時停止キーで行う。埋め込みではタッチレーンは使えない。どちらのモードでもリプレイとスコアは記録しない。閲覧者は URL を変えずにモードを切り替えることもできる。譜面一覧のヘッダと、単一の譜面の再生ボタンの下にある **AUTO** チェックボックスは、`preview` ではオン、`play` ではオフで始まる。再生ボタンの下で切り替えると、もう一方のモードでプレイを読み込み直す。 | パラメーター | 意味 | | ------------ | ------------------------------------------------------------------------ | @@ -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 c2317a719..3e10ae5f0 100644 --- a/docs/player-web.md +++ b/docs/player-web.md @@ -878,7 +878,7 @@ PixiJS scene wiring can remain in `player-browser` when the behavior depends on ## Embedded preview -`/embed/?track=` (`play.be-music.app`, and `play.preview.be-music.app` for the preview environment) previews a chart archive inside an iframe. It is a second entry of `@be-music/app-play` (`embed/index.html`, `src/embed/`), served by the same Worker; the Worker starts no automatic sign-in for an iframe. The archive is fetched like `?music=`: directly from this origin, through `/__url-load-proxy` from another https origin. +`/embed/?track=` (`play.be-music.app`, and `play.preview.be-music.app` for the preview environment) previews a chart archive inside an iframe. It is a second entry of `@be-music/app-play` (`embed/index.html`, `src/embed/`), served by the same Worker; the Worker starts no automatic sign-in for an iframe. The archive is fetched like `?music=`: directly from this origin, through `/__url-load-proxy` from another https origin. The browser keeps the archive and revalidates it on each load: the proxy marks it `no-cache` and passes the host's `ETag` / `Last-Modified` and the browser's conditional request through, so an unchanged archive is not downloaded again. A host that sends neither validator is downloaded every time. On a phone or tablet, and wherever the browser asks to save data, the embed does not download at once: it shows a **Load** button with the archive's file name and its size (from a `HEAD` request, which the proxy answers with the host's `Content-Length`), and downloads when it is pressed. ```html ``` -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 | | --------- | ----------------------------------------------------------------------------- | @@ -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/CHANGELOG.md b/packages/app-play/CHANGELOG.md index 388837d45..7a87dae61 100644 --- a/packages/app-play/CHANGELOG.md +++ b/packages/app-play/CHANGELOG.md @@ -1,5 +1,17 @@ # @be-music/app-play +## 0.9.0 + +### Minor Changes + +- 155bd8d: The embed can switch between the AUTO preview and the viewer's own play without changing `?mode=`: an **AUTO** checkbox sits in the chart list's header and under the play button of a single chart, starting from `?mode=`. Changing it under the play button loads the play again in the other mode. +- c6437bc: 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. + +### Patch Changes + +- b9bc65c: 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. +- Updated dependencies: `@be-music/player-browser@0.13.2` + ## 0.8.1 ### Patch Changes diff --git a/packages/app-play/e2e/embed.spec.ts b/packages/app-play/e2e/embed.spec.ts index 747215540..7c2184647 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(); @@ -104,6 +106,42 @@ 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('a phone waits for Load, which shows the archive size, before downloading the track', async ({ browser }) => { + const context = await browser.newContext({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + let downloads = 0; + page.on('request', (request) => { + if (request.url().includes(TRACK_ROUTE) && request.method() === 'GET') downloads += 1; + }); + await openEmbed(page, ['songs/fixA']); + const load = page.locator('be-music-preview-start').getByRole('button'); + await expect(load).toContainText('Load'); + await expect(load).toContainText(/fixA\.zip · \d+ KB/); + expect(downloads).toBe(0); + await load.click(); + await expect(page.getByRole('button', { name: 'Play', exact: true })).toBeEnabled({ timeout: 60_000 }); + expect(downloads).toBe(1); + await context.close(); +}); + 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/package.json b/packages/app-play/package.json index 4e6bc1e89..6e2b82ab9 100644 --- a/packages/app-play/package.json +++ b/packages/app-play/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/app-play", - "version": "0.8.1", + "version": "0.9.0", "private": true, "description": "Web player application for browsing charts, gameplay, and replays", "type": "module", diff --git a/packages/app-play/src/embed/download-size.test.ts b/packages/app-play/src/embed/download-size.test.ts new file mode 100644 index 000000000..801ed7154 --- /dev/null +++ b/packages/app-play/src/embed/download-size.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { describeDownload, readDownloadSize } from './download-size.ts'; + +describe('readDownloadSize', () => { + it('reads the length of an uncompressed body', () => { + expect(readDownloadSize(new Headers({ 'content-length': '12345678' }))).toBe(12_345_678); + expect(readDownloadSize(new Headers({ 'content-length': '10', 'content-encoding': 'identity' }))).toBe(10); + }); + + it('knows no size without a length, or for a body compressed in transit', () => { + expect(readDownloadSize(new Headers())).toBeUndefined(); + expect(readDownloadSize(new Headers({ 'content-length': '0' }))).toBeUndefined(); + expect(readDownloadSize(new Headers({ 'content-length': 'abc' }))).toBeUndefined(); + expect(readDownloadSize(new Headers({ 'content-length': '10', 'content-encoding': 'gzip' }))).toBeUndefined(); + }); +}); + +describe('describeDownload', () => { + it('adds the size in MB when it is known', () => { + expect(describeDownload('song.zip', 12_345_678)).toBe('song.zip · 12.3 MB'); + expect(describeDownload('song.zip', 16_642)).toBe('song.zip · 17 KB'); + expect(describeDownload('song.zip', 120)).toBe('song.zip · 1 KB'); + expect(describeDownload('song.zip', undefined)).toBe('song.zip'); + }); +}); diff --git a/packages/app-play/src/embed/download-size.ts b/packages/app-play/src/embed/download-size.ts new file mode 100644 index 000000000..a6d11fef4 --- /dev/null +++ b/packages/app-play/src/embed/download-size.ts @@ -0,0 +1,22 @@ +// The track's size, shown on the Load button before the embed downloads it on a phone or tablet. + +/** + * The bytes a response's headers say the body holds, or `undefined` when they do not say, or count it compressed in + * transit (`Content-Encoding`), which is not what the download will deliver. + */ +export function readDownloadSize(headers: Headers): number | undefined { + if ((headers.get('content-encoding') ?? 'identity') !== 'identity') return undefined; + const length = Number(headers.get('content-length')); + return Number.isFinite(length) && length > 0 ? length : undefined; +} + +/** + * The Load button's caption: the file name, and its size when known, in MB as the download progress counts them (KB + * under 1 MB, where a tenth of a MB would round to nothing). + */ +export function describeDownload(fileName: string, bytes: number | undefined): string { + if (bytes === undefined) return fileName; + const size = + bytes < 1_000_000 ? `${Math.max(1, Math.round(bytes / 1000))} KB` : `${(bytes / 1_000_000).toFixed(1)} MB`; + return `${fileName} · ${size}`; +} diff --git a/packages/app-play/src/embed/preview-app.ts b/packages/app-play/src/embed/preview-app.ts index e468d78f3..669f6108d 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,15 @@ 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, + embedWaitsToDownload, + type EmbedMode, + type EmbedTrack, +} from './track-url.ts'; +import { readDeviceSignals, resolveDeviceProfile } from '../device-profile.ts'; +import { describeDownload, readDownloadSize } from './download-size.ts'; /** How far ← / → seek. */ const SEEK_STEP_SECONDS = 5; @@ -64,7 +72,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. */ @@ -98,6 +106,8 @@ export class PreviewApp { } this.mode = track.mode; this.trackName = track.fileName.replace(/\.zip$/iu, ''); + const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData; + if (embedWaitsToDownload(resolveDeviceProfile(readDeviceSignals()), saveData)) await this.waitForLoad(track); const progress = new LoadingProgress(); this.showOverlay(progress.element); try { @@ -138,6 +148,22 @@ export class PreviewApp { } } + /** + * Shows the Load button with the track's file name, and its size once a HEAD request tells it, and resolves once the + * button is pressed. + */ + private waitForLoad(track: EmbedTrack): Promise { + const load = document.createElement('be-music-preview-start'); + Object.assign(load, { mode: 'load', caption: describeDownload(track.fileName, undefined) }); + this.showOverlay(load); + void fetch(track.fetchURL, { method: 'HEAD', redirect: 'follow' }) + .then((response) => { + if (response.ok) load.caption = describeDownload(track.fileName, readDownloadSize(response.headers)); + }) + .catch(() => undefined); + return new Promise((resolve) => load.addEventListener('play-request', () => resolve(), { once: true })); + } + private showSelect(): void { this.revokeArtwork(); const list = document.createElement('be-music-preview-select'); @@ -145,6 +171,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 +256,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/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/app-play/src/embed/track-url.test.ts b/packages/app-play/src/embed/track-url.test.ts index 4d17cbcd9..e7200c5c2 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, embedWaitsToDownload, parseEmbedTrack } from './track-url.ts'; const PAGE = 'https://play.be-music.app/embed/'; @@ -39,6 +39,18 @@ describe('parseEmbedTrack', () => { expect(parseEmbedTrack(`${PAGE}?track=/tracks/demo.zip`)?.mode).toBe('preview'); }); + it('waits for a tap before downloading on a phone or tablet, or when the browser saves data', () => { + expect(embedWaitsToDownload({ touchFirst: true }, undefined)).toBe(true); + expect(embedWaitsToDownload({ touchFirst: false }, true)).toBe(true); + expect(embedWaitsToDownload({ touchFirst: false }, false)).toBe(false); + expect(embedWaitsToDownload({ touchFirst: false }, undefined)).toBe(false); + }); + + 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..6c6da7136 100644 --- a/packages/app-play/src/embed/track-url.ts +++ b/packages/app-play/src/embed/track-url.ts @@ -3,6 +3,19 @@ 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'; +/** + * Whether the embed waits for a tap before downloading the track: on a phone or tablet (`touchFirst`), whose data may + * be metered, and wherever the browser asks to save data. A desktop downloads at once. + */ +export function embedWaitsToDownload(device: { touchFirst: boolean }, saveData: boolean | undefined): boolean { + return device.touchFirst || saveData === true; +} + +/** 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-browser/CHANGELOG.md b/packages/player-browser/CHANGELOG.md index 1fe0f84c4..59da21dd8 100644 --- a/packages/player-browser/CHANGELOG.md +++ b/packages/player-browser/CHANGELOG.md @@ -1,5 +1,11 @@ # @be-music/player-browser +## 0.13.2 + +### Patch Changes + +- Updated dependencies: `@be-music/player-ui@0.5.0` + ## 0.13.1 ### Patch Changes diff --git a/packages/player-browser/package.json b/packages/player-browser/package.json index 59d4f0788..efa99f45c 100644 --- a/packages/player-browser/package.json +++ b/packages/player-browser/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player-browser", - "version": "0.13.1", + "version": "0.13.2", "description": "Browser player runtime with audio, input, skin rendering, and video recording", "license": "MIT", "files": [ diff --git a/packages/player-ui-react/CHANGELOG.md b/packages/player-ui-react/CHANGELOG.md index 7db33ddf4..ae130898c 100644 --- a/packages/player-ui-react/CHANGELOG.md +++ b/packages/player-ui-react/CHANGELOG.md @@ -1,5 +1,11 @@ # @be-music/player-ui-react +## 0.2.3 + +### Patch Changes + +- Updated dependencies: `@be-music/player-ui@0.5.0` + ## 0.2.2 ### Patch Changes diff --git a/packages/player-ui-react/package.json b/packages/player-ui-react/package.json index 48312d150..3a6232142 100644 --- a/packages/player-ui-react/package.json +++ b/packages/player-ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player-ui-react", - "version": "0.2.2", + "version": "0.2.3", "description": "Typed React wrappers for Be-Music Web Components", "license": "MIT", "files": [ diff --git a/packages/player-ui-vue/CHANGELOG.md b/packages/player-ui-vue/CHANGELOG.md index 7c7eb1c04..56fc37022 100644 --- a/packages/player-ui-vue/CHANGELOG.md +++ b/packages/player-ui-vue/CHANGELOG.md @@ -1,5 +1,11 @@ # @be-music/player-ui-vue +## 0.2.3 + +### Patch Changes + +- Updated dependencies: `@be-music/player-ui@0.5.0` + ## 0.2.2 ### Patch Changes diff --git a/packages/player-ui-vue/package.json b/packages/player-ui-vue/package.json index 759fb6eaf..2d070912a 100644 --- a/packages/player-ui-vue/package.json +++ b/packages/player-ui-vue/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player-ui-vue", - "version": "0.2.2", + "version": "0.2.3", "description": "Typed Vue wrappers for Be-Music Web Components", "license": "MIT", "files": [ diff --git a/packages/player-ui/CHANGELOG.md b/packages/player-ui/CHANGELOG.md index 872f18d54..64014c8e9 100644 --- a/packages/player-ui/CHANGELOG.md +++ b/packages/player-ui/CHANGELOG.md @@ -1,5 +1,13 @@ # @be-music/player-ui +## 0.5.0 + +### Minor Changes + +- 155bd8d: `be-music-preview-select` and `be-music-preview-start` gain an `autoPlay` property: set, they show an **AUTO** checkbox (in the list's header, under the play button) whose change emits the new `auto-play-change` event. Unset, nothing changes. +- b9bc65c: 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. +- c6437bc: `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. + ## 0.4.0 ### Minor Changes diff --git a/packages/player-ui/package.json b/packages/player-ui/package.json index 2a9467b21..9037e2720 100644 --- a/packages/player-ui/package.json +++ b/packages/player-ui/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player-ui", - "version": "0.4.0", + "version": "0.5.0", "description": "Reusable Lit Web Components for Be-Music player interfaces", "license": "MIT", "files": [ 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-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/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..1cf10a36a 100644 --- a/packages/player-ui/src/components/preview-start.ts +++ b/packages/player-ui/src/components/preview-start.ts @@ -1,16 +1,30 @@ -import { css, html, svg, type CSSResultGroup, type PropertyDeclarations, type TemplateResult } from 'lit'; +import { css, html, nothing, 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"`), or the + * button that starts downloading the track (`mode="load"`), with its file name as the `caption`. 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 }, + /** A line of small type under the label, such as the file the button loads. */ + caption: {}, + /** 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 +38,22 @@ 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; + } + .caption { + max-width: 16rem; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.7; + } svg { width: 56px; height: 56px; @@ -31,8 +61,10 @@ export class BeMusicPreviewStartElement extends BeMusicElement { } `, ]; - declare mode: 'play' | 'replay'; + declare mode: 'play' | 'replay' | 'load'; + declare caption: string | undefined; declare disabled: boolean; + declare autoPlay: boolean | undefined; constructor() { super(); @@ -42,17 +74,26 @@ export class BeMusicPreviewStartElement extends BeMusicElement { protected override render(): TemplateResult { const replay = this.mode === 'replay'; + const load = this.mode === 'load'; return html``; + class="btn" + data-size="lg" + part="button" + ?disabled=${this.disabled} + @click=${() => this.emit('play-request', { autoPlay: true })} + > + + ${replay ? 'Replay' : load ? 'Load' : 'Play'} + ${this.caption ? html`${this.caption}` : nothing} + + ${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 }; 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/packages/worker-play/CHANGELOG.md b/packages/worker-play/CHANGELOG.md index 53e4c0699..f76374507 100644 --- a/packages/worker-play/CHANGELOG.md +++ b/packages/worker-play/CHANGELOG.md @@ -1,5 +1,17 @@ # @be-music/worker-play +## 0.1.4 + +### Patch Changes + +- c6437bc: `/__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. + +## 0.1.3 + +### Patch Changes + +- afcbc07: 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. + ## 0.1.2 ### Patch Changes diff --git a/packages/worker-play/package.json b/packages/worker-play/package.json index 12d43e20e..3f16398dc 100644 --- a/packages/worker-play/package.json +++ b/packages/worker-play/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/worker-play", - "version": "0.1.2", + "version": "0.1.4", "private": true, "description": "Cloudflare Worker serving the web player, authentication, API forwarding, and asset proxies", "type": "module", diff --git a/packages/worker-play/src/index.ts b/packages/worker-play/src/index.ts index e8601af8e..c069f2581 100644 --- a/packages/worker-play/src/index.ts +++ b/packages/worker-play/src/index.ts @@ -18,6 +18,7 @@ import { handleAuth, readSession, type OIDCEnv } from '@be-music/service-auth-client'; import { isLeaderboardPath } from './leaderboard-paths.ts'; import { handlePlayerSettings, PLAYER_SETTINGS_PATH } from './player-settings.ts'; +import { serveURLLoadProxy } from './url-load-proxy.ts'; interface Env extends OIDCEnv { ASSETS: Fetcher; @@ -69,56 +70,6 @@ async function serveFFmpegCore(request: Request, env: Env, pathname: string): Pr return new Response(request.method === 'HEAD' ? null : object.body, { headers }); } -function resolveURLLoadProxyTarget(rawURL: string | null): URL { - if (!rawURL) { - throw new Error('missing url query parameter'); - } - const target = new URL(rawURL); - if (target.protocol !== 'https:') { - throw new Error('only https proxy targets are allowed'); - } - if (target.username || target.password) { - throw new Error('proxy target credentials are not allowed'); - } - return target; -} - -async function serveURLLoadProxy(request: Request): Promise { - if (request.method !== 'GET' && request.method !== 'HEAD') { - return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } }); - } - - let target: URL; - try { - target = resolveURLLoadProxyTarget(new URL(request.url).searchParams.get('url')); - } catch (error) { - return new Response(error instanceof Error ? error.message : 'invalid proxy target', { status: 400 }); - } - - let upstream: Response; - try { - upstream = await fetch(target, { - method: request.method === 'HEAD' ? 'HEAD' : 'GET', - redirect: 'follow', - }); - } catch (error) { - return new Response(`failed to fetch ${target.href}: ${error instanceof Error ? error.message : String(error)}`, { - status: 502, - }); - } - - const headers = new Headers(); - headers.set('content-type', upstream.headers.get('content-type') ?? 'application/octet-stream'); - headers.set('cache-control', 'no-store'); - headers.set('cross-origin-resource-policy', 'same-origin'); - - return new Response(request.method === 'HEAD' ? null : upstream.body, { - status: upstream.status, - statusText: upstream.statusText, - headers, - }); -} - export default { async fetch(request: Request, env: Env): Promise { const auth = await handleAuth(request, env); diff --git a/packages/worker-play/src/url-load-proxy.test.ts b/packages/worker-play/src/url-load-proxy.test.ts new file mode 100644 index 000000000..608ab6f42 --- /dev/null +++ b/packages/worker-play/src/url-load-proxy.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vite-plus/test'; +import { resolveURLLoadProxyTarget, serveURLLoadProxy } from './url-load-proxy.ts'; + +const ARCHIVE = 'https://example.com/song.zip'; +const proxyURL = (url: string) => `https://play.be-music.app/__url-load-proxy?url=${encodeURIComponent(url)}`; + +/** A fake upstream recording what it was asked: 304 when the request's If-None-Match is its ETag. */ +function upstream(etag: string) { + const requests: { url: string; headers: Headers }[] = []; + const fetcher = async (input: URL, init: RequestInit): Promise => { + const headers = new Headers(init.headers); + requests.push({ url: input.href, headers }); + const validators = { etag, 'last-modified': 'Sat, 31 Oct 2026 00:00:00 GMT' }; + if (headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers: validators }); + return new Response('zip', { headers: { ...validators, 'content-type': 'application/zip' } }); + }; + return { requests, fetcher }; +} + +describe('serveURLLoadProxy', () => { + test('passes the archive through with its validators, cached but revalidated each time', async () => { + const { requests, fetcher } = upstream('"v1"'); + const response = await serveURLLoadProxy(new Request(proxyURL(ARCHIVE)), fetcher); + expect(response.status).toBe(200); + expect(await response.text()).toBe('zip'); + expect(response.headers.get('cache-control')).toBe('no-cache'); + expect(response.headers.get('etag')).toBe('"v1"'); + expect(response.headers.get('last-modified')).toBe('Sat, 31 Oct 2026 00:00:00 GMT'); + expect(response.headers.get('content-type')).toBe('application/zip'); + expect(requests[0]!.url).toBe(ARCHIVE); + }); + + test("answers an unchanged archive's revalidation with a bodiless 304", async () => { + const { requests, fetcher } = upstream('"v1"'); + const request = new Request(proxyURL(ARCHIVE), { + headers: { 'if-none-match': '"v1"', 'if-modified-since': 'Sat, 31 Oct 2026 00:00:00 GMT' }, + }); + const response = await serveURLLoadProxy(request, fetcher); + expect(response.status).toBe(304); + expect(response.body).toBeNull(); + expect(response.headers.get('etag')).toBe('"v1"'); + expect(requests[0]!.headers.get('if-none-match')).toBe('"v1"'); + expect(requests[0]!.headers.get('if-modified-since')).toBe('Sat, 31 Oct 2026 00:00:00 GMT'); + }); + + test('downloads a changed archive again', async () => { + const { fetcher } = upstream('"v2"'); + const request = new Request(proxyURL(ARCHIVE), { headers: { 'if-none-match': '"v1"' } }); + const response = await serveURLLoadProxy(request, fetcher); + expect(response.status).toBe(200); + expect(response.headers.get('etag')).toBe('"v2"'); + }); + + test('forwards no other browser header, such as cookies', async () => { + const { requests, fetcher } = upstream('"v1"'); + await serveURLLoadProxy(new Request(proxyURL(ARCHIVE), { headers: { cookie: 'session=1' } }), fetcher); + expect([...requests[0]!.headers.keys()]).toEqual([]); + }); + + test('tells a HEAD request the archive size, unless it travels compressed', async () => { + const sized = async (encoding?: string) => + serveURLLoadProxy(new Request(proxyURL(ARCHIVE), { method: 'HEAD' }), async () => { + const headers: Record = { 'content-length': '12345678' }; + if (encoding) headers['content-encoding'] = encoding; + return new Response(null, { headers }); + }); + expect((await sized()).headers.get('content-length')).toBe('12345678'); + expect((await sized('gzip')).headers.get('content-length')).toBeNull(); + }); + + test('refuses other methods and targets it must not fetch', async () => { + const { fetcher } = upstream('"v1"'); + expect((await serveURLLoadProxy(new Request(proxyURL(ARCHIVE), { method: 'POST' }), fetcher)).status).toBe(405); + expect((await serveURLLoadProxy(new Request(proxyURL('http://example.com/a.zip')), fetcher)).status).toBe(400); + }); +}); + +describe('resolveURLLoadProxyTarget', () => { + test('accepts https URLs only, without credentials', () => { + expect(resolveURLLoadProxyTarget(ARCHIVE).href).toBe(ARCHIVE); + expect(() => resolveURLLoadProxyTarget(null)).toThrow('missing url'); + expect(() => resolveURLLoadProxyTarget('http://example.com/a.zip')).toThrow('only https'); + expect(() => resolveURLLoadProxyTarget('https://user:pass@example.com/a.zip')).toThrow('credentials'); + }); +}); diff --git a/packages/worker-play/src/url-load-proxy.ts b/packages/worker-play/src/url-load-proxy.ts new file mode 100644 index 000000000..0d58491e4 --- /dev/null +++ b/packages/worker-play/src/url-load-proxy.ts @@ -0,0 +1,83 @@ +// `/__url-load-proxy?url=`: fetches an archive from another origin for the player's `?music=` and the embed's +// `?track=`, whose hosts often send no CORS headers. The browser may keep the response but revalidates it on every use +// (`no-cache`): the upstream's ETag / Last-Modified pass through, and so do the browser's If-None-Match / +// If-Modified-Since, so an unchanged archive comes back as a 304 instead of downloading again. + +/** The validators the proxy carries from the browser to the upstream. */ +const CONDITIONAL_REQUEST_HEADERS = ['if-none-match', 'if-modified-since'] as const; +/** The validators the proxy carries from the upstream back to the browser. */ +const VALIDATOR_RESPONSE_HEADERS = ['etag', 'last-modified'] as const; + +export function resolveURLLoadProxyTarget(rawURL: string | null): URL { + if (!rawURL) { + throw new Error('missing url query parameter'); + } + const target = new URL(rawURL); + if (target.protocol !== 'https:') { + throw new Error('only https proxy targets are allowed'); + } + if (target.username || target.password) { + throw new Error('proxy target credentials are not allowed'); + } + return target; +} + +export async function serveURLLoadProxy( + request: Request, + fetcher: (input: URL, init: RequestInit) => Promise = fetch, +): Promise { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } }); + } + + let target: URL; + try { + target = resolveURLLoadProxyTarget(new URL(request.url).searchParams.get('url')); + } catch (error) { + return new Response(error instanceof Error ? error.message : 'invalid proxy target', { status: 400 }); + } + + const conditional = new Headers(); + for (const name of CONDITIONAL_REQUEST_HEADERS) { + const value = request.headers.get(name); + if (value !== null) conditional.set(name, value); + } + + let upstream: Response; + try { + upstream = await fetcher(target, { + method: request.method === 'HEAD' ? 'HEAD' : 'GET', + headers: conditional, + redirect: 'follow', + }); + } catch (error) { + return new Response(`failed to fetch ${target.href}: ${error instanceof Error ? error.message : String(error)}`, { + status: 502, + }); + } + + const headers = new Headers(); + headers.set('content-type', upstream.headers.get('content-type') ?? 'application/octet-stream'); + // Keep it, but ask again each time: the same URL may serve a new archive. + headers.set('cache-control', 'no-cache'); + headers.set('cross-origin-resource-policy', 'same-origin'); + for (const name of VALIDATOR_RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value !== null) headers.set(name, value); + } + + // A HEAD request asks for the size before the download (the embed's Load button on a phone). Only there: a GET body + // may come decoded from a compressed transfer, which the upstream's length would not match. + if (request.method === 'HEAD') { + const length = upstream.headers.get('content-length'); + const encoding = upstream.headers.get('content-encoding') ?? 'identity'; + if (length !== null && encoding === 'identity') headers.set('content-length', length); + } + + const bodyless = request.method === 'HEAD' || upstream.status === 304; + return new Response(bodyless ? null : upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +} 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);