chart-preview 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +1 -1
- package/.github/workflows/release.yml +1 -1
- package/CHANGELOG.md +13 -0
- package/RELEASING.md +43 -0
- package/dev/main.ts +10 -3
- package/dist/index.js +104 -64
- package/dist/index.mjs +104 -64
- package/package.json +1 -1
- package/src/ChartPreview.ts +142 -68
- package/src/ChartPreviewPlayer.ts +6 -17
package/.changeset/config.json
CHANGED
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# chart-preview
|
|
2
|
+
|
|
3
|
+
## 1.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5319db5: Refactor AudioManager to improve audio playback control and state management. Introduce independent paused state tracking, enhance audio source management, and streamline audio decoding process. Update ChartPreviewPlayer styles for better UI responsiveness and remove idle message.
|
|
8
|
+
|
|
9
|
+
## 1.0.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 0bbccfe: Enhance audio management by adding fallback audio length and improving start delay handling. Update AudioManager to use fallback length if no audio files are provided, and ensure end event timeout is managed correctly during playback and seeking. Refactor start delay calculation for clarity in main.ts.
|
package/RELEASING.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
This project uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
|
|
4
|
+
|
|
5
|
+
## How to Release
|
|
6
|
+
|
|
7
|
+
### 1. Create a Changeset
|
|
8
|
+
|
|
9
|
+
After making changes you want to release:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx changeset
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- Select the version bump: `patch` (bug fix), `minor` (new feature), or `major` (breaking change)
|
|
16
|
+
- Write a short summary of the changes
|
|
17
|
+
|
|
18
|
+
This creates a file in `.changeset/` — commit it with your changes.
|
|
19
|
+
|
|
20
|
+
### 2. Push to `main`
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git add .
|
|
24
|
+
git commit -m "Your commit message"
|
|
25
|
+
git push
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 3. Merge the Version PR
|
|
29
|
+
|
|
30
|
+
GitHub Actions will create a **"Version Packages"** PR that:
|
|
31
|
+
|
|
32
|
+
- Bumps the version in `package.json`
|
|
33
|
+
- Updates `CHANGELOG.md`
|
|
34
|
+
|
|
35
|
+
Merge this PR to publish to npm automatically.
|
|
36
|
+
|
|
37
|
+
## Version Bump Guide
|
|
38
|
+
|
|
39
|
+
| Type | When to Use | Example |
|
|
40
|
+
| ------- | ----------------------------------- | ------------- |
|
|
41
|
+
| `patch` | Bug fixes, minor tweaks | 1.0.0 → 1.0.1 |
|
|
42
|
+
| `minor` | New features (backwards compatible) | 1.0.0 → 1.1.0 |
|
|
43
|
+
| `major` | Breaking changes | 1.0.0 → 2.0.0 |
|
package/dev/main.ts
CHANGED
|
@@ -471,9 +471,16 @@ loadBtn.addEventListener("click", async () => {
|
|
|
471
471
|
const audioLengthMs = currentScannedChart.song_length || lastNote + 5000;
|
|
472
472
|
|
|
473
473
|
// Get start delay from chart metadata
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
474
|
+
// delay takes priority over chart_offset if both are present
|
|
475
|
+
// Both use the same sign convention:
|
|
476
|
+
// - Positive value = audio starts earlier (audio is ahead of chart time)
|
|
477
|
+
// - Negative value = audio starts later (audio is delayed)
|
|
478
|
+
// We negate to convert to startDelayMs (internal representation):
|
|
479
|
+
// - startDelayMs < 0 = audio is ahead (at chart time 0, audio is already playing)
|
|
480
|
+
// - startDelayMs > 0 = audio is delayed (audio starts after chart time 0)
|
|
481
|
+
const startDelayMs = currentScannedChart.delay
|
|
482
|
+
? -currentScannedChart.delay
|
|
483
|
+
: -(currentScannedChart.chart_offset || 0);
|
|
477
484
|
|
|
478
485
|
// Calculate initial seek position from preview_start_time if available
|
|
479
486
|
const initialSeekPercent = currentScannedChart.preview_start_time
|
package/dist/index.js
CHANGED
|
@@ -148,7 +148,11 @@ var _ChartPreview = class {
|
|
|
148
148
|
chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
|
|
149
149
|
chartPreview.camera = new ChartCamera(config.container);
|
|
150
150
|
chartPreview.renderer = new ChartRenderer(config.container);
|
|
151
|
-
chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(
|
|
151
|
+
chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(
|
|
152
|
+
config.audioFiles,
|
|
153
|
+
config.startDelayMs,
|
|
154
|
+
config.audioLengthMs
|
|
155
|
+
) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
|
|
152
156
|
chartPreview.audioManager.on(
|
|
153
157
|
"end",
|
|
154
158
|
() => chartPreview.eventEmitter.emit("end")
|
|
@@ -326,10 +330,13 @@ var ChartRenderer = class extends THREE.WebGLRenderer {
|
|
|
326
330
|
}
|
|
327
331
|
};
|
|
328
332
|
var AudioManager = class {
|
|
329
|
-
|
|
333
|
+
// Track paused state independently since we share the AudioContext
|
|
334
|
+
constructor(audioFiles, startDelayMs, fallbackAudioLengthMs) {
|
|
330
335
|
this.audioFiles = audioFiles;
|
|
331
336
|
this.startDelayMs = startDelayMs;
|
|
337
|
+
this.fallbackAudioLengthMs = fallbackAudioLengthMs;
|
|
332
338
|
this.eventEmitter = new import_eventemitter3.EventEmitter();
|
|
339
|
+
this.audioBuffers = [];
|
|
333
340
|
this.gainNode = null;
|
|
334
341
|
this.audioSources = [];
|
|
335
342
|
this._volume = 0.5;
|
|
@@ -337,6 +344,8 @@ var AudioManager = class {
|
|
|
337
344
|
this.lastAudioCtxCurrentTime = 0;
|
|
338
345
|
// Necessary because audioCtx.currentTime doesn't reset to 0 on seek
|
|
339
346
|
this.audioLengthMs = 0;
|
|
347
|
+
this.endEventTimeout = null;
|
|
348
|
+
this._isPaused = true;
|
|
340
349
|
const ctx = getSharedAudioContext();
|
|
341
350
|
if (!ctx) {
|
|
342
351
|
throw new Error("AudioContext is not supported in this environment");
|
|
@@ -347,10 +356,15 @@ var AudioManager = class {
|
|
|
347
356
|
/**
|
|
348
357
|
* @param audioFiles The `Uint8Array[]` of the audio files to be played.
|
|
349
358
|
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
|
359
|
+
* @param fallbackAudioLengthMs The fallback audio length to use if no audio files are provided.
|
|
350
360
|
*/
|
|
351
|
-
static async create(audioFiles, startDelayMs) {
|
|
352
|
-
const audioManager = new AudioManager(
|
|
353
|
-
|
|
361
|
+
static async create(audioFiles, startDelayMs, fallbackAudioLengthMs) {
|
|
362
|
+
const audioManager = new AudioManager(
|
|
363
|
+
audioFiles,
|
|
364
|
+
startDelayMs,
|
|
365
|
+
fallbackAudioLengthMs
|
|
366
|
+
);
|
|
367
|
+
await audioManager.decodeAudioFiles();
|
|
354
368
|
return audioManager;
|
|
355
369
|
}
|
|
356
370
|
/**
|
|
@@ -374,44 +388,75 @@ var AudioManager = class {
|
|
|
374
388
|
}
|
|
375
389
|
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
|
|
376
390
|
get chartCurrentTimeMs() {
|
|
377
|
-
|
|
391
|
+
if (this._isPaused) {
|
|
392
|
+
return this.lastSeekChartTimeMs;
|
|
393
|
+
}
|
|
378
394
|
const audioLatency = (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1e3;
|
|
379
395
|
const audioTimeSinceLastSeekMs = (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1e3;
|
|
380
|
-
return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs -
|
|
396
|
+
return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - audioLatency;
|
|
381
397
|
}
|
|
382
398
|
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
383
399
|
get chartEndTimeMs() {
|
|
384
|
-
|
|
400
|
+
const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
|
|
401
|
+
return Math.max(
|
|
402
|
+
theoreticalEndTime,
|
|
403
|
+
this.audioLengthMs,
|
|
404
|
+
this.fallbackAudioLengthMs,
|
|
405
|
+
0
|
|
406
|
+
);
|
|
385
407
|
}
|
|
386
408
|
async play() {
|
|
387
|
-
if (this.gainNode === null) {
|
|
388
|
-
await this.initAudio();
|
|
389
|
-
}
|
|
390
409
|
if (this.audioCtx.state === "suspended") {
|
|
391
410
|
await this.audioCtx.resume();
|
|
392
411
|
}
|
|
412
|
+
this.startAudioSources();
|
|
413
|
+
this._isPaused = false;
|
|
414
|
+
if (this.audioBuffers.length === 0 && !this.endEventTimeout) {
|
|
415
|
+
const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
|
|
416
|
+
if (remainingMs > 0) {
|
|
417
|
+
this.endEventTimeout = setTimeout(() => {
|
|
418
|
+
this.eventEmitter.emit("end");
|
|
419
|
+
}, remainingMs);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
393
422
|
}
|
|
394
423
|
async pause() {
|
|
395
|
-
|
|
396
|
-
|
|
424
|
+
const currentTime = this.chartCurrentTimeMs;
|
|
425
|
+
this._isPaused = true;
|
|
426
|
+
this.lastSeekChartTimeMs = currentTime;
|
|
427
|
+
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
428
|
+
if (this.endEventTimeout) {
|
|
429
|
+
clearTimeout(this.endEventTimeout);
|
|
430
|
+
this.endEventTimeout = null;
|
|
397
431
|
}
|
|
432
|
+
this.stopAudioSources();
|
|
398
433
|
}
|
|
399
434
|
closeAudio() {
|
|
400
435
|
this.eventEmitter.removeAllListeners();
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
source.disconnect();
|
|
406
|
-
} catch {
|
|
407
|
-
}
|
|
436
|
+
this._isPaused = true;
|
|
437
|
+
if (this.endEventTimeout) {
|
|
438
|
+
clearTimeout(this.endEventTimeout);
|
|
439
|
+
this.endEventTimeout = null;
|
|
408
440
|
}
|
|
409
|
-
this.
|
|
410
|
-
this.gainNode?.disconnect();
|
|
411
|
-
this.gainNode = null;
|
|
441
|
+
this.stopAudioSources();
|
|
412
442
|
unregisterInstance();
|
|
413
443
|
}
|
|
414
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Decodes audio files into AudioBuffers. Called once during initialization.
|
|
446
|
+
*/
|
|
447
|
+
async decodeAudioFiles() {
|
|
448
|
+
this.audioBuffers = await Promise.all(
|
|
449
|
+
this.audioFiles.map(
|
|
450
|
+
(file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
|
|
451
|
+
)
|
|
452
|
+
);
|
|
453
|
+
const calculatedLengthMs = this.audioBuffers.length > 0 ? Math.max(...this.audioBuffers.map((b) => b.duration)) * 1e3 : 0;
|
|
454
|
+
this.audioLengthMs = calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Stops all currently playing audio sources for this instance.
|
|
458
|
+
*/
|
|
459
|
+
stopAudioSources() {
|
|
415
460
|
for (const source of this.audioSources) {
|
|
416
461
|
source.onended = null;
|
|
417
462
|
try {
|
|
@@ -421,24 +466,37 @@ var AudioManager = class {
|
|
|
421
466
|
}
|
|
422
467
|
}
|
|
423
468
|
this.audioSources = [];
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
469
|
+
this.gainNode?.disconnect();
|
|
470
|
+
this.gainNode = null;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Creates and starts audio sources from the current playback position.
|
|
474
|
+
*/
|
|
475
|
+
startAudioSources() {
|
|
476
|
+
this.stopAudioSources();
|
|
431
477
|
this.gainNode = this.audioCtx.createGain();
|
|
432
478
|
this.gainNode.gain.value = this._volume;
|
|
433
479
|
this.gainNode.connect(this.audioCtx.destination);
|
|
434
480
|
let endedCount = 0;
|
|
435
481
|
const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1e3;
|
|
436
|
-
|
|
482
|
+
if (this.audioBuffers.length === 0) {
|
|
483
|
+
if (this.endEventTimeout) {
|
|
484
|
+
clearTimeout(this.endEventTimeout);
|
|
485
|
+
this.endEventTimeout = null;
|
|
486
|
+
}
|
|
487
|
+
const remainingMs = this.chartEndTimeMs - this.lastSeekChartTimeMs;
|
|
488
|
+
if (remainingMs > 0) {
|
|
489
|
+
this.endEventTimeout = setTimeout(() => {
|
|
490
|
+
this.eventEmitter.emit("end");
|
|
491
|
+
}, remainingMs);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const audioBuffer of this.audioBuffers) {
|
|
437
495
|
const source = this.audioCtx.createBufferSource();
|
|
438
496
|
source.buffer = audioBuffer;
|
|
439
497
|
source.onended = () => {
|
|
440
498
|
endedCount++;
|
|
441
|
-
if (endedCount === audioBuffers.length) {
|
|
499
|
+
if (endedCount === this.audioBuffers.length && !this._isPaused) {
|
|
442
500
|
this.eventEmitter.emit("end");
|
|
443
501
|
}
|
|
444
502
|
};
|
|
@@ -450,29 +508,20 @@ var AudioManager = class {
|
|
|
450
508
|
this.audioSources.push(source);
|
|
451
509
|
}
|
|
452
510
|
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
453
|
-
this.pause();
|
|
454
511
|
}
|
|
455
512
|
/**
|
|
456
513
|
* @param percentComplete The progress between the start and end of the preview.
|
|
457
514
|
*/
|
|
458
515
|
async seek(percentComplete) {
|
|
459
|
-
if (this.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
for (const source of this.audioSources) {
|
|
463
|
-
source.onended = null;
|
|
464
|
-
try {
|
|
465
|
-
source.stop();
|
|
466
|
-
source.disconnect();
|
|
467
|
-
} catch {
|
|
468
|
-
}
|
|
516
|
+
if (this.endEventTimeout) {
|
|
517
|
+
clearTimeout(this.endEventTimeout);
|
|
518
|
+
this.endEventTimeout = null;
|
|
469
519
|
}
|
|
470
|
-
this.
|
|
471
|
-
this.gainNode?.disconnect();
|
|
472
|
-
this.gainNode = null;
|
|
520
|
+
this.stopAudioSources();
|
|
473
521
|
const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
|
|
474
522
|
this.lastSeekChartTimeMs = chartSeekTimeMs;
|
|
475
523
|
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
524
|
+
this._isPaused = true;
|
|
476
525
|
}
|
|
477
526
|
};
|
|
478
527
|
var SilentAudioManager = class {
|
|
@@ -517,7 +566,8 @@ var SilentAudioManager = class {
|
|
|
517
566
|
}
|
|
518
567
|
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
519
568
|
get chartEndTimeMs() {
|
|
520
|
-
|
|
569
|
+
const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
|
|
570
|
+
return Math.max(theoreticalEndTime, this.audioLengthMs, 0);
|
|
521
571
|
}
|
|
522
572
|
async play() {
|
|
523
573
|
if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
|
|
@@ -1218,7 +1268,6 @@ var STYLES = `
|
|
|
1218
1268
|
--accent-color: #3b82f6;
|
|
1219
1269
|
--text-color: #e0e0e0;
|
|
1220
1270
|
--text-muted: #aaa;
|
|
1221
|
-
--text-idle: #666;
|
|
1222
1271
|
--error-color: #ef4444;
|
|
1223
1272
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
|
1224
1273
|
|
|
@@ -1290,8 +1339,8 @@ var STYLES = `
|
|
|
1290
1339
|
align-items: center;
|
|
1291
1340
|
justify-content: center;
|
|
1292
1341
|
height: 40px;
|
|
1293
|
-
width:
|
|
1294
|
-
padding:
|
|
1342
|
+
width: 32px;
|
|
1343
|
+
padding: 6px;
|
|
1295
1344
|
transition: background 0.15s;
|
|
1296
1345
|
flex-shrink: 0;
|
|
1297
1346
|
}
|
|
@@ -1376,10 +1425,10 @@ var STYLES = `
|
|
|
1376
1425
|
.timestamp {
|
|
1377
1426
|
color: var(--text-muted);
|
|
1378
1427
|
font-size: 12px;
|
|
1379
|
-
padding: 0
|
|
1428
|
+
padding: 0 2px;
|
|
1380
1429
|
white-space: nowrap;
|
|
1381
1430
|
flex-shrink: 0;
|
|
1382
|
-
min-width:
|
|
1431
|
+
min-width: 70px;
|
|
1383
1432
|
text-align: center;
|
|
1384
1433
|
}
|
|
1385
1434
|
|
|
@@ -1387,7 +1436,7 @@ var STYLES = `
|
|
|
1387
1436
|
flex: 1;
|
|
1388
1437
|
display: flex;
|
|
1389
1438
|
align-items: center;
|
|
1390
|
-
padding: 0
|
|
1439
|
+
padding: 0 4px;
|
|
1391
1440
|
min-width: 0;
|
|
1392
1441
|
}
|
|
1393
1442
|
|
|
@@ -1429,11 +1478,6 @@ var STYLES = `
|
|
|
1429
1478
|
cursor: not-allowed;
|
|
1430
1479
|
}
|
|
1431
1480
|
|
|
1432
|
-
.idle-message {
|
|
1433
|
-
color: var(--text-idle);
|
|
1434
|
-
font-size: 14px;
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
1481
|
.error-message {
|
|
1438
1482
|
color: var(--error-color);
|
|
1439
1483
|
font-size: 14px;
|
|
@@ -1538,7 +1582,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1538
1582
|
<style>${STYLES}</style>
|
|
1539
1583
|
<div class="player-container">
|
|
1540
1584
|
<div class="preview-viewport">
|
|
1541
|
-
<span class="idle-message">Click play to start</span>
|
|
1542
1585
|
</div>
|
|
1543
1586
|
<div class="controls">
|
|
1544
1587
|
<button class="control-btn play-btn" aria-label="Play" disabled>
|
|
@@ -1656,9 +1699,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1656
1699
|
*/
|
|
1657
1700
|
prepareForLoading() {
|
|
1658
1701
|
this.setState("loading");
|
|
1659
|
-
const idleMessage = this.viewport.querySelector(".idle-message");
|
|
1660
|
-
if (idleMessage)
|
|
1661
|
-
idleMessage.remove();
|
|
1662
1702
|
const errorMessage = this.viewport.querySelector(".error-message");
|
|
1663
1703
|
if (errorMessage)
|
|
1664
1704
|
errorMessage.remove();
|
|
@@ -1701,7 +1741,7 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1701
1741
|
updatePlayButton() {
|
|
1702
1742
|
switch (this._state) {
|
|
1703
1743
|
case "idle":
|
|
1704
|
-
this.playBtn.disabled =
|
|
1744
|
+
this.playBtn.disabled = false;
|
|
1705
1745
|
this.playBtn.innerHTML = ICONS.play;
|
|
1706
1746
|
this.playBtn.setAttribute("aria-label", "Play");
|
|
1707
1747
|
break;
|
package/dist/index.mjs
CHANGED
|
@@ -109,7 +109,11 @@ var _ChartPreview = class {
|
|
|
109
109
|
chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
|
|
110
110
|
chartPreview.camera = new ChartCamera(config.container);
|
|
111
111
|
chartPreview.renderer = new ChartRenderer(config.container);
|
|
112
|
-
chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(
|
|
112
|
+
chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(
|
|
113
|
+
config.audioFiles,
|
|
114
|
+
config.startDelayMs,
|
|
115
|
+
config.audioLengthMs
|
|
116
|
+
) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
|
|
113
117
|
chartPreview.audioManager.on(
|
|
114
118
|
"end",
|
|
115
119
|
() => chartPreview.eventEmitter.emit("end")
|
|
@@ -287,10 +291,13 @@ var ChartRenderer = class extends THREE.WebGLRenderer {
|
|
|
287
291
|
}
|
|
288
292
|
};
|
|
289
293
|
var AudioManager = class {
|
|
290
|
-
|
|
294
|
+
// Track paused state independently since we share the AudioContext
|
|
295
|
+
constructor(audioFiles, startDelayMs, fallbackAudioLengthMs) {
|
|
291
296
|
this.audioFiles = audioFiles;
|
|
292
297
|
this.startDelayMs = startDelayMs;
|
|
298
|
+
this.fallbackAudioLengthMs = fallbackAudioLengthMs;
|
|
293
299
|
this.eventEmitter = new EventEmitter();
|
|
300
|
+
this.audioBuffers = [];
|
|
294
301
|
this.gainNode = null;
|
|
295
302
|
this.audioSources = [];
|
|
296
303
|
this._volume = 0.5;
|
|
@@ -298,6 +305,8 @@ var AudioManager = class {
|
|
|
298
305
|
this.lastAudioCtxCurrentTime = 0;
|
|
299
306
|
// Necessary because audioCtx.currentTime doesn't reset to 0 on seek
|
|
300
307
|
this.audioLengthMs = 0;
|
|
308
|
+
this.endEventTimeout = null;
|
|
309
|
+
this._isPaused = true;
|
|
301
310
|
const ctx = getSharedAudioContext();
|
|
302
311
|
if (!ctx) {
|
|
303
312
|
throw new Error("AudioContext is not supported in this environment");
|
|
@@ -308,10 +317,15 @@ var AudioManager = class {
|
|
|
308
317
|
/**
|
|
309
318
|
* @param audioFiles The `Uint8Array[]` of the audio files to be played.
|
|
310
319
|
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
|
320
|
+
* @param fallbackAudioLengthMs The fallback audio length to use if no audio files are provided.
|
|
311
321
|
*/
|
|
312
|
-
static async create(audioFiles, startDelayMs) {
|
|
313
|
-
const audioManager = new AudioManager(
|
|
314
|
-
|
|
322
|
+
static async create(audioFiles, startDelayMs, fallbackAudioLengthMs) {
|
|
323
|
+
const audioManager = new AudioManager(
|
|
324
|
+
audioFiles,
|
|
325
|
+
startDelayMs,
|
|
326
|
+
fallbackAudioLengthMs
|
|
327
|
+
);
|
|
328
|
+
await audioManager.decodeAudioFiles();
|
|
315
329
|
return audioManager;
|
|
316
330
|
}
|
|
317
331
|
/**
|
|
@@ -335,44 +349,75 @@ var AudioManager = class {
|
|
|
335
349
|
}
|
|
336
350
|
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
|
|
337
351
|
get chartCurrentTimeMs() {
|
|
338
|
-
|
|
352
|
+
if (this._isPaused) {
|
|
353
|
+
return this.lastSeekChartTimeMs;
|
|
354
|
+
}
|
|
339
355
|
const audioLatency = (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1e3;
|
|
340
356
|
const audioTimeSinceLastSeekMs = (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1e3;
|
|
341
|
-
return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs -
|
|
357
|
+
return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - audioLatency;
|
|
342
358
|
}
|
|
343
359
|
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
344
360
|
get chartEndTimeMs() {
|
|
345
|
-
|
|
361
|
+
const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
|
|
362
|
+
return Math.max(
|
|
363
|
+
theoreticalEndTime,
|
|
364
|
+
this.audioLengthMs,
|
|
365
|
+
this.fallbackAudioLengthMs,
|
|
366
|
+
0
|
|
367
|
+
);
|
|
346
368
|
}
|
|
347
369
|
async play() {
|
|
348
|
-
if (this.gainNode === null) {
|
|
349
|
-
await this.initAudio();
|
|
350
|
-
}
|
|
351
370
|
if (this.audioCtx.state === "suspended") {
|
|
352
371
|
await this.audioCtx.resume();
|
|
353
372
|
}
|
|
373
|
+
this.startAudioSources();
|
|
374
|
+
this._isPaused = false;
|
|
375
|
+
if (this.audioBuffers.length === 0 && !this.endEventTimeout) {
|
|
376
|
+
const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
|
|
377
|
+
if (remainingMs > 0) {
|
|
378
|
+
this.endEventTimeout = setTimeout(() => {
|
|
379
|
+
this.eventEmitter.emit("end");
|
|
380
|
+
}, remainingMs);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
354
383
|
}
|
|
355
384
|
async pause() {
|
|
356
|
-
|
|
357
|
-
|
|
385
|
+
const currentTime = this.chartCurrentTimeMs;
|
|
386
|
+
this._isPaused = true;
|
|
387
|
+
this.lastSeekChartTimeMs = currentTime;
|
|
388
|
+
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
389
|
+
if (this.endEventTimeout) {
|
|
390
|
+
clearTimeout(this.endEventTimeout);
|
|
391
|
+
this.endEventTimeout = null;
|
|
358
392
|
}
|
|
393
|
+
this.stopAudioSources();
|
|
359
394
|
}
|
|
360
395
|
closeAudio() {
|
|
361
396
|
this.eventEmitter.removeAllListeners();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
source.disconnect();
|
|
367
|
-
} catch {
|
|
368
|
-
}
|
|
397
|
+
this._isPaused = true;
|
|
398
|
+
if (this.endEventTimeout) {
|
|
399
|
+
clearTimeout(this.endEventTimeout);
|
|
400
|
+
this.endEventTimeout = null;
|
|
369
401
|
}
|
|
370
|
-
this.
|
|
371
|
-
this.gainNode?.disconnect();
|
|
372
|
-
this.gainNode = null;
|
|
402
|
+
this.stopAudioSources();
|
|
373
403
|
unregisterInstance();
|
|
374
404
|
}
|
|
375
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Decodes audio files into AudioBuffers. Called once during initialization.
|
|
407
|
+
*/
|
|
408
|
+
async decodeAudioFiles() {
|
|
409
|
+
this.audioBuffers = await Promise.all(
|
|
410
|
+
this.audioFiles.map(
|
|
411
|
+
(file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
|
|
412
|
+
)
|
|
413
|
+
);
|
|
414
|
+
const calculatedLengthMs = this.audioBuffers.length > 0 ? Math.max(...this.audioBuffers.map((b) => b.duration)) * 1e3 : 0;
|
|
415
|
+
this.audioLengthMs = calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Stops all currently playing audio sources for this instance.
|
|
419
|
+
*/
|
|
420
|
+
stopAudioSources() {
|
|
376
421
|
for (const source of this.audioSources) {
|
|
377
422
|
source.onended = null;
|
|
378
423
|
try {
|
|
@@ -382,24 +427,37 @@ var AudioManager = class {
|
|
|
382
427
|
}
|
|
383
428
|
}
|
|
384
429
|
this.audioSources = [];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
430
|
+
this.gainNode?.disconnect();
|
|
431
|
+
this.gainNode = null;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Creates and starts audio sources from the current playback position.
|
|
435
|
+
*/
|
|
436
|
+
startAudioSources() {
|
|
437
|
+
this.stopAudioSources();
|
|
392
438
|
this.gainNode = this.audioCtx.createGain();
|
|
393
439
|
this.gainNode.gain.value = this._volume;
|
|
394
440
|
this.gainNode.connect(this.audioCtx.destination);
|
|
395
441
|
let endedCount = 0;
|
|
396
442
|
const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1e3;
|
|
397
|
-
|
|
443
|
+
if (this.audioBuffers.length === 0) {
|
|
444
|
+
if (this.endEventTimeout) {
|
|
445
|
+
clearTimeout(this.endEventTimeout);
|
|
446
|
+
this.endEventTimeout = null;
|
|
447
|
+
}
|
|
448
|
+
const remainingMs = this.chartEndTimeMs - this.lastSeekChartTimeMs;
|
|
449
|
+
if (remainingMs > 0) {
|
|
450
|
+
this.endEventTimeout = setTimeout(() => {
|
|
451
|
+
this.eventEmitter.emit("end");
|
|
452
|
+
}, remainingMs);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const audioBuffer of this.audioBuffers) {
|
|
398
456
|
const source = this.audioCtx.createBufferSource();
|
|
399
457
|
source.buffer = audioBuffer;
|
|
400
458
|
source.onended = () => {
|
|
401
459
|
endedCount++;
|
|
402
|
-
if (endedCount === audioBuffers.length) {
|
|
460
|
+
if (endedCount === this.audioBuffers.length && !this._isPaused) {
|
|
403
461
|
this.eventEmitter.emit("end");
|
|
404
462
|
}
|
|
405
463
|
};
|
|
@@ -411,29 +469,20 @@ var AudioManager = class {
|
|
|
411
469
|
this.audioSources.push(source);
|
|
412
470
|
}
|
|
413
471
|
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
414
|
-
this.pause();
|
|
415
472
|
}
|
|
416
473
|
/**
|
|
417
474
|
* @param percentComplete The progress between the start and end of the preview.
|
|
418
475
|
*/
|
|
419
476
|
async seek(percentComplete) {
|
|
420
|
-
if (this.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
for (const source of this.audioSources) {
|
|
424
|
-
source.onended = null;
|
|
425
|
-
try {
|
|
426
|
-
source.stop();
|
|
427
|
-
source.disconnect();
|
|
428
|
-
} catch {
|
|
429
|
-
}
|
|
477
|
+
if (this.endEventTimeout) {
|
|
478
|
+
clearTimeout(this.endEventTimeout);
|
|
479
|
+
this.endEventTimeout = null;
|
|
430
480
|
}
|
|
431
|
-
this.
|
|
432
|
-
this.gainNode?.disconnect();
|
|
433
|
-
this.gainNode = null;
|
|
481
|
+
this.stopAudioSources();
|
|
434
482
|
const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
|
|
435
483
|
this.lastSeekChartTimeMs = chartSeekTimeMs;
|
|
436
484
|
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
485
|
+
this._isPaused = true;
|
|
437
486
|
}
|
|
438
487
|
};
|
|
439
488
|
var SilentAudioManager = class {
|
|
@@ -478,7 +527,8 @@ var SilentAudioManager = class {
|
|
|
478
527
|
}
|
|
479
528
|
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
480
529
|
get chartEndTimeMs() {
|
|
481
|
-
|
|
530
|
+
const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
|
|
531
|
+
return Math.max(theoreticalEndTime, this.audioLengthMs, 0);
|
|
482
532
|
}
|
|
483
533
|
async play() {
|
|
484
534
|
if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
|
|
@@ -1183,7 +1233,6 @@ var STYLES = `
|
|
|
1183
1233
|
--accent-color: #3b82f6;
|
|
1184
1234
|
--text-color: #e0e0e0;
|
|
1185
1235
|
--text-muted: #aaa;
|
|
1186
|
-
--text-idle: #666;
|
|
1187
1236
|
--error-color: #ef4444;
|
|
1188
1237
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
|
1189
1238
|
|
|
@@ -1255,8 +1304,8 @@ var STYLES = `
|
|
|
1255
1304
|
align-items: center;
|
|
1256
1305
|
justify-content: center;
|
|
1257
1306
|
height: 40px;
|
|
1258
|
-
width:
|
|
1259
|
-
padding:
|
|
1307
|
+
width: 32px;
|
|
1308
|
+
padding: 6px;
|
|
1260
1309
|
transition: background 0.15s;
|
|
1261
1310
|
flex-shrink: 0;
|
|
1262
1311
|
}
|
|
@@ -1341,10 +1390,10 @@ var STYLES = `
|
|
|
1341
1390
|
.timestamp {
|
|
1342
1391
|
color: var(--text-muted);
|
|
1343
1392
|
font-size: 12px;
|
|
1344
|
-
padding: 0
|
|
1393
|
+
padding: 0 2px;
|
|
1345
1394
|
white-space: nowrap;
|
|
1346
1395
|
flex-shrink: 0;
|
|
1347
|
-
min-width:
|
|
1396
|
+
min-width: 70px;
|
|
1348
1397
|
text-align: center;
|
|
1349
1398
|
}
|
|
1350
1399
|
|
|
@@ -1352,7 +1401,7 @@ var STYLES = `
|
|
|
1352
1401
|
flex: 1;
|
|
1353
1402
|
display: flex;
|
|
1354
1403
|
align-items: center;
|
|
1355
|
-
padding: 0
|
|
1404
|
+
padding: 0 4px;
|
|
1356
1405
|
min-width: 0;
|
|
1357
1406
|
}
|
|
1358
1407
|
|
|
@@ -1394,11 +1443,6 @@ var STYLES = `
|
|
|
1394
1443
|
cursor: not-allowed;
|
|
1395
1444
|
}
|
|
1396
1445
|
|
|
1397
|
-
.idle-message {
|
|
1398
|
-
color: var(--text-idle);
|
|
1399
|
-
font-size: 14px;
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
1446
|
.error-message {
|
|
1403
1447
|
color: var(--error-color);
|
|
1404
1448
|
font-size: 14px;
|
|
@@ -1503,7 +1547,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1503
1547
|
<style>${STYLES}</style>
|
|
1504
1548
|
<div class="player-container">
|
|
1505
1549
|
<div class="preview-viewport">
|
|
1506
|
-
<span class="idle-message">Click play to start</span>
|
|
1507
1550
|
</div>
|
|
1508
1551
|
<div class="controls">
|
|
1509
1552
|
<button class="control-btn play-btn" aria-label="Play" disabled>
|
|
@@ -1621,9 +1664,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1621
1664
|
*/
|
|
1622
1665
|
prepareForLoading() {
|
|
1623
1666
|
this.setState("loading");
|
|
1624
|
-
const idleMessage = this.viewport.querySelector(".idle-message");
|
|
1625
|
-
if (idleMessage)
|
|
1626
|
-
idleMessage.remove();
|
|
1627
1667
|
const errorMessage = this.viewport.querySelector(".error-message");
|
|
1628
1668
|
if (errorMessage)
|
|
1629
1669
|
errorMessage.remove();
|
|
@@ -1666,7 +1706,7 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1666
1706
|
updatePlayButton() {
|
|
1667
1707
|
switch (this._state) {
|
|
1668
1708
|
case "idle":
|
|
1669
|
-
this.playBtn.disabled =
|
|
1709
|
+
this.playBtn.disabled = false;
|
|
1670
1710
|
this.playBtn.innerHTML = ICONS.play;
|
|
1671
1711
|
this.playBtn.setAttribute("aria-label", "Play");
|
|
1672
1712
|
break;
|
package/package.json
CHANGED
package/src/ChartPreview.ts
CHANGED
|
@@ -167,7 +167,11 @@ export class ChartPreview {
|
|
|
167
167
|
chartPreview.camera = new ChartCamera(config.container);
|
|
168
168
|
chartPreview.renderer = new ChartRenderer(config.container);
|
|
169
169
|
chartPreview.audioManager = await (isAudioContextSupported()
|
|
170
|
-
? AudioManager.create(
|
|
170
|
+
? AudioManager.create(
|
|
171
|
+
config.audioFiles,
|
|
172
|
+
config.startDelayMs,
|
|
173
|
+
config.audioLengthMs
|
|
174
|
+
)
|
|
171
175
|
: SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
|
|
172
176
|
chartPreview.audioManager.on("end", () =>
|
|
173
177
|
chartPreview.eventEmitter.emit("end")
|
|
@@ -383,16 +387,20 @@ class AudioManager {
|
|
|
383
387
|
private eventEmitter = new EventEmitter();
|
|
384
388
|
|
|
385
389
|
private audioCtx: AudioContext;
|
|
390
|
+
private audioBuffers: AudioBuffer[] = [];
|
|
386
391
|
private gainNode: GainNode | null = null;
|
|
387
392
|
private audioSources: AudioBufferSourceNode[] = [];
|
|
388
393
|
private _volume = 0.5;
|
|
389
394
|
private lastSeekChartTimeMs = 0;
|
|
390
395
|
private lastAudioCtxCurrentTime = 0; // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
|
|
391
396
|
private audioLengthMs: number = 0;
|
|
397
|
+
private endEventTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
398
|
+
private _isPaused = true; // Track paused state independently since we share the AudioContext
|
|
392
399
|
|
|
393
400
|
private constructor(
|
|
394
401
|
private audioFiles: Uint8Array[],
|
|
395
|
-
private startDelayMs: number
|
|
402
|
+
private startDelayMs: number,
|
|
403
|
+
private fallbackAudioLengthMs: number
|
|
396
404
|
) {
|
|
397
405
|
const ctx = getSharedAudioContext();
|
|
398
406
|
if (!ctx) {
|
|
@@ -405,10 +413,19 @@ class AudioManager {
|
|
|
405
413
|
/**
|
|
406
414
|
* @param audioFiles The `Uint8Array[]` of the audio files to be played.
|
|
407
415
|
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
|
416
|
+
* @param fallbackAudioLengthMs The fallback audio length to use if no audio files are provided.
|
|
408
417
|
*/
|
|
409
|
-
static async create(
|
|
410
|
-
|
|
411
|
-
|
|
418
|
+
static async create(
|
|
419
|
+
audioFiles: Uint8Array[],
|
|
420
|
+
startDelayMs: number,
|
|
421
|
+
fallbackAudioLengthMs: number
|
|
422
|
+
) {
|
|
423
|
+
const audioManager = new AudioManager(
|
|
424
|
+
audioFiles,
|
|
425
|
+
startDelayMs,
|
|
426
|
+
fallbackAudioLengthMs
|
|
427
|
+
);
|
|
428
|
+
await audioManager.decodeAudioFiles();
|
|
412
429
|
return audioManager;
|
|
413
430
|
}
|
|
414
431
|
|
|
@@ -438,68 +455,111 @@ class AudioManager {
|
|
|
438
455
|
|
|
439
456
|
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
|
|
440
457
|
get chartCurrentTimeMs() {
|
|
441
|
-
|
|
458
|
+
// Use our own paused state since we share the AudioContext with other instances
|
|
459
|
+
// and can't rely on audioCtx.state to determine this instance's state
|
|
460
|
+
if (this._isPaused) {
|
|
461
|
+
return this.lastSeekChartTimeMs;
|
|
462
|
+
}
|
|
442
463
|
// outputLatency is not implemented in safari
|
|
443
464
|
const audioLatency =
|
|
444
465
|
(this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1000;
|
|
445
466
|
const audioTimeSinceLastSeekMs =
|
|
446
467
|
(this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1000;
|
|
447
|
-
|
|
448
|
-
// The solution here is to represent that visually by jumping ahead slightly by ignoring latency when paused.
|
|
449
|
-
// If this is a more significant problem, it can be fixed by seeking backward by `audioLatency`.
|
|
450
|
-
return (
|
|
451
|
-
this.lastSeekChartTimeMs +
|
|
452
|
-
audioTimeSinceLastSeekMs -
|
|
453
|
-
(isPaused ? 0 : audioLatency)
|
|
454
|
-
);
|
|
468
|
+
return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - audioLatency;
|
|
455
469
|
}
|
|
456
470
|
|
|
457
471
|
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
458
472
|
get chartEndTimeMs() {
|
|
459
|
-
|
|
473
|
+
// Calculate the theoretical end time based on start delay + audio length
|
|
474
|
+
const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
|
|
475
|
+
// Ensure we always have a positive end time - use audio length as minimum if startDelay is extremely negative
|
|
476
|
+
// This handles edge cases where chart metadata has invalid/extreme delay values
|
|
477
|
+
return Math.max(
|
|
478
|
+
theoreticalEndTime,
|
|
479
|
+
this.audioLengthMs,
|
|
480
|
+
this.fallbackAudioLengthMs,
|
|
481
|
+
0
|
|
482
|
+
);
|
|
460
483
|
}
|
|
461
484
|
|
|
462
485
|
async play() {
|
|
463
|
-
//
|
|
464
|
-
if (this.gainNode === null) {
|
|
465
|
-
await this.initAudio();
|
|
466
|
-
}
|
|
467
|
-
// Resume context if suspended
|
|
486
|
+
// Resume the shared context if suspended (needed for initial user interaction)
|
|
468
487
|
if (this.audioCtx.state === "suspended") {
|
|
469
488
|
await this.audioCtx.resume();
|
|
470
489
|
}
|
|
490
|
+
|
|
491
|
+
// Start audio playback
|
|
492
|
+
this.startAudioSources();
|
|
493
|
+
this._isPaused = false;
|
|
494
|
+
|
|
495
|
+
// Start end event timeout for charts without audio
|
|
496
|
+
if (this.audioBuffers.length === 0 && !this.endEventTimeout) {
|
|
497
|
+
const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
|
|
498
|
+
if (remainingMs > 0) {
|
|
499
|
+
this.endEventTimeout = setTimeout(() => {
|
|
500
|
+
this.eventEmitter.emit("end");
|
|
501
|
+
}, remainingMs);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
471
504
|
}
|
|
505
|
+
|
|
472
506
|
async pause() {
|
|
473
|
-
|
|
474
|
-
|
|
507
|
+
// Record current playback position before stopping
|
|
508
|
+
const currentTime = this.chartCurrentTimeMs;
|
|
509
|
+
this._isPaused = true;
|
|
510
|
+
this.lastSeekChartTimeMs = currentTime;
|
|
511
|
+
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
512
|
+
|
|
513
|
+
// Clear end event timeout when pausing
|
|
514
|
+
if (this.endEventTimeout) {
|
|
515
|
+
clearTimeout(this.endEventTimeout);
|
|
516
|
+
this.endEventTimeout = null;
|
|
475
517
|
}
|
|
518
|
+
|
|
519
|
+
// Stop all audio sources for this instance only
|
|
520
|
+
// (Don't suspend the shared AudioContext - other instances may be playing)
|
|
521
|
+
this.stopAudioSources();
|
|
476
522
|
}
|
|
477
523
|
|
|
478
524
|
closeAudio() {
|
|
479
525
|
this.eventEmitter.removeAllListeners();
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
source.stop();
|
|
486
|
-
source.disconnect();
|
|
487
|
-
} catch {
|
|
488
|
-
// Source may already be stopped
|
|
489
|
-
}
|
|
526
|
+
this._isPaused = true;
|
|
527
|
+
// Clear end event timeout
|
|
528
|
+
if (this.endEventTimeout) {
|
|
529
|
+
clearTimeout(this.endEventTimeout);
|
|
530
|
+
this.endEventTimeout = null;
|
|
490
531
|
}
|
|
491
|
-
|
|
492
|
-
this.
|
|
493
|
-
this.gainNode = null;
|
|
532
|
+
// Stop all audio sources
|
|
533
|
+
this.stopAudioSources();
|
|
494
534
|
// Don't close the shared context - just unregister this instance
|
|
495
535
|
unregisterInstance();
|
|
496
536
|
}
|
|
497
537
|
|
|
498
|
-
|
|
499
|
-
|
|
538
|
+
/**
|
|
539
|
+
* Decodes audio files into AudioBuffers. Called once during initialization.
|
|
540
|
+
*/
|
|
541
|
+
private async decodeAudioFiles() {
|
|
542
|
+
this.audioBuffers = await Promise.all(
|
|
543
|
+
this.audioFiles.map((file) =>
|
|
544
|
+
this.audioCtx.decodeAudioData(file.slice(0).buffer)
|
|
545
|
+
)
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
// Use fallback if no audio files or if calculated duration is invalid
|
|
549
|
+
const calculatedLengthMs =
|
|
550
|
+
this.audioBuffers.length > 0
|
|
551
|
+
? Math.max(...this.audioBuffers.map((b) => b.duration)) * 1000
|
|
552
|
+
: 0;
|
|
553
|
+
this.audioLengthMs =
|
|
554
|
+
calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Stops all currently playing audio sources for this instance.
|
|
559
|
+
*/
|
|
560
|
+
private stopAudioSources() {
|
|
500
561
|
for (const source of this.audioSources) {
|
|
501
|
-
|
|
502
|
-
source.onended = null;
|
|
562
|
+
source.onended = null; // Prevent false "end" events
|
|
503
563
|
try {
|
|
504
564
|
source.stop();
|
|
505
565
|
source.disconnect();
|
|
@@ -508,15 +568,16 @@ class AudioManager {
|
|
|
508
568
|
}
|
|
509
569
|
}
|
|
510
570
|
this.audioSources = [];
|
|
571
|
+
this.gainNode?.disconnect();
|
|
572
|
+
this.gainNode = null;
|
|
573
|
+
}
|
|
511
574
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
);
|
|
518
|
-
this.audioLengthMs =
|
|
519
|
-
Math.max(...audioBuffers.map((b) => b.duration)) * 1000;
|
|
575
|
+
/**
|
|
576
|
+
* Creates and starts audio sources from the current playback position.
|
|
577
|
+
*/
|
|
578
|
+
private startAudioSources() {
|
|
579
|
+
// Stop any existing sources before creating new ones
|
|
580
|
+
this.stopAudioSources();
|
|
520
581
|
|
|
521
582
|
this.gainNode = this.audioCtx.createGain();
|
|
522
583
|
this.gainNode.gain.value = this._volume;
|
|
@@ -525,18 +586,39 @@ class AudioManager {
|
|
|
525
586
|
let endedCount = 0;
|
|
526
587
|
const audioStartOffsetSeconds =
|
|
527
588
|
(this.lastSeekChartTimeMs - this.startDelayMs) / 1000;
|
|
528
|
-
|
|
589
|
+
|
|
590
|
+
// If no audio files, use a timeout to emit "end" event
|
|
591
|
+
if (this.audioBuffers.length === 0) {
|
|
592
|
+
// Clear any existing timeout
|
|
593
|
+
if (this.endEventTimeout) {
|
|
594
|
+
clearTimeout(this.endEventTimeout);
|
|
595
|
+
this.endEventTimeout = null;
|
|
596
|
+
}
|
|
597
|
+
// Schedule end event based on remaining time
|
|
598
|
+
const remainingMs = this.chartEndTimeMs - this.lastSeekChartTimeMs;
|
|
599
|
+
if (remainingMs > 0) {
|
|
600
|
+
this.endEventTimeout = setTimeout(() => {
|
|
601
|
+
this.eventEmitter.emit("end");
|
|
602
|
+
}, remainingMs);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
for (const audioBuffer of this.audioBuffers) {
|
|
529
607
|
const source = this.audioCtx.createBufferSource();
|
|
530
608
|
source.buffer = audioBuffer;
|
|
531
609
|
source.onended = () => {
|
|
532
610
|
endedCount++;
|
|
533
|
-
if (endedCount === audioBuffers.length) {
|
|
611
|
+
if (endedCount === this.audioBuffers.length && !this._isPaused) {
|
|
534
612
|
this.eventEmitter.emit("end");
|
|
535
613
|
}
|
|
536
614
|
};
|
|
537
615
|
source.connect(this.gainNode!);
|
|
538
616
|
// When using a shared AudioContext, we need to schedule relative to the current time,
|
|
539
617
|
// not absolute time 0, since the context may have been running for a while
|
|
618
|
+
//
|
|
619
|
+
// audioStartOffsetSeconds calculation:
|
|
620
|
+
// - Positive value: Start audio from this position (audio is "behind" chart time)
|
|
621
|
+
// - Negative value: Delay audio start by |value| seconds (audio hasn't started yet at chart time 0)
|
|
540
622
|
const delaySeconds = Math.abs(Math.min(audioStartOffsetSeconds, 0));
|
|
541
623
|
const when = this.audioCtx.currentTime + delaySeconds;
|
|
542
624
|
const offset = Math.max(audioStartOffsetSeconds, 0);
|
|
@@ -544,36 +626,25 @@ class AudioManager {
|
|
|
544
626
|
this.audioSources.push(source);
|
|
545
627
|
}
|
|
546
628
|
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
547
|
-
this.pause();
|
|
548
629
|
}
|
|
549
630
|
|
|
550
631
|
/**
|
|
551
632
|
* @param percentComplete The progress between the start and end of the preview.
|
|
552
633
|
*/
|
|
553
634
|
async seek(percentComplete: number) {
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
635
|
+
// Clear end event timeout when seeking
|
|
636
|
+
if (this.endEventTimeout) {
|
|
637
|
+
clearTimeout(this.endEventTimeout);
|
|
638
|
+
this.endEventTimeout = null;
|
|
558
639
|
}
|
|
559
640
|
|
|
560
641
|
// Stop existing audio sources
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
source.onended = null;
|
|
564
|
-
try {
|
|
565
|
-
source.stop();
|
|
566
|
-
source.disconnect();
|
|
567
|
-
} catch {
|
|
568
|
-
// Source may already be stopped
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
this.audioSources = [];
|
|
572
|
-
this.gainNode?.disconnect();
|
|
573
|
-
this.gainNode = null;
|
|
642
|
+
this.stopAudioSources();
|
|
643
|
+
|
|
574
644
|
const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
|
|
575
645
|
this.lastSeekChartTimeMs = chartSeekTimeMs;
|
|
576
646
|
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
647
|
+
this._isPaused = true;
|
|
577
648
|
}
|
|
578
649
|
}
|
|
579
650
|
|
|
@@ -635,7 +706,10 @@ class SilentAudioManager {
|
|
|
635
706
|
}
|
|
636
707
|
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
637
708
|
get chartEndTimeMs() {
|
|
638
|
-
|
|
709
|
+
// Calculate the theoretical end time based on start delay + audio length
|
|
710
|
+
const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
|
|
711
|
+
// Ensure we always have a positive end time - use audio length as minimum if startDelay is extremely negative
|
|
712
|
+
return Math.max(theoreticalEndTime, this.audioLengthMs, 0);
|
|
639
713
|
}
|
|
640
714
|
|
|
641
715
|
async play() {
|
|
@@ -57,7 +57,6 @@ const STYLES = `
|
|
|
57
57
|
--accent-color: #3b82f6;
|
|
58
58
|
--text-color: #e0e0e0;
|
|
59
59
|
--text-muted: #aaa;
|
|
60
|
-
--text-idle: #666;
|
|
61
60
|
--error-color: #ef4444;
|
|
62
61
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
|
63
62
|
|
|
@@ -129,8 +128,8 @@ const STYLES = `
|
|
|
129
128
|
align-items: center;
|
|
130
129
|
justify-content: center;
|
|
131
130
|
height: 40px;
|
|
132
|
-
width:
|
|
133
|
-
padding:
|
|
131
|
+
width: 32px;
|
|
132
|
+
padding: 6px;
|
|
134
133
|
transition: background 0.15s;
|
|
135
134
|
flex-shrink: 0;
|
|
136
135
|
}
|
|
@@ -215,10 +214,10 @@ const STYLES = `
|
|
|
215
214
|
.timestamp {
|
|
216
215
|
color: var(--text-muted);
|
|
217
216
|
font-size: 12px;
|
|
218
|
-
padding: 0
|
|
217
|
+
padding: 0 2px;
|
|
219
218
|
white-space: nowrap;
|
|
220
219
|
flex-shrink: 0;
|
|
221
|
-
min-width:
|
|
220
|
+
min-width: 70px;
|
|
222
221
|
text-align: center;
|
|
223
222
|
}
|
|
224
223
|
|
|
@@ -226,7 +225,7 @@ const STYLES = `
|
|
|
226
225
|
flex: 1;
|
|
227
226
|
display: flex;
|
|
228
227
|
align-items: center;
|
|
229
|
-
padding: 0
|
|
228
|
+
padding: 0 4px;
|
|
230
229
|
min-width: 0;
|
|
231
230
|
}
|
|
232
231
|
|
|
@@ -268,11 +267,6 @@ const STYLES = `
|
|
|
268
267
|
cursor: not-allowed;
|
|
269
268
|
}
|
|
270
269
|
|
|
271
|
-
.idle-message {
|
|
272
|
-
color: var(--text-idle);
|
|
273
|
-
font-size: 14px;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
270
|
.error-message {
|
|
277
271
|
color: var(--error-color);
|
|
278
272
|
font-size: 14px;
|
|
@@ -379,7 +373,6 @@ export class ChartPreviewPlayer extends HTMLElement {
|
|
|
379
373
|
<style>${STYLES}</style>
|
|
380
374
|
<div class="player-container">
|
|
381
375
|
<div class="preview-viewport">
|
|
382
|
-
<span class="idle-message">Click play to start</span>
|
|
383
376
|
</div>
|
|
384
377
|
<div class="controls">
|
|
385
378
|
<button class="control-btn play-btn" aria-label="Play" disabled>
|
|
@@ -587,10 +580,6 @@ export class ChartPreviewPlayer extends HTMLElement {
|
|
|
587
580
|
private prepareForLoading(): void {
|
|
588
581
|
this.setState("loading");
|
|
589
582
|
|
|
590
|
-
// Remove idle message
|
|
591
|
-
const idleMessage = this.viewport.querySelector(".idle-message");
|
|
592
|
-
if (idleMessage) idleMessage.remove();
|
|
593
|
-
|
|
594
583
|
// Remove error message if present
|
|
595
584
|
const errorMessage = this.viewport.querySelector(".error-message");
|
|
596
585
|
if (errorMessage) errorMessage.remove();
|
|
@@ -644,7 +633,7 @@ export class ChartPreviewPlayer extends HTMLElement {
|
|
|
644
633
|
private updatePlayButton() {
|
|
645
634
|
switch (this._state) {
|
|
646
635
|
case "idle":
|
|
647
|
-
this.playBtn.disabled =
|
|
636
|
+
this.playBtn.disabled = false;
|
|
648
637
|
this.playBtn.innerHTML = ICONS.play;
|
|
649
638
|
this.playBtn.setAttribute("aria-label", "Play");
|
|
650
639
|
break;
|