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.
@@ -5,7 +5,7 @@
5
5
  "fixed": [],
6
6
  "linked": [],
7
7
  "access": "public",
8
- "baseBranch": "main",
8
+ "baseBranch": "master",
9
9
  "updateInternalDependencies": "patch",
10
10
  "ignore": []
11
11
  }
@@ -3,7 +3,7 @@ name: Release
3
3
  on:
4
4
  push:
5
5
  branches:
6
- - main
6
+ - master
7
7
 
8
8
  concurrency: ${{ github.workflow }}-${{ github.ref }}
9
9
 
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
- const startDelayMs =
475
- (currentScannedChart.delay || 0) +
476
- (currentScannedChart.chart_offset || 0) * 1000;
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(config.audioFiles, config.startDelayMs) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
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
- constructor(audioFiles, startDelayMs) {
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(audioFiles, startDelayMs);
353
- await audioManager.initAudio();
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
- const isPaused = this.audioCtx.state === "suspended";
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 - (isPaused ? 0 : audioLatency);
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
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
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
- if (this.audioCtx.state === "running") {
396
- await this.audioCtx.suspend();
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
- for (const source of this.audioSources) {
402
- source.onended = null;
403
- try {
404
- source.stop();
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.audioSources = [];
410
- this.gainNode?.disconnect();
411
- this.gainNode = null;
441
+ this.stopAudioSources();
412
442
  unregisterInstance();
413
443
  }
414
- async initAudio() {
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
- const audioBuffers = await Promise.all(
425
- // Must be recreated on each seek because seek is not supported by the Web Audio API
426
- this.audioFiles.map(
427
- (file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
428
- )
429
- );
430
- this.audioLengthMs = Math.max(...audioBuffers.map((b) => b.duration)) * 1e3;
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
- for (const audioBuffer of audioBuffers) {
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.audioCtx.state === "running") {
460
- await this.audioCtx.suspend();
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.audioSources = [];
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
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
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: 38px;
1294
- padding: 8px;
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 8px;
1428
+ padding: 0 2px;
1380
1429
  white-space: nowrap;
1381
1430
  flex-shrink: 0;
1382
- min-width: 85px;
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 8px;
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 = true;
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(config.audioFiles, config.startDelayMs) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
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
- constructor(audioFiles, startDelayMs) {
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(audioFiles, startDelayMs);
314
- await audioManager.initAudio();
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
- const isPaused = this.audioCtx.state === "suspended";
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 - (isPaused ? 0 : audioLatency);
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
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
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
- if (this.audioCtx.state === "running") {
357
- await this.audioCtx.suspend();
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
- for (const source of this.audioSources) {
363
- source.onended = null;
364
- try {
365
- source.stop();
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.audioSources = [];
371
- this.gainNode?.disconnect();
372
- this.gainNode = null;
402
+ this.stopAudioSources();
373
403
  unregisterInstance();
374
404
  }
375
- async initAudio() {
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
- const audioBuffers = await Promise.all(
386
- // Must be recreated on each seek because seek is not supported by the Web Audio API
387
- this.audioFiles.map(
388
- (file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
389
- )
390
- );
391
- this.audioLengthMs = Math.max(...audioBuffers.map((b) => b.duration)) * 1e3;
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
- for (const audioBuffer of audioBuffers) {
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.audioCtx.state === "running") {
421
- await this.audioCtx.suspend();
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.audioSources = [];
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
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
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: 38px;
1259
- padding: 8px;
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 8px;
1393
+ padding: 0 2px;
1345
1394
  white-space: nowrap;
1346
1395
  flex-shrink: 0;
1347
- min-width: 85px;
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 8px;
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 = true;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chart-preview",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "author": "Geo",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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(config.audioFiles, config.startDelayMs)
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(audioFiles: Uint8Array[], startDelayMs: number) {
410
- const audioManager = new AudioManager(audioFiles, startDelayMs);
411
- await audioManager.initAudio();
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
- const isPaused = this.audioCtx.state === "suspended";
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
- // Note: when paused, the queued audio during the latency period is skipped and never heard.
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
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
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
- // Reinitialize audio if needed (e.g., after seeking)
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
- if (this.audioCtx.state === "running") {
474
- await this.audioCtx.suspend();
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
- // Stop all audio sources
481
- for (const source of this.audioSources) {
482
- // Remove onended handler before stopping to prevent false "end" events
483
- source.onended = null;
484
- try {
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
- this.audioSources = [];
492
- this.gainNode?.disconnect();
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
- async initAudio() {
499
- // Stop any existing sources before creating new ones
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
- // Remove onended handler before stopping to prevent false "end" events
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
- const audioBuffers = await Promise.all(
513
- // Must be recreated on each seek because seek is not supported by the Web Audio API
514
- this.audioFiles.map((file) =>
515
- this.audioCtx.decodeAudioData(file.slice(0).buffer)
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
- for (const audioBuffer of audioBuffers) {
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
- // Suspend context first to prevent time drift during seek
555
- // (chartCurrentTimeMs is calculated from audioCtx.currentTime)
556
- if (this.audioCtx.state === "running") {
557
- await this.audioCtx.suspend();
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
- for (const source of this.audioSources) {
562
- // Remove onended handler before stopping to prevent false "end" events
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
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
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: 38px;
133
- padding: 8px;
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 8px;
217
+ padding: 0 2px;
219
218
  white-space: nowrap;
220
219
  flex-shrink: 0;
221
- min-width: 85px;
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 8px;
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 = true;
636
+ this.playBtn.disabled = false;
648
637
  this.playBtn.innerHTML = ICONS.play;
649
638
  this.playBtn.setAttribute("aria-label", "Play");
650
639
  break;