chart-preview 1.0.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # chart-preview
2
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
+
3
9
  ## 1.0.1
4
10
 
5
11
  ### Patch Changes
package/dist/index.js CHANGED
@@ -330,11 +330,13 @@ var ChartRenderer = class extends THREE.WebGLRenderer {
330
330
  }
331
331
  };
332
332
  var AudioManager = class {
333
+ // Track paused state independently since we share the AudioContext
333
334
  constructor(audioFiles, startDelayMs, fallbackAudioLengthMs) {
334
335
  this.audioFiles = audioFiles;
335
336
  this.startDelayMs = startDelayMs;
336
337
  this.fallbackAudioLengthMs = fallbackAudioLengthMs;
337
338
  this.eventEmitter = new import_eventemitter3.EventEmitter();
339
+ this.audioBuffers = [];
338
340
  this.gainNode = null;
339
341
  this.audioSources = [];
340
342
  this._volume = 0.5;
@@ -343,6 +345,7 @@ var AudioManager = class {
343
345
  // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
344
346
  this.audioLengthMs = 0;
345
347
  this.endEventTimeout = null;
348
+ this._isPaused = true;
346
349
  const ctx = getSharedAudioContext();
347
350
  if (!ctx) {
348
351
  throw new Error("AudioContext is not supported in this environment");
@@ -361,7 +364,7 @@ var AudioManager = class {
361
364
  startDelayMs,
362
365
  fallbackAudioLengthMs
363
366
  );
364
- await audioManager.initAudio();
367
+ await audioManager.decodeAudioFiles();
365
368
  return audioManager;
366
369
  }
367
370
  /**
@@ -385,10 +388,12 @@ var AudioManager = class {
385
388
  }
386
389
  /** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
387
390
  get chartCurrentTimeMs() {
388
- const isPaused = this.audioCtx.state === "suspended";
391
+ if (this._isPaused) {
392
+ return this.lastSeekChartTimeMs;
393
+ }
389
394
  const audioLatency = (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1e3;
390
395
  const audioTimeSinceLastSeekMs = (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1e3;
391
- return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - (isPaused ? 0 : audioLatency);
396
+ return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - audioLatency;
392
397
  }
393
398
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
394
399
  get chartEndTimeMs() {
@@ -401,13 +406,12 @@ var AudioManager = class {
401
406
  );
402
407
  }
403
408
  async play() {
404
- if (this.gainNode === null) {
405
- await this.initAudio();
406
- }
407
409
  if (this.audioCtx.state === "suspended") {
408
410
  await this.audioCtx.resume();
409
411
  }
410
- if (this.audioSources.length === 0 && !this.endEventTimeout) {
412
+ this.startAudioSources();
413
+ this._isPaused = false;
414
+ if (this.audioBuffers.length === 0 && !this.endEventTimeout) {
411
415
  const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
412
416
  if (remainingMs > 0) {
413
417
  this.endEventTimeout = setTimeout(() => {
@@ -417,20 +421,42 @@ var AudioManager = class {
417
421
  }
418
422
  }
419
423
  async pause() {
420
- if (this.audioCtx.state === "running") {
421
- await this.audioCtx.suspend();
422
- }
424
+ const currentTime = this.chartCurrentTimeMs;
425
+ this._isPaused = true;
426
+ this.lastSeekChartTimeMs = currentTime;
427
+ this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
423
428
  if (this.endEventTimeout) {
424
429
  clearTimeout(this.endEventTimeout);
425
430
  this.endEventTimeout = null;
426
431
  }
432
+ this.stopAudioSources();
427
433
  }
428
434
  closeAudio() {
429
435
  this.eventEmitter.removeAllListeners();
436
+ this._isPaused = true;
430
437
  if (this.endEventTimeout) {
431
438
  clearTimeout(this.endEventTimeout);
432
439
  this.endEventTimeout = null;
433
440
  }
441
+ this.stopAudioSources();
442
+ unregisterInstance();
443
+ }
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() {
434
460
  for (const source of this.audioSources) {
435
461
  source.onended = null;
436
462
  try {
@@ -442,32 +468,18 @@ var AudioManager = class {
442
468
  this.audioSources = [];
443
469
  this.gainNode?.disconnect();
444
470
  this.gainNode = null;
445
- unregisterInstance();
446
471
  }
447
- async initAudio() {
448
- for (const source of this.audioSources) {
449
- source.onended = null;
450
- try {
451
- source.stop();
452
- source.disconnect();
453
- } catch {
454
- }
455
- }
456
- this.audioSources = [];
457
- const audioBuffers = await Promise.all(
458
- // Must be recreated on each seek because seek is not supported by the Web Audio API
459
- this.audioFiles.map(
460
- (file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
461
- )
462
- );
463
- const calculatedLengthMs = audioBuffers.length > 0 ? Math.max(...audioBuffers.map((b) => b.duration)) * 1e3 : 0;
464
- this.audioLengthMs = calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
472
+ /**
473
+ * Creates and starts audio sources from the current playback position.
474
+ */
475
+ startAudioSources() {
476
+ this.stopAudioSources();
465
477
  this.gainNode = this.audioCtx.createGain();
466
478
  this.gainNode.gain.value = this._volume;
467
479
  this.gainNode.connect(this.audioCtx.destination);
468
480
  let endedCount = 0;
469
481
  const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1e3;
470
- if (audioBuffers.length === 0) {
482
+ if (this.audioBuffers.length === 0) {
471
483
  if (this.endEventTimeout) {
472
484
  clearTimeout(this.endEventTimeout);
473
485
  this.endEventTimeout = null;
@@ -479,12 +491,12 @@ var AudioManager = class {
479
491
  }, remainingMs);
480
492
  }
481
493
  }
482
- for (const audioBuffer of audioBuffers) {
494
+ for (const audioBuffer of this.audioBuffers) {
483
495
  const source = this.audioCtx.createBufferSource();
484
496
  source.buffer = audioBuffer;
485
497
  source.onended = () => {
486
498
  endedCount++;
487
- if (endedCount === audioBuffers.length) {
499
+ if (endedCount === this.audioBuffers.length && !this._isPaused) {
488
500
  this.eventEmitter.emit("end");
489
501
  }
490
502
  };
@@ -496,33 +508,20 @@ var AudioManager = class {
496
508
  this.audioSources.push(source);
497
509
  }
498
510
  this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
499
- this.pause();
500
511
  }
501
512
  /**
502
513
  * @param percentComplete The progress between the start and end of the preview.
503
514
  */
504
515
  async seek(percentComplete) {
505
- if (this.audioCtx.state === "running") {
506
- await this.audioCtx.suspend();
507
- }
508
516
  if (this.endEventTimeout) {
509
517
  clearTimeout(this.endEventTimeout);
510
518
  this.endEventTimeout = null;
511
519
  }
512
- for (const source of this.audioSources) {
513
- source.onended = null;
514
- try {
515
- source.stop();
516
- source.disconnect();
517
- } catch {
518
- }
519
- }
520
- this.audioSources = [];
521
- this.gainNode?.disconnect();
522
- this.gainNode = null;
520
+ this.stopAudioSources();
523
521
  const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
524
522
  this.lastSeekChartTimeMs = chartSeekTimeMs;
525
523
  this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
524
+ this._isPaused = true;
526
525
  }
527
526
  };
528
527
  var SilentAudioManager = class {
@@ -1269,7 +1268,6 @@ var STYLES = `
1269
1268
  --accent-color: #3b82f6;
1270
1269
  --text-color: #e0e0e0;
1271
1270
  --text-muted: #aaa;
1272
- --text-idle: #666;
1273
1271
  --error-color: #ef4444;
1274
1272
  --shadow-color: rgba(0, 0, 0, 0.3);
1275
1273
 
@@ -1341,8 +1339,8 @@ var STYLES = `
1341
1339
  align-items: center;
1342
1340
  justify-content: center;
1343
1341
  height: 40px;
1344
- width: 38px;
1345
- padding: 8px;
1342
+ width: 32px;
1343
+ padding: 6px;
1346
1344
  transition: background 0.15s;
1347
1345
  flex-shrink: 0;
1348
1346
  }
@@ -1427,10 +1425,10 @@ var STYLES = `
1427
1425
  .timestamp {
1428
1426
  color: var(--text-muted);
1429
1427
  font-size: 12px;
1430
- padding: 0 8px;
1428
+ padding: 0 2px;
1431
1429
  white-space: nowrap;
1432
1430
  flex-shrink: 0;
1433
- min-width: 85px;
1431
+ min-width: 70px;
1434
1432
  text-align: center;
1435
1433
  }
1436
1434
 
@@ -1438,7 +1436,7 @@ var STYLES = `
1438
1436
  flex: 1;
1439
1437
  display: flex;
1440
1438
  align-items: center;
1441
- padding: 0 8px;
1439
+ padding: 0 4px;
1442
1440
  min-width: 0;
1443
1441
  }
1444
1442
 
@@ -1480,11 +1478,6 @@ var STYLES = `
1480
1478
  cursor: not-allowed;
1481
1479
  }
1482
1480
 
1483
- .idle-message {
1484
- color: var(--text-idle);
1485
- font-size: 14px;
1486
- }
1487
-
1488
1481
  .error-message {
1489
1482
  color: var(--error-color);
1490
1483
  font-size: 14px;
@@ -1589,7 +1582,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
1589
1582
  <style>${STYLES}</style>
1590
1583
  <div class="player-container">
1591
1584
  <div class="preview-viewport">
1592
- <span class="idle-message">Click play to start</span>
1593
1585
  </div>
1594
1586
  <div class="controls">
1595
1587
  <button class="control-btn play-btn" aria-label="Play" disabled>
@@ -1707,9 +1699,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
1707
1699
  */
1708
1700
  prepareForLoading() {
1709
1701
  this.setState("loading");
1710
- const idleMessage = this.viewport.querySelector(".idle-message");
1711
- if (idleMessage)
1712
- idleMessage.remove();
1713
1702
  const errorMessage = this.viewport.querySelector(".error-message");
1714
1703
  if (errorMessage)
1715
1704
  errorMessage.remove();
@@ -1752,7 +1741,7 @@ var ChartPreviewPlayer = class extends HTMLElement {
1752
1741
  updatePlayButton() {
1753
1742
  switch (this._state) {
1754
1743
  case "idle":
1755
- this.playBtn.disabled = true;
1744
+ this.playBtn.disabled = false;
1756
1745
  this.playBtn.innerHTML = ICONS.play;
1757
1746
  this.playBtn.setAttribute("aria-label", "Play");
1758
1747
  break;
package/dist/index.mjs CHANGED
@@ -291,11 +291,13 @@ var ChartRenderer = class extends THREE.WebGLRenderer {
291
291
  }
292
292
  };
293
293
  var AudioManager = class {
294
+ // Track paused state independently since we share the AudioContext
294
295
  constructor(audioFiles, startDelayMs, fallbackAudioLengthMs) {
295
296
  this.audioFiles = audioFiles;
296
297
  this.startDelayMs = startDelayMs;
297
298
  this.fallbackAudioLengthMs = fallbackAudioLengthMs;
298
299
  this.eventEmitter = new EventEmitter();
300
+ this.audioBuffers = [];
299
301
  this.gainNode = null;
300
302
  this.audioSources = [];
301
303
  this._volume = 0.5;
@@ -304,6 +306,7 @@ var AudioManager = class {
304
306
  // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
305
307
  this.audioLengthMs = 0;
306
308
  this.endEventTimeout = null;
309
+ this._isPaused = true;
307
310
  const ctx = getSharedAudioContext();
308
311
  if (!ctx) {
309
312
  throw new Error("AudioContext is not supported in this environment");
@@ -322,7 +325,7 @@ var AudioManager = class {
322
325
  startDelayMs,
323
326
  fallbackAudioLengthMs
324
327
  );
325
- await audioManager.initAudio();
328
+ await audioManager.decodeAudioFiles();
326
329
  return audioManager;
327
330
  }
328
331
  /**
@@ -346,10 +349,12 @@ var AudioManager = class {
346
349
  }
347
350
  /** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
348
351
  get chartCurrentTimeMs() {
349
- const isPaused = this.audioCtx.state === "suspended";
352
+ if (this._isPaused) {
353
+ return this.lastSeekChartTimeMs;
354
+ }
350
355
  const audioLatency = (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1e3;
351
356
  const audioTimeSinceLastSeekMs = (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1e3;
352
- return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - (isPaused ? 0 : audioLatency);
357
+ return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - audioLatency;
353
358
  }
354
359
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
355
360
  get chartEndTimeMs() {
@@ -362,13 +367,12 @@ var AudioManager = class {
362
367
  );
363
368
  }
364
369
  async play() {
365
- if (this.gainNode === null) {
366
- await this.initAudio();
367
- }
368
370
  if (this.audioCtx.state === "suspended") {
369
371
  await this.audioCtx.resume();
370
372
  }
371
- if (this.audioSources.length === 0 && !this.endEventTimeout) {
373
+ this.startAudioSources();
374
+ this._isPaused = false;
375
+ if (this.audioBuffers.length === 0 && !this.endEventTimeout) {
372
376
  const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
373
377
  if (remainingMs > 0) {
374
378
  this.endEventTimeout = setTimeout(() => {
@@ -378,20 +382,42 @@ var AudioManager = class {
378
382
  }
379
383
  }
380
384
  async pause() {
381
- if (this.audioCtx.state === "running") {
382
- await this.audioCtx.suspend();
383
- }
385
+ const currentTime = this.chartCurrentTimeMs;
386
+ this._isPaused = true;
387
+ this.lastSeekChartTimeMs = currentTime;
388
+ this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
384
389
  if (this.endEventTimeout) {
385
390
  clearTimeout(this.endEventTimeout);
386
391
  this.endEventTimeout = null;
387
392
  }
393
+ this.stopAudioSources();
388
394
  }
389
395
  closeAudio() {
390
396
  this.eventEmitter.removeAllListeners();
397
+ this._isPaused = true;
391
398
  if (this.endEventTimeout) {
392
399
  clearTimeout(this.endEventTimeout);
393
400
  this.endEventTimeout = null;
394
401
  }
402
+ this.stopAudioSources();
403
+ unregisterInstance();
404
+ }
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() {
395
421
  for (const source of this.audioSources) {
396
422
  source.onended = null;
397
423
  try {
@@ -403,32 +429,18 @@ var AudioManager = class {
403
429
  this.audioSources = [];
404
430
  this.gainNode?.disconnect();
405
431
  this.gainNode = null;
406
- unregisterInstance();
407
432
  }
408
- async initAudio() {
409
- for (const source of this.audioSources) {
410
- source.onended = null;
411
- try {
412
- source.stop();
413
- source.disconnect();
414
- } catch {
415
- }
416
- }
417
- this.audioSources = [];
418
- const audioBuffers = await Promise.all(
419
- // Must be recreated on each seek because seek is not supported by the Web Audio API
420
- this.audioFiles.map(
421
- (file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
422
- )
423
- );
424
- const calculatedLengthMs = audioBuffers.length > 0 ? Math.max(...audioBuffers.map((b) => b.duration)) * 1e3 : 0;
425
- this.audioLengthMs = calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
433
+ /**
434
+ * Creates and starts audio sources from the current playback position.
435
+ */
436
+ startAudioSources() {
437
+ this.stopAudioSources();
426
438
  this.gainNode = this.audioCtx.createGain();
427
439
  this.gainNode.gain.value = this._volume;
428
440
  this.gainNode.connect(this.audioCtx.destination);
429
441
  let endedCount = 0;
430
442
  const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1e3;
431
- if (audioBuffers.length === 0) {
443
+ if (this.audioBuffers.length === 0) {
432
444
  if (this.endEventTimeout) {
433
445
  clearTimeout(this.endEventTimeout);
434
446
  this.endEventTimeout = null;
@@ -440,12 +452,12 @@ var AudioManager = class {
440
452
  }, remainingMs);
441
453
  }
442
454
  }
443
- for (const audioBuffer of audioBuffers) {
455
+ for (const audioBuffer of this.audioBuffers) {
444
456
  const source = this.audioCtx.createBufferSource();
445
457
  source.buffer = audioBuffer;
446
458
  source.onended = () => {
447
459
  endedCount++;
448
- if (endedCount === audioBuffers.length) {
460
+ if (endedCount === this.audioBuffers.length && !this._isPaused) {
449
461
  this.eventEmitter.emit("end");
450
462
  }
451
463
  };
@@ -457,33 +469,20 @@ var AudioManager = class {
457
469
  this.audioSources.push(source);
458
470
  }
459
471
  this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
460
- this.pause();
461
472
  }
462
473
  /**
463
474
  * @param percentComplete The progress between the start and end of the preview.
464
475
  */
465
476
  async seek(percentComplete) {
466
- if (this.audioCtx.state === "running") {
467
- await this.audioCtx.suspend();
468
- }
469
477
  if (this.endEventTimeout) {
470
478
  clearTimeout(this.endEventTimeout);
471
479
  this.endEventTimeout = null;
472
480
  }
473
- for (const source of this.audioSources) {
474
- source.onended = null;
475
- try {
476
- source.stop();
477
- source.disconnect();
478
- } catch {
479
- }
480
- }
481
- this.audioSources = [];
482
- this.gainNode?.disconnect();
483
- this.gainNode = null;
481
+ this.stopAudioSources();
484
482
  const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
485
483
  this.lastSeekChartTimeMs = chartSeekTimeMs;
486
484
  this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
485
+ this._isPaused = true;
487
486
  }
488
487
  };
489
488
  var SilentAudioManager = class {
@@ -1234,7 +1233,6 @@ var STYLES = `
1234
1233
  --accent-color: #3b82f6;
1235
1234
  --text-color: #e0e0e0;
1236
1235
  --text-muted: #aaa;
1237
- --text-idle: #666;
1238
1236
  --error-color: #ef4444;
1239
1237
  --shadow-color: rgba(0, 0, 0, 0.3);
1240
1238
 
@@ -1306,8 +1304,8 @@ var STYLES = `
1306
1304
  align-items: center;
1307
1305
  justify-content: center;
1308
1306
  height: 40px;
1309
- width: 38px;
1310
- padding: 8px;
1307
+ width: 32px;
1308
+ padding: 6px;
1311
1309
  transition: background 0.15s;
1312
1310
  flex-shrink: 0;
1313
1311
  }
@@ -1392,10 +1390,10 @@ var STYLES = `
1392
1390
  .timestamp {
1393
1391
  color: var(--text-muted);
1394
1392
  font-size: 12px;
1395
- padding: 0 8px;
1393
+ padding: 0 2px;
1396
1394
  white-space: nowrap;
1397
1395
  flex-shrink: 0;
1398
- min-width: 85px;
1396
+ min-width: 70px;
1399
1397
  text-align: center;
1400
1398
  }
1401
1399
 
@@ -1403,7 +1401,7 @@ var STYLES = `
1403
1401
  flex: 1;
1404
1402
  display: flex;
1405
1403
  align-items: center;
1406
- padding: 0 8px;
1404
+ padding: 0 4px;
1407
1405
  min-width: 0;
1408
1406
  }
1409
1407
 
@@ -1445,11 +1443,6 @@ var STYLES = `
1445
1443
  cursor: not-allowed;
1446
1444
  }
1447
1445
 
1448
- .idle-message {
1449
- color: var(--text-idle);
1450
- font-size: 14px;
1451
- }
1452
-
1453
1446
  .error-message {
1454
1447
  color: var(--error-color);
1455
1448
  font-size: 14px;
@@ -1554,7 +1547,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
1554
1547
  <style>${STYLES}</style>
1555
1548
  <div class="player-container">
1556
1549
  <div class="preview-viewport">
1557
- <span class="idle-message">Click play to start</span>
1558
1550
  </div>
1559
1551
  <div class="controls">
1560
1552
  <button class="control-btn play-btn" aria-label="Play" disabled>
@@ -1672,9 +1664,6 @@ var ChartPreviewPlayer = class extends HTMLElement {
1672
1664
  */
1673
1665
  prepareForLoading() {
1674
1666
  this.setState("loading");
1675
- const idleMessage = this.viewport.querySelector(".idle-message");
1676
- if (idleMessage)
1677
- idleMessage.remove();
1678
1667
  const errorMessage = this.viewport.querySelector(".error-message");
1679
1668
  if (errorMessage)
1680
1669
  errorMessage.remove();
@@ -1717,7 +1706,7 @@ var ChartPreviewPlayer = class extends HTMLElement {
1717
1706
  updatePlayButton() {
1718
1707
  switch (this._state) {
1719
1708
  case "idle":
1720
- this.playBtn.disabled = true;
1709
+ this.playBtn.disabled = false;
1721
1710
  this.playBtn.innerHTML = ICONS.play;
1722
1711
  this.playBtn.setAttribute("aria-label", "Play");
1723
1712
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chart-preview",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "author": "Geo",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -387,6 +387,7 @@ class AudioManager {
387
387
  private eventEmitter = new EventEmitter();
388
388
 
389
389
  private audioCtx: AudioContext;
390
+ private audioBuffers: AudioBuffer[] = [];
390
391
  private gainNode: GainNode | null = null;
391
392
  private audioSources: AudioBufferSourceNode[] = [];
392
393
  private _volume = 0.5;
@@ -394,6 +395,7 @@ class AudioManager {
394
395
  private lastAudioCtxCurrentTime = 0; // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
395
396
  private audioLengthMs: number = 0;
396
397
  private endEventTimeout: ReturnType<typeof setTimeout> | null = null;
398
+ private _isPaused = true; // Track paused state independently since we share the AudioContext
397
399
 
398
400
  private constructor(
399
401
  private audioFiles: Uint8Array[],
@@ -423,7 +425,7 @@ class AudioManager {
423
425
  startDelayMs,
424
426
  fallbackAudioLengthMs
425
427
  );
426
- await audioManager.initAudio();
428
+ await audioManager.decodeAudioFiles();
427
429
  return audioManager;
428
430
  }
429
431
 
@@ -453,20 +455,17 @@ class AudioManager {
453
455
 
454
456
  /** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
455
457
  get chartCurrentTimeMs() {
456
- 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
+ }
457
463
  // outputLatency is not implemented in safari
458
464
  const audioLatency =
459
465
  (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1000;
460
466
  const audioTimeSinceLastSeekMs =
461
467
  (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1000;
462
- // Note: when paused, the queued audio during the latency period is skipped and never heard.
463
- // The solution here is to represent that visually by jumping ahead slightly by ignoring latency when paused.
464
- // If this is a more significant problem, it can be fixed by seeking backward by `audioLatency`.
465
- return (
466
- this.lastSeekChartTimeMs +
467
- audioTimeSinceLastSeekMs -
468
- (isPaused ? 0 : audioLatency)
469
- );
468
+ return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - audioLatency;
470
469
  }
471
470
 
472
471
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
@@ -484,16 +483,17 @@ class AudioManager {
484
483
  }
485
484
 
486
485
  async play() {
487
- // Reinitialize audio if needed (e.g., after seeking)
488
- if (this.gainNode === null) {
489
- await this.initAudio();
490
- }
491
- // Resume context if suspended
486
+ // Resume the shared context if suspended (needed for initial user interaction)
492
487
  if (this.audioCtx.state === "suspended") {
493
488
  await this.audioCtx.resume();
494
489
  }
495
- // Restart end event timeout for charts without audio
496
- if (this.audioSources.length === 0 && !this.endEventTimeout) {
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
497
  const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
498
498
  if (remainingMs > 0) {
499
499
  this.endEventTimeout = setTimeout(() => {
@@ -502,58 +502,44 @@ class AudioManager {
502
502
  }
503
503
  }
504
504
  }
505
+
505
506
  async pause() {
506
- if (this.audioCtx.state === "running") {
507
- await this.audioCtx.suspend();
508
- }
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
+
509
513
  // Clear end event timeout when pausing
510
514
  if (this.endEventTimeout) {
511
515
  clearTimeout(this.endEventTimeout);
512
516
  this.endEventTimeout = null;
513
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();
514
522
  }
515
523
 
516
524
  closeAudio() {
517
525
  this.eventEmitter.removeAllListeners();
526
+ this._isPaused = true;
518
527
  // Clear end event timeout
519
528
  if (this.endEventTimeout) {
520
529
  clearTimeout(this.endEventTimeout);
521
530
  this.endEventTimeout = null;
522
531
  }
523
532
  // Stop all audio sources
524
- for (const source of this.audioSources) {
525
- // Remove onended handler before stopping to prevent false "end" events
526
- source.onended = null;
527
- try {
528
- source.stop();
529
- source.disconnect();
530
- } catch {
531
- // Source may already be stopped
532
- }
533
- }
534
- this.audioSources = [];
535
- this.gainNode?.disconnect();
536
- this.gainNode = null;
533
+ this.stopAudioSources();
537
534
  // Don't close the shared context - just unregister this instance
538
535
  unregisterInstance();
539
536
  }
540
537
 
541
- async initAudio() {
542
- // Stop any existing sources before creating new ones
543
- for (const source of this.audioSources) {
544
- // Remove onended handler before stopping to prevent false "end" events
545
- source.onended = null;
546
- try {
547
- source.stop();
548
- source.disconnect();
549
- } catch {
550
- // Source may already be stopped
551
- }
552
- }
553
- this.audioSources = [];
554
-
555
- const audioBuffers = await Promise.all(
556
- // Must be recreated on each seek because seek is not supported by the Web Audio API
538
+ /**
539
+ * Decodes audio files into AudioBuffers. Called once during initialization.
540
+ */
541
+ private async decodeAudioFiles() {
542
+ this.audioBuffers = await Promise.all(
557
543
  this.audioFiles.map((file) =>
558
544
  this.audioCtx.decodeAudioData(file.slice(0).buffer)
559
545
  )
@@ -561,11 +547,37 @@ class AudioManager {
561
547
 
562
548
  // Use fallback if no audio files or if calculated duration is invalid
563
549
  const calculatedLengthMs =
564
- audioBuffers.length > 0
565
- ? Math.max(...audioBuffers.map((b) => b.duration)) * 1000
550
+ this.audioBuffers.length > 0
551
+ ? Math.max(...this.audioBuffers.map((b) => b.duration)) * 1000
566
552
  : 0;
567
553
  this.audioLengthMs =
568
554
  calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
555
+ }
556
+
557
+ /**
558
+ * Stops all currently playing audio sources for this instance.
559
+ */
560
+ private stopAudioSources() {
561
+ for (const source of this.audioSources) {
562
+ source.onended = null; // Prevent false "end" events
563
+ try {
564
+ source.stop();
565
+ source.disconnect();
566
+ } catch {
567
+ // Source may already be stopped
568
+ }
569
+ }
570
+ this.audioSources = [];
571
+ this.gainNode?.disconnect();
572
+ this.gainNode = null;
573
+ }
574
+
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();
569
581
 
570
582
  this.gainNode = this.audioCtx.createGain();
571
583
  this.gainNode.gain.value = this._volume;
@@ -575,8 +587,8 @@ class AudioManager {
575
587
  const audioStartOffsetSeconds =
576
588
  (this.lastSeekChartTimeMs - this.startDelayMs) / 1000;
577
589
 
578
- // If no audio files, use a timeout to emit "end" event like SilentAudioManager does
579
- if (audioBuffers.length === 0) {
590
+ // If no audio files, use a timeout to emit "end" event
591
+ if (this.audioBuffers.length === 0) {
580
592
  // Clear any existing timeout
581
593
  if (this.endEventTimeout) {
582
594
  clearTimeout(this.endEventTimeout);
@@ -591,12 +603,12 @@ class AudioManager {
591
603
  }
592
604
  }
593
605
 
594
- for (const audioBuffer of audioBuffers) {
606
+ for (const audioBuffer of this.audioBuffers) {
595
607
  const source = this.audioCtx.createBufferSource();
596
608
  source.buffer = audioBuffer;
597
609
  source.onended = () => {
598
610
  endedCount++;
599
- if (endedCount === audioBuffers.length) {
611
+ if (endedCount === this.audioBuffers.length && !this._isPaused) {
600
612
  this.eventEmitter.emit("end");
601
613
  }
602
614
  };
@@ -614,19 +626,12 @@ class AudioManager {
614
626
  this.audioSources.push(source);
615
627
  }
616
628
  this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
617
- this.pause();
618
629
  }
619
630
 
620
631
  /**
621
632
  * @param percentComplete The progress between the start and end of the preview.
622
633
  */
623
634
  async seek(percentComplete: number) {
624
- // Suspend context first to prevent time drift during seek
625
- // (chartCurrentTimeMs is calculated from audioCtx.currentTime)
626
- if (this.audioCtx.state === "running") {
627
- await this.audioCtx.suspend();
628
- }
629
-
630
635
  // Clear end event timeout when seeking
631
636
  if (this.endEventTimeout) {
632
637
  clearTimeout(this.endEventTimeout);
@@ -634,22 +639,12 @@ class AudioManager {
634
639
  }
635
640
 
636
641
  // Stop existing audio sources
637
- for (const source of this.audioSources) {
638
- // Remove onended handler before stopping to prevent false "end" events
639
- source.onended = null;
640
- try {
641
- source.stop();
642
- source.disconnect();
643
- } catch {
644
- // Source may already be stopped
645
- }
646
- }
647
- this.audioSources = [];
648
- this.gainNode?.disconnect();
649
- this.gainNode = null;
642
+ this.stopAudioSources();
643
+
650
644
  const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
651
645
  this.lastSeekChartTimeMs = chartSeekTimeMs;
652
646
  this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
647
+ this._isPaused = true;
653
648
  }
654
649
  }
655
650
 
@@ -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;