Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/player-web.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,7 @@ PixiJS scene wiring は browser rendering や WebAudio resource に依存する

## 埋め込みプレビュー

`/embed/?track=<URL>`(`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=<URL>`(`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
<iframe
Expand All @@ -886,7 +886,7 @@ PixiJS scene wiring は browser rendering や WebAudio resource に依存する
></iframe>
```

既定ではプレビューは AUTO で再生し、入力を判定しない。`mode=play` を付けると、閲覧者がキーボードで譜面をプレイできる。既定のレーンキーで通常のプレイと同じく判定し、シークはできない。このとき M と F はレーンなので、ミュートと全画面の切り替えはプレイ開始前だけ効く。プレイ中はコントロールバーを使う。ステージのクリックやタップでも一時停止しない。一時停止はコントロールバーかプレイ自体の一時停止キーで行う。埋め込みではタッチレーンは使えない。どちらのモードでもリプレイとスコアは記録しない。
既定ではプレビューは AUTO で再生し、入力を判定しない。`mode=play` を付けると、閲覧者がキーボードで譜面をプレイできる。既定のレーンキーで通常のプレイと同じく判定し、シークはできない。このとき M と F はレーンなので、ミュートと全画面の切り替えはプレイ開始前だけ効く。プレイ中はコントロールバーを使う。ステージのクリックやタップでも一時停止しない。一時停止はコントロールバーかプレイ自体の一時停止キーで行う。埋め込みではタッチレーンは使えない。どちらのモードでもリプレイとスコアは記録しない。閲覧者は URL を変えずにモードを切り替えることもできる。譜面一覧のヘッダと、単一の譜面の再生ボタンの下にある **AUTO** チェックボックスは、`preview` ではオン、`play` ではオフで始まる。再生ボタンの下で切り替えると、もう一方のモードでプレイを読み込み直す。

| パラメーター | 意味 |
| ------------ | ------------------------------------------------------------------------ |
Expand Down Expand Up @@ -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`)。シークバーと時間は新しい世代のフレームに従い、一時停止中のプレイは一時停止したままになる。セッションの側は「プレイの部品の置き場所」を参照。

Expand Down
6 changes: 3 additions & 3 deletions docs/player-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,7 @@ PixiJS scene wiring can remain in `player-browser` when the behavior depends on

## Embedded preview

`/embed/?track=<URL>` (`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=<URL>` (`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
<iframe
Expand All @@ -889,7 +889,7 @@ PixiJS scene wiring can remain in `player-browser` when the behavior depends on
></iframe>
```

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 |
| --------- | ----------------------------------------------------------------------------- |
Expand Down Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions packages/app-play/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
40 changes: 39 additions & 1 deletion packages/app-play/e2e/embed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion packages/app-play/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 25 additions & 0 deletions packages/app-play/src/embed/download-size.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading