chart-preview 1.0.0

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/dist/index.js ADDED
@@ -0,0 +1,2158 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // src/index.ts
30
+ var src_exports = {};
31
+ __export(src_exports, {
32
+ ChartPreview: () => ChartPreview,
33
+ ChartPreviewPlayer: () => ChartPreviewPlayer,
34
+ extractSngFile: () => extractSngFile,
35
+ fetchSngFile: () => fetchSngFile,
36
+ findAudioFiles: () => findAudioFiles,
37
+ findChartFile: () => findChartFile,
38
+ getInstrumentType: () => import_scan_chart3.getInstrumentType,
39
+ instrumentTypes: () => import_scan_chart3.instrumentTypes,
40
+ isVideoFile: () => isVideoFile,
41
+ prepareChartData: () => prepareChartData
42
+ });
43
+ module.exports = __toCommonJS(src_exports);
44
+
45
+ // src/ChartPreview.ts
46
+ var import_eventemitter3 = require("eventemitter3");
47
+ var import_scan_chart = require("scan-chart");
48
+ var THREE = __toESM(require("three"));
49
+
50
+ // src/SharedAudioContext.ts
51
+ var AudioContextClass = typeof window !== "undefined" ? window.AudioContext || window.webkitAudioContext : null;
52
+ var sharedContext = null;
53
+ var activeInstanceCount = 0;
54
+ function getSharedAudioContext() {
55
+ if (!AudioContextClass) {
56
+ return null;
57
+ }
58
+ if (!sharedContext || sharedContext.state === "closed") {
59
+ sharedContext = new AudioContextClass();
60
+ }
61
+ return sharedContext;
62
+ }
63
+ function registerInstance() {
64
+ activeInstanceCount++;
65
+ }
66
+ function unregisterInstance() {
67
+ activeInstanceCount = Math.max(0, activeInstanceCount - 1);
68
+ }
69
+ function isAudioContextSupported() {
70
+ return AudioContextClass !== null;
71
+ }
72
+
73
+ // src/ChartPreview.ts
74
+ var HIGHWAY_DURATION_MS = 1500;
75
+ var SCALE = 0.105;
76
+ var NOTE_SPAN_WIDTH = 0.95;
77
+ var BARRE1_TYPE = 99991;
78
+ var BARRE2_TYPE = 99992;
79
+ var BARRE3_TYPE = 99993;
80
+ var barreTypes = [
81
+ BARRE1_TYPE,
82
+ BARRE2_TYPE,
83
+ BARRE3_TYPE
84
+ ];
85
+ var SP_FLAG = 2147483648;
86
+ var DEFAULT_PROGRESS_INTERVAL_MS = 50;
87
+ var _ChartPreview = class {
88
+ constructor() {
89
+ this.eventEmitter = new import_eventemitter3.EventEmitter();
90
+ this.paused = true;
91
+ this.scene = new THREE.Scene();
92
+ this.lastProgressEmitTime = 0;
93
+ }
94
+ /**
95
+ * Adds an event listener.
96
+ *
97
+ * Available events:
98
+ * - `progress`: called during playback with the current progress (0-1), throttled by progressIntervalMs.
99
+ * - `end`: called when the chart preview ends.
100
+ */
101
+ on(event, listener) {
102
+ this.eventEmitter.on(event, listener);
103
+ }
104
+ /**
105
+ * Removes an event listener.
106
+ */
107
+ off(event, listener) {
108
+ this.eventEmitter.off(event, listener);
109
+ }
110
+ /**
111
+ * Creates a new ChartPreview instance.
112
+ *
113
+ * @param config - Configuration object for the chart preview
114
+ *
115
+ * Will throw an exception if textures fail to load or if audio decoding fails.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * import { ChartPreview, ParsedChart } from 'chart-preview'
120
+ * import { parseChartFile, getInstrumentType } from 'scan-chart'
121
+ *
122
+ * // Parse your chart file first
123
+ * const parsedChart = parseChartFile(chartData, format, modifiers)
124
+ *
125
+ * // Load textures for the instrument type
126
+ * const textures = await ChartPreview.loadTextures(getInstrumentType('guitar'))
127
+ *
128
+ * // Create the preview
129
+ * const preview = await ChartPreview.create({
130
+ * parsedChart,
131
+ * textures,
132
+ * audioFiles: [audioData],
133
+ * instrument: 'guitar',
134
+ * difficulty: 'expert',
135
+ * startDelayMs: 0,
136
+ * audioLengthMs: 180000,
137
+ * container: document.getElementById('preview-container')
138
+ * })
139
+ *
140
+ * // Control playback
141
+ * await preview.togglePaused()
142
+ * ```
143
+ */
144
+ static async create(config) {
145
+ const chartPreview = new _ChartPreview();
146
+ chartPreview.instrumentType = (0, import_scan_chart.getInstrumentType)(config.instrument);
147
+ chartPreview.highwayTexture = config.textures.highwayTexture;
148
+ chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
149
+ chartPreview.camera = new ChartCamera(config.container);
150
+ chartPreview.renderer = new ChartRenderer(config.container);
151
+ chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(config.audioFiles, config.startDelayMs) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
152
+ chartPreview.audioManager.on(
153
+ "end",
154
+ () => chartPreview.eventEmitter.emit("end")
155
+ );
156
+ chartPreview.notesManager = new NotesManager(
157
+ config.parsedChart,
158
+ config.instrument,
159
+ config.difficulty,
160
+ chartPreview.scene,
161
+ config.textures.noteTextures
162
+ );
163
+ chartPreview.addHighwayToScene(config.textures.highwayTexture);
164
+ chartPreview.addStrikelineToScene(config.textures.strikelineTexture);
165
+ config.container.firstChild?.remove();
166
+ config.container.appendChild(chartPreview.renderer.domElement);
167
+ return chartPreview;
168
+ }
169
+ /**
170
+ * Toggles between playing and paused states.
171
+ */
172
+ async togglePaused() {
173
+ if (this.paused) {
174
+ await this.audioManager.play();
175
+ this.renderer.setAnimationLoop(() => this.animateFrame());
176
+ } else {
177
+ await this.audioManager.pause();
178
+ this.renderer.setAnimationLoop(null);
179
+ }
180
+ this.paused = !this.paused;
181
+ }
182
+ /**
183
+ * Returns whether the preview is currently paused.
184
+ */
185
+ get isPaused() {
186
+ return this.paused;
187
+ }
188
+ /**
189
+ * Moves the playback time to `percentComplete` of the way through the preview.
190
+ * @param percentComplete A number between 0 and 1 (inclusive)
191
+ */
192
+ async seek(percentComplete) {
193
+ await this.audioManager.seek(percentComplete);
194
+ this.animateFrame(false);
195
+ this.renderer.setAnimationLoop(null);
196
+ this.paused = true;
197
+ }
198
+ /**
199
+ * Gets or sets the volume (0 to 1).
200
+ * Returns `null` if audio isn't loaded (e.g., using SilentAudioManager).
201
+ */
202
+ set volume(volume) {
203
+ this.audioManager.volume = volume;
204
+ }
205
+ get volume() {
206
+ return this.audioManager.volume;
207
+ }
208
+ /** Current playback position in milliseconds */
209
+ get chartCurrentTimeMs() {
210
+ return this.audioManager.chartCurrentTimeMs;
211
+ }
212
+ /** Total duration in milliseconds */
213
+ get chartEndTimeMs() {
214
+ return this.audioManager.chartEndTimeMs;
215
+ }
216
+ /**
217
+ * Cleans up all resources. Call this when discarding the preview.
218
+ * The preview should not be used after calling dispose().
219
+ */
220
+ dispose() {
221
+ this.eventEmitter.removeAllListeners();
222
+ this.camera.dispose();
223
+ this.renderer.setAnimationLoop(null);
224
+ this.renderer.renderLists.dispose();
225
+ this.renderer.dispose();
226
+ this.renderer.forceContextLoss();
227
+ this.audioManager.closeAudio();
228
+ }
229
+ addHighwayToScene(highwayTexture) {
230
+ const mat = new THREE.MeshBasicMaterial({ map: highwayTexture });
231
+ const geometry = new THREE.PlaneGeometry(
232
+ this.instrumentType === import_scan_chart.instrumentTypes.drums ? 0.9 : this.instrumentType === import_scan_chart.instrumentTypes.sixFret ? 0.7 : 1,
233
+ 2
234
+ );
235
+ const plane = new THREE.Mesh(geometry, mat);
236
+ plane.position.y = -0.1;
237
+ plane.renderOrder = 1;
238
+ this.scene.add(plane);
239
+ }
240
+ addStrikelineToScene(strikelineTexture) {
241
+ const material = new THREE.SpriteMaterial({
242
+ map: strikelineTexture,
243
+ sizeAttenuation: true,
244
+ transparent: true,
245
+ depthTest: false
246
+ });
247
+ const aspectRatio = strikelineTexture.image.width / strikelineTexture.image.height;
248
+ const scale = this.instrumentType === import_scan_chart.instrumentTypes.sixFret ? 0.141 : 0.19;
249
+ const sprite = new THREE.Sprite(material);
250
+ if (aspectRatio > 1) {
251
+ sprite.scale.set(aspectRatio * scale, 1 * scale, 1);
252
+ } else {
253
+ sprite.scale.set(1 * scale, 1 / aspectRatio * scale, 1);
254
+ }
255
+ sprite.position.y = -1;
256
+ sprite.renderOrder = 3;
257
+ this.scene.add(sprite);
258
+ }
259
+ animateFrame(emit = true) {
260
+ this.notesManager.updateDisplayedNotes(
261
+ this.audioManager.chartCurrentTimeMs
262
+ );
263
+ const scrollPosition = -0.9 * (this.audioManager.chartCurrentTimeMs / 1e3) * (HIGHWAY_DURATION_MS / 1e3);
264
+ this.highwayTexture.offset.y = -1 * scrollPosition;
265
+ this.renderer.render(this.scene, this.camera);
266
+ if (emit) {
267
+ const now = performance.now();
268
+ if (now - this.lastProgressEmitTime >= this.progressIntervalMs) {
269
+ this.lastProgressEmitTime = now;
270
+ this.eventEmitter.emit(
271
+ "progress",
272
+ this.audioManager.chartCurrentTimeMs / this.audioManager.chartEndTimeMs
273
+ );
274
+ }
275
+ }
276
+ }
277
+ };
278
+ var ChartPreview = _ChartPreview;
279
+ /**
280
+ * Loads textures required for the chart preview.
281
+ * Call this once per instrument type and cache the result for reuse.
282
+ */
283
+ ChartPreview.loadTextures = loadTextures;
284
+ var ChartCamera = class extends THREE.PerspectiveCamera {
285
+ constructor(divContainer) {
286
+ super(90, 1 / 1, 0.01, 10);
287
+ this.divContainer = divContainer;
288
+ this.resizeListener = () => this.onResize();
289
+ this.position.z = 0.8;
290
+ this.position.y = -1.3;
291
+ this.rotation.x = THREE.MathUtils.degToRad(60);
292
+ this.onResize();
293
+ window.addEventListener("resize", this.resizeListener);
294
+ }
295
+ onResize() {
296
+ const width = this.divContainer.offsetWidth ?? window.innerWidth;
297
+ const height = this.divContainer.offsetHeight ?? window.innerHeight;
298
+ this.aspect = width / height;
299
+ this.updateProjectionMatrix();
300
+ }
301
+ dispose() {
302
+ window.removeEventListener("resize", this.resizeListener);
303
+ this.clear();
304
+ }
305
+ };
306
+ var ChartRenderer = class extends THREE.WebGLRenderer {
307
+ constructor(divContainer) {
308
+ super({
309
+ antialias: true
310
+ });
311
+ this.divContainer = divContainer;
312
+ this.resizeListener = () => this.onResize();
313
+ this.localClippingEnabled = true;
314
+ this.outputColorSpace = THREE.LinearSRGBColorSpace;
315
+ this.onResize();
316
+ window.addEventListener("resize", this.resizeListener);
317
+ }
318
+ onResize() {
319
+ const width = this.divContainer.offsetWidth ?? window.innerWidth;
320
+ const height = this.divContainer.offsetHeight ?? window.innerHeight;
321
+ this.setSize(width, height);
322
+ }
323
+ dispose() {
324
+ window.removeEventListener("resize", this.resizeListener);
325
+ super.dispose();
326
+ }
327
+ };
328
+ var AudioManager = class {
329
+ constructor(audioFiles, startDelayMs) {
330
+ this.audioFiles = audioFiles;
331
+ this.startDelayMs = startDelayMs;
332
+ this.eventEmitter = new import_eventemitter3.EventEmitter();
333
+ this.gainNode = null;
334
+ this.audioSources = [];
335
+ this._volume = 0.5;
336
+ this.lastSeekChartTimeMs = 0;
337
+ this.lastAudioCtxCurrentTime = 0;
338
+ // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
339
+ this.audioLengthMs = 0;
340
+ const ctx = getSharedAudioContext();
341
+ if (!ctx) {
342
+ throw new Error("AudioContext is not supported in this environment");
343
+ }
344
+ this.audioCtx = ctx;
345
+ registerInstance();
346
+ }
347
+ /**
348
+ * @param audioFiles The `Uint8Array[]` of the audio files to be played.
349
+ * @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
350
+ */
351
+ static async create(audioFiles, startDelayMs) {
352
+ const audioManager = new AudioManager(audioFiles, startDelayMs);
353
+ await audioManager.initAudio();
354
+ return audioManager;
355
+ }
356
+ /**
357
+ * Available events:
358
+ * - `end`: called when the audio playback ends.
359
+ */
360
+ on(event, listener) {
361
+ this.eventEmitter.on(event, listener);
362
+ }
363
+ /** `volume` is a number between 0 and 1 (inclusive). Values outside this range are clamped. */
364
+ set volume(volume) {
365
+ const clamped = Math.max(0, Math.min(1, volume));
366
+ this._volume = clamped * clamped;
367
+ if (this.gainNode) {
368
+ this.gainNode.gain.value = this._volume;
369
+ }
370
+ }
371
+ /** `volume` is a number between 0 and 1 (inclusive) */
372
+ get volume() {
373
+ return Math.sqrt(this._volume);
374
+ }
375
+ /** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
376
+ get chartCurrentTimeMs() {
377
+ const isPaused = this.audioCtx.state === "suspended";
378
+ const audioLatency = (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1e3;
379
+ const audioTimeSinceLastSeekMs = (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1e3;
380
+ return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - (isPaused ? 0 : audioLatency);
381
+ }
382
+ /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
383
+ get chartEndTimeMs() {
384
+ return Math.max(this.startDelayMs + this.audioLengthMs, 0);
385
+ }
386
+ async play() {
387
+ if (this.gainNode === null) {
388
+ await this.initAudio();
389
+ }
390
+ if (this.audioCtx.state === "suspended") {
391
+ await this.audioCtx.resume();
392
+ }
393
+ }
394
+ async pause() {
395
+ if (this.audioCtx.state === "running") {
396
+ await this.audioCtx.suspend();
397
+ }
398
+ }
399
+ closeAudio() {
400
+ 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
+ }
408
+ }
409
+ this.audioSources = [];
410
+ this.gainNode?.disconnect();
411
+ this.gainNode = null;
412
+ unregisterInstance();
413
+ }
414
+ async initAudio() {
415
+ for (const source of this.audioSources) {
416
+ source.onended = null;
417
+ try {
418
+ source.stop();
419
+ source.disconnect();
420
+ } catch {
421
+ }
422
+ }
423
+ 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;
431
+ this.gainNode = this.audioCtx.createGain();
432
+ this.gainNode.gain.value = this._volume;
433
+ this.gainNode.connect(this.audioCtx.destination);
434
+ let endedCount = 0;
435
+ const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1e3;
436
+ for (const audioBuffer of audioBuffers) {
437
+ const source = this.audioCtx.createBufferSource();
438
+ source.buffer = audioBuffer;
439
+ source.onended = () => {
440
+ endedCount++;
441
+ if (endedCount === audioBuffers.length) {
442
+ this.eventEmitter.emit("end");
443
+ }
444
+ };
445
+ source.connect(this.gainNode);
446
+ const delaySeconds = Math.abs(Math.min(audioStartOffsetSeconds, 0));
447
+ const when = this.audioCtx.currentTime + delaySeconds;
448
+ const offset = Math.max(audioStartOffsetSeconds, 0);
449
+ source.start(when, offset);
450
+ this.audioSources.push(source);
451
+ }
452
+ this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
453
+ this.pause();
454
+ }
455
+ /**
456
+ * @param percentComplete The progress between the start and end of the preview.
457
+ */
458
+ 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
+ }
469
+ }
470
+ this.audioSources = [];
471
+ this.gainNode?.disconnect();
472
+ this.gainNode = null;
473
+ const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
474
+ this.lastSeekChartTimeMs = chartSeekTimeMs;
475
+ this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
476
+ }
477
+ };
478
+ var SilentAudioManager = class {
479
+ constructor(startDelayMs, audioLengthMs) {
480
+ this.startDelayMs = startDelayMs;
481
+ this.audioLengthMs = audioLengthMs;
482
+ this.eventEmitter = new import_eventemitter3.EventEmitter();
483
+ this.endEventTimeout = null;
484
+ this.isPaused = true;
485
+ }
486
+ /**
487
+ * @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
488
+ * @param audioLengthMs The length of the longest audio file stem.
489
+ */
490
+ static async create(startDelayMs, audioLengthMs) {
491
+ const audioManager = new SilentAudioManager(startDelayMs, audioLengthMs);
492
+ await audioManager.seek(0);
493
+ return audioManager;
494
+ }
495
+ /**
496
+ * Available events:
497
+ * - `end`: called when the playback ends.
498
+ */
499
+ on(event, listener) {
500
+ this.eventEmitter.on(event, listener);
501
+ }
502
+ /** `volume` is invalid for silent playback. */
503
+ set volume(_null) {
504
+ return;
505
+ }
506
+ /** `volume` is invalid for silent playback. */
507
+ get volume() {
508
+ return null;
509
+ }
510
+ /** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
511
+ get chartCurrentTimeMs() {
512
+ if (this.isPaused) {
513
+ return this.lastResumeChartTimeMs;
514
+ } else {
515
+ return this.lastResumeChartTimeMs + performance.now() - this.lastResumeClockTimeMs;
516
+ }
517
+ }
518
+ /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
519
+ get chartEndTimeMs() {
520
+ return Math.max(this.startDelayMs + this.audioLengthMs, 0);
521
+ }
522
+ async play() {
523
+ if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
524
+ this.lastResumeChartTimeMs = 0;
525
+ }
526
+ this.lastResumeClockTimeMs = performance.now();
527
+ this.endEventTimeout = setTimeout(() => {
528
+ this.pause();
529
+ this.eventEmitter.emit("end");
530
+ }, this.chartEndTimeMs - this.lastResumeChartTimeMs);
531
+ this.isPaused = false;
532
+ }
533
+ async pause() {
534
+ this.lastResumeChartTimeMs = this.chartCurrentTimeMs;
535
+ if (this.endEventTimeout) {
536
+ clearTimeout(this.endEventTimeout);
537
+ }
538
+ this.isPaused = true;
539
+ }
540
+ closeAudio() {
541
+ this.eventEmitter.removeAllListeners();
542
+ if (this.endEventTimeout) {
543
+ clearTimeout(this.endEventTimeout);
544
+ }
545
+ }
546
+ /**
547
+ * @param percentComplete The progress between the start and end of the preview.
548
+ */
549
+ async seek(percentComplete) {
550
+ this.lastResumeChartTimeMs = this.chartEndTimeMs * percentComplete;
551
+ if (!this.isPaused) {
552
+ this.play();
553
+ }
554
+ }
555
+ };
556
+ var NotesManager = class {
557
+ constructor(chartData, instrument, difficulty, scene, noteTextures) {
558
+ this.chartData = chartData;
559
+ this.instrument = instrument;
560
+ this.difficulty = difficulty;
561
+ this.scene = scene;
562
+ this.noteMaterials = /* @__PURE__ */ new Map();
563
+ this.clippingPlanes = [
564
+ new THREE.Plane(new THREE.Vector3(0, 1, 0), 1),
565
+ new THREE.Plane(new THREE.Vector3(0, -1, 0), 0.9)
566
+ ];
567
+ this.noteGroups = /* @__PURE__ */ new Map();
568
+ adjustParsedChart(chartData, instrument, difficulty);
569
+ Object.values(import_scan_chart.noteTypes).forEach(
570
+ (noteType) => this.noteMaterials.set(noteType, /* @__PURE__ */ new Map())
571
+ );
572
+ barreTypes.forEach(
573
+ (barreType) => this.noteMaterials.set(barreType, /* @__PURE__ */ new Map())
574
+ );
575
+ noteTextures.forEach((flagTextures, noteType) => {
576
+ flagTextures.forEach((texture, noteFlagsValue) => {
577
+ this.noteMaterials.get(noteType).set(noteFlagsValue, new THREE.SpriteMaterial({ map: texture }));
578
+ });
579
+ });
580
+ const track = chartData.trackData.find(
581
+ (t) => t.instrument === instrument && t.difficulty === difficulty
582
+ );
583
+ if (!track) {
584
+ throw new Error(
585
+ `Track not found for instrument "${instrument}" at difficulty "${difficulty}"`
586
+ );
587
+ }
588
+ this.instrumentType = (0, import_scan_chart.getInstrumentType)(instrument);
589
+ this.noteEvents = track.noteEventGroups.flat();
590
+ this.notes = new EventSequence(this.noteEvents);
591
+ this.soloSections = new EventSequence(track.soloSections);
592
+ this.flexLanes = new EventSequence(track.flexLanes);
593
+ this.drumFreestyleSections = new EventSequence(track.drumFreestyleSections);
594
+ }
595
+ updateDisplayedNotes(chartCurrentTimeMs) {
596
+ const noteStartIndex = this.notes.getEarliestActiveEventIndex(chartCurrentTimeMs);
597
+ const renderEndTimeMs = chartCurrentTimeMs + HIGHWAY_DURATION_MS;
598
+ let maxNoteEventIndex = noteStartIndex - 1;
599
+ for (const [noteEventIndex, sprite] of this.noteGroups) {
600
+ if (noteEventIndex < noteStartIndex || this.noteEvents[noteEventIndex].msTime > renderEndTimeMs) {
601
+ this.scene.remove(sprite);
602
+ this.noteGroups.delete(noteEventIndex);
603
+ } else {
604
+ sprite.position.y = interpolate(
605
+ this.noteEvents[noteEventIndex].msTime,
606
+ chartCurrentTimeMs,
607
+ renderEndTimeMs,
608
+ -1,
609
+ 1
610
+ );
611
+ if (noteEventIndex > maxNoteEventIndex) {
612
+ maxNoteEventIndex = noteEventIndex;
613
+ }
614
+ }
615
+ }
616
+ for (let i = maxNoteEventIndex + 1; this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs; i++) {
617
+ const note = this.noteEvents[i];
618
+ const noteGroup = new THREE.Group();
619
+ const scale = note.type === import_scan_chart.noteTypes.kick ? 0.045 : note.type === import_scan_chart.noteTypes.open && this.instrumentType === import_scan_chart.instrumentTypes.sixFret ? 0.04 : SCALE;
620
+ const sprite = new THREE.Sprite(
621
+ this.noteMaterials.get(note.type).get(note.flags)
622
+ );
623
+ noteGroup.add(sprite);
624
+ sprite.center = new THREE.Vector2(
625
+ note.type === import_scan_chart.noteTypes.kick ? 0.62 : 0.5,
626
+ note.type === import_scan_chart.noteTypes.kick ? -0.5 : 0
627
+ );
628
+ const aspectRatio = sprite.material.map.image.width / sprite.material.map.image.height;
629
+ sprite.scale.set(scale * aspectRatio, scale, scale);
630
+ noteGroup.position.x = calculateNoteXOffset(
631
+ this.instrumentType,
632
+ note.type
633
+ );
634
+ noteGroup.position.y = interpolate(
635
+ note.msTime,
636
+ chartCurrentTimeMs,
637
+ renderEndTimeMs,
638
+ -1,
639
+ 1
640
+ );
641
+ noteGroup.position.z = 0;
642
+ sprite.material.clippingPlanes = this.clippingPlanes;
643
+ sprite.material.depthTest = false;
644
+ sprite.material.transparent = true;
645
+ sprite.renderOrder = note.type === import_scan_chart.noteTypes.kick ? 1 : 4;
646
+ if (note.msLength > 0) {
647
+ const mat = new THREE.MeshBasicMaterial({
648
+ color: calculateColor(note.type),
649
+ side: THREE.DoubleSide
650
+ });
651
+ mat.clippingPlanes = this.clippingPlanes;
652
+ mat.depthTest = false;
653
+ mat.transparent = true;
654
+ const geometry = new THREE.PlaneGeometry(
655
+ SCALE * (note.type === import_scan_chart.noteTypes.open ? 5 : 0.3),
656
+ 2 * (note.msLength / HIGHWAY_DURATION_MS)
657
+ );
658
+ const plane = new THREE.Mesh(geometry, mat);
659
+ plane.position.y = 0.03 + note.msLength / HIGHWAY_DURATION_MS;
660
+ plane.renderOrder = 2;
661
+ noteGroup.add(plane);
662
+ }
663
+ this.noteGroups.set(i, noteGroup);
664
+ this.scene.add(noteGroup);
665
+ }
666
+ }
667
+ };
668
+ var EventSequence = class {
669
+ /** Assumes `events` are already sorted in `msTime` order. */
670
+ constructor(events) {
671
+ this.events = events;
672
+ /** Contains the closest events before msTime, grouped by type */
673
+ this.lastPrecedingEventIndexesOfType = /* @__PURE__ */ new Map();
674
+ this.lastPrecedingEventIndex = -1;
675
+ }
676
+ getEarliestActiveEventIndex(startMs) {
677
+ if (this.lastPrecedingEventIndex !== -1 && startMs < this.events[this.lastPrecedingEventIndex].msTime) {
678
+ this.lastPrecedingEventIndexesOfType = /* @__PURE__ */ new Map();
679
+ this.lastPrecedingEventIndex = -1;
680
+ }
681
+ while (this.events[this.lastPrecedingEventIndex + 1] && this.events[this.lastPrecedingEventIndex + 1].msTime < startMs) {
682
+ this.lastPrecedingEventIndexesOfType.set(
683
+ this.events[this.lastPrecedingEventIndex + 1].type,
684
+ this.lastPrecedingEventIndex + 1
685
+ );
686
+ this.lastPrecedingEventIndex++;
687
+ }
688
+ let earliestActiveEventIndex = null;
689
+ for (const [, index] of this.lastPrecedingEventIndexesOfType) {
690
+ if (this.events[index].msTime + this.events[index].msLength > startMs) {
691
+ if (earliestActiveEventIndex === null || earliestActiveEventIndex > index) {
692
+ earliestActiveEventIndex = index;
693
+ }
694
+ }
695
+ }
696
+ return earliestActiveEventIndex === null ? this.lastPrecedingEventIndex + 1 : earliestActiveEventIndex;
697
+ }
698
+ };
699
+ async function loadTextures(instrumentType) {
700
+ const textureLoader = new THREE.TextureLoader();
701
+ const load = (path) => textureLoader.loadAsync("https://static.enchor.us/" + path);
702
+ const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
703
+ (async () => {
704
+ const texture = await load("preview-highway.png");
705
+ texture.wrapS = THREE.RepeatWrapping;
706
+ texture.wrapT = THREE.RepeatWrapping;
707
+ texture.repeat.set(1, 2);
708
+ return texture;
709
+ })(),
710
+ (async () => {
711
+ switch (instrumentType) {
712
+ case import_scan_chart.instrumentTypes.drums:
713
+ return await load("preview-drums-strikeline.png");
714
+ case import_scan_chart.instrumentTypes.sixFret:
715
+ return await load("preview-6fret-strikeline.png");
716
+ case import_scan_chart.instrumentTypes.fiveFret:
717
+ return await load("preview-5fret-strikeline.png");
718
+ }
719
+ })(),
720
+ (async () => {
721
+ const texturePromises = [];
722
+ const addTexture = (type, flags, path) => {
723
+ const texture = textureLoader.loadAsync(
724
+ `https://static.enchor.us/preview-${path}.webp`
725
+ );
726
+ texturePromises.push({ type, flags, texture });
727
+ return texture;
728
+ };
729
+ const reuseTexture = (type, flags, texture) => {
730
+ texturePromises.push({ type, flags, texture });
731
+ };
732
+ if (instrumentType === import_scan_chart.instrumentTypes.drums) {
733
+ const colors = /* @__PURE__ */ new Map([
734
+ [import_scan_chart.noteTypes.redDrum, "red"],
735
+ [import_scan_chart.noteTypes.yellowDrum, "yellow"],
736
+ [import_scan_chart.noteTypes.blueDrum, "blue"],
737
+ [import_scan_chart.noteTypes.greenDrum, "green"]
738
+ ]);
739
+ const dynamicFlags = /* @__PURE__ */ new Map([
740
+ [import_scan_chart.noteFlags.none, ""],
741
+ [import_scan_chart.noteFlags.ghost, "-ghost"],
742
+ [import_scan_chart.noteFlags.accent, "-accent"]
743
+ ]);
744
+ const spFlags = /* @__PURE__ */ new Map([
745
+ [import_scan_chart.noteFlags.none, ""],
746
+ [SP_FLAG, "-sp"]
747
+ ]);
748
+ addTexture(import_scan_chart.noteTypes.kick, import_scan_chart.noteFlags.none, "drums-kick");
749
+ addTexture(import_scan_chart.noteTypes.kick, import_scan_chart.noteFlags.doubleKick, "drums-kick");
750
+ addTexture(import_scan_chart.noteTypes.kick, import_scan_chart.noteFlags.none | SP_FLAG, "drums-kick-sp");
751
+ addTexture(
752
+ import_scan_chart.noteTypes.kick,
753
+ import_scan_chart.noteFlags.doubleKick | SP_FLAG,
754
+ "drums-kick-sp"
755
+ );
756
+ for (const [colorKey, colorName] of colors) {
757
+ for (const [dynamicFlagKey, dynamicFlagName] of dynamicFlags) {
758
+ for (const [spFlagKey, spFlagName] of spFlags) {
759
+ addTexture(
760
+ colorKey,
761
+ spFlagKey | dynamicFlagKey | import_scan_chart.noteFlags.tom,
762
+ `drums-${colorName}-tom${dynamicFlagName}${spFlagName}`
763
+ );
764
+ if (colorKey !== import_scan_chart.noteTypes.redDrum) {
765
+ addTexture(
766
+ colorKey,
767
+ spFlagKey | dynamicFlagKey | import_scan_chart.noteFlags.cymbal,
768
+ `drums-${colorName}-cymbal${dynamicFlagName}${spFlagName}`
769
+ );
770
+ }
771
+ }
772
+ }
773
+ }
774
+ } else if (instrumentType === import_scan_chart.instrumentTypes.sixFret) {
775
+ const lanes = /* @__PURE__ */ new Map([
776
+ [import_scan_chart.noteTypes.open, "open"],
777
+ [import_scan_chart.noteTypes.black1, "black"],
778
+ [import_scan_chart.noteTypes.white1, "white"],
779
+ [BARRE1_TYPE, "barre"]
780
+ ]);
781
+ const modifiers = /* @__PURE__ */ new Map([
782
+ [import_scan_chart.noteFlags.strum, "-strum"],
783
+ [import_scan_chart.noteFlags.hopo, "-hopo"],
784
+ [import_scan_chart.noteFlags.tap, "-tap"]
785
+ ]);
786
+ const spFlags = /* @__PURE__ */ new Map([
787
+ [import_scan_chart.noteFlags.none, ""],
788
+ [SP_FLAG, "-sp"]
789
+ ]);
790
+ for (const [laneKey, laneName] of lanes) {
791
+ for (const [modifierKey, modifierName] of modifiers) {
792
+ for (const [spFlagKey, spFlagName] of spFlags) {
793
+ const texturePromise = addTexture(
794
+ laneKey,
795
+ modifierKey | spFlagKey,
796
+ `6fret-${laneName}${modifierName}${spFlagName}`
797
+ );
798
+ if (laneKey === import_scan_chart.noteTypes.black1) {
799
+ reuseTexture(
800
+ import_scan_chart.noteTypes.black2,
801
+ modifierKey | spFlagKey,
802
+ texturePromise
803
+ );
804
+ reuseTexture(
805
+ import_scan_chart.noteTypes.black3,
806
+ modifierKey | spFlagKey,
807
+ texturePromise
808
+ );
809
+ } else if (laneKey === import_scan_chart.noteTypes.white1) {
810
+ reuseTexture(
811
+ import_scan_chart.noteTypes.white2,
812
+ modifierKey | spFlagKey,
813
+ texturePromise
814
+ );
815
+ reuseTexture(
816
+ import_scan_chart.noteTypes.white3,
817
+ modifierKey | spFlagKey,
818
+ texturePromise
819
+ );
820
+ } else if (laneKey === BARRE1_TYPE) {
821
+ reuseTexture(
822
+ BARRE2_TYPE,
823
+ modifierKey | spFlagKey,
824
+ texturePromise
825
+ );
826
+ reuseTexture(
827
+ BARRE3_TYPE,
828
+ modifierKey | spFlagKey,
829
+ texturePromise
830
+ );
831
+ }
832
+ }
833
+ }
834
+ }
835
+ } else if (instrumentType === import_scan_chart.instrumentTypes.fiveFret) {
836
+ const lanes = /* @__PURE__ */ new Map([
837
+ [import_scan_chart.noteTypes.open, "open"],
838
+ [import_scan_chart.noteTypes.green, "green"],
839
+ [import_scan_chart.noteTypes.red, "red"],
840
+ [import_scan_chart.noteTypes.yellow, "yellow"],
841
+ [import_scan_chart.noteTypes.blue, "blue"],
842
+ [import_scan_chart.noteTypes.orange, "orange"]
843
+ ]);
844
+ const modifiers = /* @__PURE__ */ new Map([
845
+ [import_scan_chart.noteFlags.strum, "-strum"],
846
+ [import_scan_chart.noteFlags.hopo, "-hopo"],
847
+ [import_scan_chart.noteFlags.tap, "-tap"]
848
+ ]);
849
+ const spFlags = /* @__PURE__ */ new Map([
850
+ [import_scan_chart.noteFlags.none, ""],
851
+ [SP_FLAG, "-sp"]
852
+ ]);
853
+ for (const [laneKey, laneName] of lanes) {
854
+ for (let [modifierKey, modifierName] of modifiers) {
855
+ for (const [spFlagKey, spFlagName] of spFlags) {
856
+ if (laneKey === import_scan_chart.noteTypes.open && modifierKey === import_scan_chart.noteFlags.tap) {
857
+ modifierName = "-hopo";
858
+ modifierKey = import_scan_chart.noteFlags.hopo;
859
+ }
860
+ addTexture(
861
+ laneKey,
862
+ modifierKey | spFlagKey,
863
+ `5fret-${laneName}${modifierName}${spFlagName}`
864
+ );
865
+ }
866
+ }
867
+ }
868
+ }
869
+ const textures = await Promise.all(
870
+ texturePromises.map(async (t) => ({
871
+ type: t.type,
872
+ flags: t.flags,
873
+ texture: await t.texture
874
+ }))
875
+ );
876
+ const textureMap = /* @__PURE__ */ new Map();
877
+ Object.values(import_scan_chart.noteTypes).forEach(
878
+ (noteType) => textureMap.set(noteType, /* @__PURE__ */ new Map())
879
+ );
880
+ barreTypes.forEach((barreType) => textureMap.set(barreType, /* @__PURE__ */ new Map()));
881
+ for (const texture of textures) {
882
+ textureMap.get(texture.type).set(texture.flags, texture.texture);
883
+ }
884
+ return textureMap;
885
+ })()
886
+ ]);
887
+ return {
888
+ highwayTexture,
889
+ strikelineTexture,
890
+ noteTextures
891
+ };
892
+ }
893
+ function adjustParsedChart(parsedChart, instrument, difficulty) {
894
+ const track = parsedChart.trackData.find(
895
+ (t) => t.instrument === instrument && t.difficulty === difficulty
896
+ );
897
+ const starPower = track.starPowerSections;
898
+ if (starPower.length > 0) {
899
+ let starPowerIndex = 0;
900
+ for (const noteGroup of track.noteEventGroups) {
901
+ while (starPowerIndex < starPower.length && starPower[starPowerIndex].tick + starPower[starPowerIndex].length < noteGroup[0].tick) {
902
+ starPowerIndex++;
903
+ }
904
+ if (starPowerIndex === starPower.length) {
905
+ break;
906
+ }
907
+ if (noteGroup[0].tick >= starPower[starPowerIndex].tick && noteGroup[0].tick < starPower[starPowerIndex].tick + starPower[starPowerIndex].length) {
908
+ for (const note of noteGroup) {
909
+ note.flags |= SP_FLAG;
910
+ }
911
+ }
912
+ }
913
+ }
914
+ if ((0, import_scan_chart.getInstrumentType)(instrument) === import_scan_chart.instrumentTypes.sixFret) {
915
+ for (const noteGroup of track.noteEventGroups) {
916
+ let oneCount = 0;
917
+ let twoCount = 0;
918
+ let threeCount = 0;
919
+ for (const note of noteGroup) {
920
+ switch (note.type) {
921
+ case import_scan_chart.noteTypes.black1:
922
+ case import_scan_chart.noteTypes.white1:
923
+ oneCount++;
924
+ break;
925
+ case import_scan_chart.noteTypes.black2:
926
+ case import_scan_chart.noteTypes.white2:
927
+ twoCount++;
928
+ break;
929
+ case import_scan_chart.noteTypes.black3:
930
+ case import_scan_chart.noteTypes.white3:
931
+ threeCount++;
932
+ break;
933
+ }
934
+ }
935
+ if (oneCount > 1) {
936
+ const removed = removeFromArray(
937
+ noteGroup,
938
+ (n) => n.type === import_scan_chart.noteTypes.black1 || n.type === import_scan_chart.noteTypes.white1
939
+ );
940
+ removed[0].type = BARRE1_TYPE;
941
+ noteGroup.push(removed[0]);
942
+ }
943
+ if (twoCount > 1) {
944
+ const removed = removeFromArray(
945
+ noteGroup,
946
+ (n) => n.type === import_scan_chart.noteTypes.black2 || n.type === import_scan_chart.noteTypes.white2
947
+ );
948
+ removed[0].type = BARRE2_TYPE;
949
+ noteGroup.push(removed[0]);
950
+ }
951
+ if (threeCount > 1) {
952
+ const removed = removeFromArray(
953
+ noteGroup,
954
+ (n) => n.type === import_scan_chart.noteTypes.black3 || n.type === import_scan_chart.noteTypes.white3
955
+ );
956
+ removed[0].type = BARRE3_TYPE;
957
+ noteGroup.push(removed[0]);
958
+ }
959
+ }
960
+ } else if ((0, import_scan_chart.getInstrumentType)(instrument) === import_scan_chart.instrumentTypes.drums) {
961
+ for (const noteGroup of track.noteEventGroups) {
962
+ for (const note of noteGroup) {
963
+ if (note.flags & import_scan_chart.noteFlags.discoNoflip) {
964
+ note.flags &= ~import_scan_chart.noteFlags.discoNoflip;
965
+ }
966
+ if (note.flags & import_scan_chart.noteFlags.disco) {
967
+ note.flags &= ~import_scan_chart.noteFlags.disco;
968
+ switch (note.type) {
969
+ case import_scan_chart.noteTypes.redDrum:
970
+ note.type = import_scan_chart.noteTypes.yellowDrum;
971
+ note.flags &= ~import_scan_chart.noteFlags.tom;
972
+ note.flags |= import_scan_chart.noteFlags.cymbal;
973
+ break;
974
+ case import_scan_chart.noteTypes.yellowDrum:
975
+ note.type = import_scan_chart.noteTypes.redDrum;
976
+ note.flags &= ~import_scan_chart.noteFlags.cymbal;
977
+ note.flags |= import_scan_chart.noteFlags.tom;
978
+ }
979
+ }
980
+ }
981
+ }
982
+ }
983
+ return parsedChart;
984
+ }
985
+ function calculateNoteXOffset(instrumentType, noteType) {
986
+ const lane = calculateLane(noteType);
987
+ const leftOffset = instrumentType === import_scan_chart.instrumentTypes.drums ? 0.135 : instrumentType === import_scan_chart.instrumentTypes.sixFret && noteType !== import_scan_chart.noteTypes.open ? 0.2 : instrumentType === import_scan_chart.instrumentTypes.sixFret && noteType === import_scan_chart.noteTypes.open ? 0.035 : 0.035;
988
+ return leftOffset + -(NOTE_SPAN_WIDTH / 2) + SCALE + (NOTE_SPAN_WIDTH - SCALE) / 5 * lane;
989
+ }
990
+ function calculateLane(noteType) {
991
+ switch (noteType) {
992
+ case import_scan_chart.noteTypes.green:
993
+ case import_scan_chart.noteTypes.redDrum:
994
+ case import_scan_chart.noteTypes.black1:
995
+ case import_scan_chart.noteTypes.white1:
996
+ case BARRE1_TYPE:
997
+ return 0;
998
+ case import_scan_chart.noteTypes.red:
999
+ case import_scan_chart.noteTypes.yellowDrum:
1000
+ case import_scan_chart.noteTypes.black2:
1001
+ case import_scan_chart.noteTypes.white2:
1002
+ case BARRE2_TYPE:
1003
+ return 1;
1004
+ case import_scan_chart.noteTypes.yellow:
1005
+ case import_scan_chart.noteTypes.blueDrum:
1006
+ case import_scan_chart.noteTypes.open:
1007
+ case import_scan_chart.noteTypes.kick:
1008
+ case import_scan_chart.noteTypes.black3:
1009
+ case import_scan_chart.noteTypes.white3:
1010
+ case BARRE3_TYPE:
1011
+ return 2;
1012
+ case import_scan_chart.noteTypes.blue:
1013
+ case import_scan_chart.noteTypes.greenDrum:
1014
+ return 3;
1015
+ case import_scan_chart.noteTypes.orange:
1016
+ return 4;
1017
+ default:
1018
+ return 0;
1019
+ }
1020
+ }
1021
+ function calculateColor(noteType) {
1022
+ switch (noteType) {
1023
+ case import_scan_chart.noteTypes.green:
1024
+ case import_scan_chart.noteTypes.greenDrum:
1025
+ return "#01B11A";
1026
+ case import_scan_chart.noteTypes.red:
1027
+ case import_scan_chart.noteTypes.redDrum:
1028
+ return "#DD2214";
1029
+ case import_scan_chart.noteTypes.yellow:
1030
+ case import_scan_chart.noteTypes.yellowDrum:
1031
+ return "#DEEB52";
1032
+ case import_scan_chart.noteTypes.blue:
1033
+ case import_scan_chart.noteTypes.blueDrum:
1034
+ return "#006CAF";
1035
+ case import_scan_chart.noteTypes.open:
1036
+ return "#8A0BB5";
1037
+ case import_scan_chart.noteTypes.orange:
1038
+ return "#F8B272";
1039
+ default:
1040
+ return "#FFFFFF";
1041
+ }
1042
+ }
1043
+ function removeFromArray(array, predicate) {
1044
+ const removed = [];
1045
+ for (let i = array.length - 1; i >= 0; i--) {
1046
+ if (predicate(array[i])) {
1047
+ removed.unshift(array.splice(i, 1)[0]);
1048
+ }
1049
+ }
1050
+ return removed;
1051
+ }
1052
+ function interpolate(val, fromStart, fromEnd, toStart, toEnd) {
1053
+ return (val - fromStart) / (fromEnd - fromStart) * (toEnd - toStart) + toStart;
1054
+ }
1055
+
1056
+ // src/SngLoader.ts
1057
+ var import_parse_sng = require("parse-sng");
1058
+ var import_scan_chart2 = require("scan-chart");
1059
+ var AUDIO_EXTENSIONS = [".ogg", ".mp3", ".wav", ".opus"];
1060
+ var EXCLUDED_AUDIO_NAMES = ["preview", "crowd"];
1061
+ async function extractSngFile(data) {
1062
+ return new Promise((resolve, reject) => {
1063
+ const files = [];
1064
+ const stream = new ReadableStream({
1065
+ start(controller) {
1066
+ controller.enqueue(data);
1067
+ controller.close();
1068
+ }
1069
+ });
1070
+ const sngStream = new import_parse_sng.SngStream(stream, { generateSongIni: true });
1071
+ sngStream.on("error", (error) => {
1072
+ reject(error instanceof Error ? error : new Error(String(error)));
1073
+ });
1074
+ sngStream.on("file", async (fileName, fileStream, nextFile) => {
1075
+ try {
1076
+ const chunks = [];
1077
+ const reader = fileStream.getReader();
1078
+ while (true) {
1079
+ const { done, value } = await reader.read();
1080
+ if (done)
1081
+ break;
1082
+ chunks.push(value);
1083
+ }
1084
+ const totalLength = chunks.reduce(
1085
+ (sum, chunk) => sum + chunk.length,
1086
+ 0
1087
+ );
1088
+ const fileData = new Uint8Array(totalLength);
1089
+ let offset = 0;
1090
+ for (const chunk of chunks) {
1091
+ fileData.set(chunk, offset);
1092
+ offset += chunk.length;
1093
+ }
1094
+ files.push({ fileName, data: fileData });
1095
+ if (nextFile) {
1096
+ nextFile();
1097
+ } else {
1098
+ resolve(files);
1099
+ }
1100
+ } catch (error) {
1101
+ reject(error instanceof Error ? error : new Error(String(error)));
1102
+ }
1103
+ });
1104
+ sngStream.start();
1105
+ });
1106
+ }
1107
+ function findChartFile(files) {
1108
+ const midFile = files.find((f) => f.fileName.toLowerCase().endsWith(".mid"));
1109
+ if (midFile) {
1110
+ return { ...midFile, format: "mid" };
1111
+ }
1112
+ const chartFile = files.find(
1113
+ (f) => f.fileName.toLowerCase().endsWith(".chart")
1114
+ );
1115
+ if (chartFile) {
1116
+ return { ...chartFile, format: "chart" };
1117
+ }
1118
+ return null;
1119
+ }
1120
+ function findAudioFiles(files) {
1121
+ return files.filter((file) => {
1122
+ const ext = file.fileName.toLowerCase().slice(file.fileName.lastIndexOf("."));
1123
+ if (!AUDIO_EXTENSIONS.includes(ext)) {
1124
+ return false;
1125
+ }
1126
+ const baseName = file.fileName.toLowerCase().replace(/\.[^/.]+$/, "").split("/").pop() || "";
1127
+ return !EXCLUDED_AUDIO_NAMES.some(
1128
+ (excluded) => baseName.includes(excluded)
1129
+ );
1130
+ }).map((file) => file.data);
1131
+ }
1132
+ function isVideoFile(fileName) {
1133
+ const videoExtensions = [".mp4", ".avi", ".webm", ".ogv", ".mpeg"];
1134
+ const ext = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
1135
+ return videoExtensions.includes(ext);
1136
+ }
1137
+ async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0) {
1138
+ const filteredFiles = files.filter((f) => !isVideoFile(f.fileName));
1139
+ const scannedChart = (0, import_scan_chart2.scanChartFolder)(filteredFiles);
1140
+ if (!scannedChart.playable || !scannedChart.notesData) {
1141
+ throw new Error("This chart has no playable tracks.");
1142
+ }
1143
+ const chartFile = findChartFile(filteredFiles);
1144
+ if (!chartFile) {
1145
+ throw new Error("No .chart or .mid file found in the chart.");
1146
+ }
1147
+ const modifiers = {
1148
+ song_length: scannedChart.song_length || 0,
1149
+ hopo_frequency: scannedChart.hopo_frequency || 0,
1150
+ eighthnote_hopo: scannedChart.eighthnote_hopo || false,
1151
+ multiplier_note: scannedChart.multiplier_note || 0,
1152
+ sustain_cutoff_threshold: scannedChart.sustain_cutoff_threshold ?? -1,
1153
+ chord_snap_threshold: scannedChart.chord_snap_threshold || 0,
1154
+ five_lane_drums: scannedChart.five_lane_drums || false,
1155
+ pro_drums: scannedChart.pro_drums || false
1156
+ };
1157
+ const parsedChart = (0, import_scan_chart2.parseChartFile)(
1158
+ chartFile.data,
1159
+ chartFile.format,
1160
+ modifiers
1161
+ );
1162
+ const track = parsedChart.trackData.find(
1163
+ (t) => t.instrument === instrument && t.difficulty === difficulty
1164
+ );
1165
+ if (!track) {
1166
+ throw new Error(
1167
+ `No ${difficulty} ${instrument} track found in this chart.`
1168
+ );
1169
+ }
1170
+ const audioFiles = findAudioFiles(filteredFiles);
1171
+ const instrumentType = (0, import_scan_chart2.getInstrumentType)(instrument);
1172
+ const textures = await ChartPreview.loadTextures(instrumentType);
1173
+ const lastNote = parsedChart.trackData.flatMap((t) => t.noteEventGroups.flat()).reduce((max, note) => Math.max(max, note.msTime + note.msLength), 0);
1174
+ const audioLengthMs = scannedChart.song_length || lastNote + 5e3;
1175
+ const startDelayMs = (scannedChart.delay || 0) + (scannedChart.chart_offset || 0) * 1e3;
1176
+ const seekPercent = initialSeekPercent > 0 ? initialSeekPercent : scannedChart.preview_start_time ? scannedChart.preview_start_time / audioLengthMs : 0;
1177
+ return {
1178
+ parsedChart,
1179
+ textures,
1180
+ audioFiles,
1181
+ instrument,
1182
+ difficulty,
1183
+ startDelayMs,
1184
+ audioLengthMs,
1185
+ initialSeekPercent: seekPercent
1186
+ };
1187
+ }
1188
+ async function fetchSngFile(url, signal) {
1189
+ const response = await fetch(url, { signal });
1190
+ if (!response.ok) {
1191
+ throw new Error(
1192
+ `Failed to fetch chart: ${response.status} ${response.statusText}`
1193
+ );
1194
+ }
1195
+ const arrayBuffer = await response.arrayBuffer();
1196
+ return new Uint8Array(arrayBuffer);
1197
+ }
1198
+
1199
+ // src/ChartPreviewPlayer.ts
1200
+ var ICONS = {
1201
+ play: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M11.596 8.697l-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/></svg>`,
1202
+ pause: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/></svg>`,
1203
+ replay: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>`,
1204
+ volumeMute: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zm7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z"/></svg>`,
1205
+ volumeLow: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M9 4a.5.5 0 0 0-.812-.39L5.825 5.5H3.5A.5.5 0 0 0 3 6v4a.5.5 0 0 0 .5.5h2.325l2.363 1.89A.5.5 0 0 0 9 12V4zm3.025 4a4.486 4.486 0 0 1-1.318 3.182L10 10.475A3.489 3.489 0 0 0 11.025 8 3.49 3.49 0 0 0 10 5.525l.707-.707A4.486 4.486 0 0 1 12.025 8z"/></svg>`,
1206
+ volumeHigh: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z"/><path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z"/><path d="M8.707 11.182A4.486 4.486 0 0 0 10.025 8a4.486 4.486 0 0 0-1.318-3.182L8 5.525A3.489 3.489 0 0 1 9.025 8 3.49 3.49 0 0 1 8 10.475l.707.707zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z"/></svg>`,
1207
+ fullscreen: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/></svg>`,
1208
+ fullscreenExit: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 0a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z"/></svg>`,
1209
+ spinner: `<svg viewBox="0 0 16 16" fill="currentColor" class="spinner"><path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/></svg>`
1210
+ };
1211
+ var STYLES = `
1212
+ :host {
1213
+ /* Themeable CSS custom properties */
1214
+ --player-bg: #000;
1215
+ --controls-bg: #2a2a3a;
1216
+ --controls-hover-bg: #3a3a4a;
1217
+ --slider-track-bg: #1a1a2a;
1218
+ --accent-color: #3b82f6;
1219
+ --text-color: #e0e0e0;
1220
+ --text-muted: #aaa;
1221
+ --text-idle: #666;
1222
+ --error-color: #ef4444;
1223
+ --shadow-color: rgba(0, 0, 0, 0.3);
1224
+
1225
+ display: block;
1226
+ width: 100%;
1227
+ height: 100%;
1228
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1229
+ }
1230
+
1231
+ * {
1232
+ box-sizing: border-box;
1233
+ }
1234
+
1235
+ .player-container {
1236
+ display: flex;
1237
+ flex-direction: column;
1238
+ width: 100%;
1239
+ height: 100%;
1240
+ background: var(--player-bg);
1241
+ position: relative;
1242
+ }
1243
+
1244
+ .player-container.fullscreen {
1245
+ position: fixed;
1246
+ top: 0;
1247
+ left: 0;
1248
+ right: 0;
1249
+ bottom: 0;
1250
+ z-index: 9999;
1251
+ }
1252
+
1253
+ .preview-viewport {
1254
+ flex: 1;
1255
+ width: 100%;
1256
+ background: var(--player-bg);
1257
+ cursor: pointer;
1258
+ display: flex;
1259
+ align-items: center;
1260
+ justify-content: center;
1261
+ overflow: hidden;
1262
+ position: relative;
1263
+ }
1264
+
1265
+ .preview-viewport canvas {
1266
+ max-width: 100%;
1267
+ max-height: 100%;
1268
+ }
1269
+
1270
+ .fullscreen-btn svg {
1271
+ width: 20px;
1272
+ height: 20px;
1273
+ }
1274
+
1275
+ .controls {
1276
+ display: flex;
1277
+ align-items: center;
1278
+ background: var(--controls-bg);
1279
+ padding: 0;
1280
+ height: 40px;
1281
+ flex-shrink: 0;
1282
+ }
1283
+
1284
+ .control-btn {
1285
+ background: var(--controls-bg);
1286
+ border: none;
1287
+ color: var(--text-color);
1288
+ cursor: pointer;
1289
+ display: flex;
1290
+ align-items: center;
1291
+ justify-content: center;
1292
+ height: 40px;
1293
+ width: 38px;
1294
+ padding: 8px;
1295
+ transition: background 0.15s;
1296
+ flex-shrink: 0;
1297
+ }
1298
+
1299
+ .control-btn:hover:not(:disabled) {
1300
+ background: var(--controls-hover-bg);
1301
+ }
1302
+
1303
+ .control-btn:disabled {
1304
+ opacity: 0.5;
1305
+ cursor: not-allowed;
1306
+ }
1307
+
1308
+ .control-btn svg {
1309
+ width: 20px;
1310
+ height: 20px;
1311
+ }
1312
+
1313
+ .control-btn .spinner {
1314
+ animation: spin 1s linear infinite;
1315
+ }
1316
+
1317
+ @keyframes spin {
1318
+ from { transform: rotate(0deg); }
1319
+ to { transform: rotate(360deg); }
1320
+ }
1321
+
1322
+ .volume-wrapper {
1323
+ position: relative;
1324
+ display: flex;
1325
+ flex-shrink: 0;
1326
+ }
1327
+
1328
+ .volume-dropdown {
1329
+ position: absolute;
1330
+ bottom: 100%;
1331
+ left: 50%;
1332
+ transform: translateX(-50%);
1333
+ background: var(--controls-bg);
1334
+ border-radius: 4px;
1335
+ padding: 10px 6px;
1336
+ box-shadow: 0 -2px 10px var(--shadow-color);
1337
+ display: none;
1338
+ z-index: 20;
1339
+ }
1340
+
1341
+ .volume-wrapper:hover .volume-dropdown,
1342
+ .volume-dropdown:hover {
1343
+ display: block;
1344
+ }
1345
+
1346
+ .volume-slider {
1347
+ writing-mode: vertical-lr;
1348
+ direction: rtl;
1349
+ width: 6px;
1350
+ height: 80px;
1351
+ -webkit-appearance: none;
1352
+ appearance: none;
1353
+ background: var(--slider-track-bg);
1354
+ border-radius: 3px;
1355
+ cursor: pointer;
1356
+ }
1357
+
1358
+ .volume-slider::-webkit-slider-thumb {
1359
+ -webkit-appearance: none;
1360
+ width: 14px;
1361
+ height: 14px;
1362
+ background: var(--accent-color);
1363
+ border-radius: 50%;
1364
+ cursor: pointer;
1365
+ }
1366
+
1367
+ .volume-slider::-moz-range-thumb {
1368
+ width: 14px;
1369
+ height: 14px;
1370
+ background: var(--accent-color);
1371
+ border-radius: 50%;
1372
+ cursor: pointer;
1373
+ border: none;
1374
+ }
1375
+
1376
+ .timestamp {
1377
+ color: var(--text-muted);
1378
+ font-size: 12px;
1379
+ padding: 0 8px;
1380
+ white-space: nowrap;
1381
+ flex-shrink: 0;
1382
+ min-width: 85px;
1383
+ text-align: center;
1384
+ }
1385
+
1386
+ .seek-wrapper {
1387
+ flex: 1;
1388
+ display: flex;
1389
+ align-items: center;
1390
+ padding: 0 8px;
1391
+ min-width: 0;
1392
+ }
1393
+
1394
+ .seek-bar {
1395
+ width: 100%;
1396
+ height: 6px;
1397
+ -webkit-appearance: none;
1398
+ appearance: none;
1399
+ background: var(--slider-track-bg);
1400
+ border-radius: 3px;
1401
+ cursor: pointer;
1402
+ }
1403
+
1404
+ .seek-bar::-webkit-slider-thumb {
1405
+ -webkit-appearance: none;
1406
+ width: 14px;
1407
+ height: 14px;
1408
+ background: var(--accent-color);
1409
+ border-radius: 50%;
1410
+ cursor: pointer;
1411
+ transition: transform 0.1s;
1412
+ }
1413
+
1414
+ .seek-bar::-webkit-slider-thumb:hover {
1415
+ transform: scale(1.2);
1416
+ }
1417
+
1418
+ .seek-bar::-moz-range-thumb {
1419
+ width: 14px;
1420
+ height: 14px;
1421
+ background: var(--accent-color);
1422
+ border-radius: 50%;
1423
+ cursor: pointer;
1424
+ border: none;
1425
+ }
1426
+
1427
+ .seek-bar:disabled {
1428
+ opacity: 0.5;
1429
+ cursor: not-allowed;
1430
+ }
1431
+
1432
+ .idle-message {
1433
+ color: var(--text-idle);
1434
+ font-size: 14px;
1435
+ }
1436
+
1437
+ .error-message {
1438
+ color: var(--error-color);
1439
+ font-size: 14px;
1440
+ text-align: center;
1441
+ padding: 20px;
1442
+ }
1443
+ `;
1444
+ var ChartPreviewPlayer = class extends HTMLElement {
1445
+ constructor() {
1446
+ super();
1447
+ this.chartPreview = null;
1448
+ this._state = "idle";
1449
+ this._volume = 50;
1450
+ this._lastVolumeBeforeMute = 50;
1451
+ this._isFullscreen = false;
1452
+ this.timestampInterval = null;
1453
+ this._wasPlayingBeforeSeek = false;
1454
+ this._isSeeking = false;
1455
+ this._currentAbortController = null;
1456
+ this.handleKeydown = (e) => {
1457
+ if (e.target instanceof HTMLInputElement)
1458
+ return;
1459
+ switch (e.code) {
1460
+ case "Space":
1461
+ e.preventDefault();
1462
+ if (this._state !== "idle" && this._state !== "loading" && this._state !== "error") {
1463
+ this.togglePlayPause();
1464
+ }
1465
+ break;
1466
+ case "ArrowLeft":
1467
+ e.preventDefault();
1468
+ this.seekRelative(-5e3);
1469
+ break;
1470
+ case "ArrowRight":
1471
+ e.preventDefault();
1472
+ this.seekRelative(5e3);
1473
+ break;
1474
+ case "ArrowUp":
1475
+ e.preventDefault();
1476
+ this.setVolume(Math.min(100, this._volume + 10));
1477
+ break;
1478
+ case "ArrowDown":
1479
+ e.preventDefault();
1480
+ this.setVolume(Math.max(0, this._volume - 10));
1481
+ break;
1482
+ case "KeyM":
1483
+ e.preventDefault();
1484
+ this.toggleMute();
1485
+ break;
1486
+ case "KeyF":
1487
+ e.preventDefault();
1488
+ this.toggleFullscreen();
1489
+ break;
1490
+ case "Escape":
1491
+ if (this._isFullscreen) {
1492
+ e.preventDefault();
1493
+ this.toggleFullscreen();
1494
+ }
1495
+ break;
1496
+ }
1497
+ };
1498
+ this.handleFullscreenChange = () => {
1499
+ this._isFullscreen = document.fullscreenElement === this.container;
1500
+ this.container.classList.toggle("fullscreen", this._isFullscreen);
1501
+ this.fullscreenBtn.innerHTML = this._isFullscreen ? ICONS.fullscreenExit : ICONS.fullscreen;
1502
+ this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Toggle fullscreen";
1503
+ };
1504
+ this.shadow = this.attachShadow({ mode: "open" });
1505
+ this.render();
1506
+ this.setupEventListeners();
1507
+ }
1508
+ static get observedAttributes() {
1509
+ return ["volume"];
1510
+ }
1511
+ attributeChangedCallback(name, _oldValue, newValue) {
1512
+ if (name === "volume" && newValue !== null) {
1513
+ const parsed = parseInt(newValue, 10);
1514
+ this._volume = Math.max(0, Math.min(100, isNaN(parsed) ? 50 : parsed));
1515
+ if (this.volumeSlider) {
1516
+ this.volumeSlider.value = String(this._volume);
1517
+ }
1518
+ if (this.chartPreview) {
1519
+ this.chartPreview.volume = this._volume / 100;
1520
+ }
1521
+ this.updateVolumeIcon();
1522
+ }
1523
+ }
1524
+ connectedCallback() {
1525
+ this.setAttribute("tabindex", "0");
1526
+ document.addEventListener("fullscreenchange", this.handleFullscreenChange);
1527
+ }
1528
+ disconnectedCallback() {
1529
+ this.dispose();
1530
+ document.removeEventListener(
1531
+ "fullscreenchange",
1532
+ this.handleFullscreenChange
1533
+ );
1534
+ this.removeEventListener("keydown", this.handleKeydown);
1535
+ }
1536
+ render() {
1537
+ this.shadow.innerHTML = `
1538
+ <style>${STYLES}</style>
1539
+ <div class="player-container">
1540
+ <div class="preview-viewport">
1541
+ <span class="idle-message">Click play to start</span>
1542
+ </div>
1543
+ <div class="controls">
1544
+ <button class="control-btn play-btn" aria-label="Play" disabled>
1545
+ ${ICONS.play}
1546
+ </button>
1547
+ <div class="volume-wrapper">
1548
+ <button class="control-btn volume-btn" aria-label="Volume">
1549
+ ${ICONS.volumeHigh}
1550
+ </button>
1551
+ <div class="volume-dropdown">
1552
+ <input type="range" class="volume-slider" min="0" max="100" value="50" aria-label="Volume">
1553
+ </div>
1554
+ </div>
1555
+ <span class="timestamp">0:00 / 0:00</span>
1556
+ <div class="seek-wrapper">
1557
+ <input type="range" class="seek-bar" min="0" max="1000" value="0" disabled aria-label="Seek">
1558
+ </div>
1559
+ <button class="control-btn fullscreen-btn" aria-label="Toggle fullscreen" title="Toggle fullscreen">
1560
+ ${ICONS.fullscreen}
1561
+ </button>
1562
+ </div>
1563
+ </div>
1564
+ `;
1565
+ this.container = this.shadow.querySelector(".player-container");
1566
+ this.viewport = this.shadow.querySelector(".preview-viewport");
1567
+ this.playBtn = this.shadow.querySelector(".play-btn");
1568
+ this.volumeBtn = this.shadow.querySelector(".volume-btn");
1569
+ this.volumeSlider = this.shadow.querySelector(".volume-slider");
1570
+ this.seekBar = this.shadow.querySelector(".seek-bar");
1571
+ this.timestampEl = this.shadow.querySelector(".timestamp");
1572
+ this.fullscreenBtn = this.shadow.querySelector(
1573
+ ".controls .fullscreen-btn"
1574
+ );
1575
+ const volumeAttr = this.getAttribute("volume");
1576
+ if (volumeAttr !== null) {
1577
+ const parsed = parseInt(volumeAttr, 10);
1578
+ this._volume = Math.max(0, Math.min(100, isNaN(parsed) ? 50 : parsed));
1579
+ }
1580
+ this.volumeSlider.value = String(this._volume);
1581
+ this.updateVolumeIcon();
1582
+ }
1583
+ setupEventListeners() {
1584
+ this.playBtn.addEventListener("click", () => this.togglePlayPause());
1585
+ this.viewport.addEventListener("click", () => {
1586
+ if (this._state !== "idle" && this._state !== "loading" && this._state !== "error") {
1587
+ this.togglePlayPause();
1588
+ }
1589
+ });
1590
+ this.volumeBtn.addEventListener("click", () => this.toggleMute());
1591
+ this.volumeSlider.addEventListener("input", () => {
1592
+ const newVolume = parseInt(this.volumeSlider.value, 10);
1593
+ if (this._volume > 0 && newVolume === 0) {
1594
+ this._lastVolumeBeforeMute = this._volume;
1595
+ }
1596
+ this._volume = newVolume;
1597
+ if (this.chartPreview) {
1598
+ this.chartPreview.volume = this._volume / 100;
1599
+ }
1600
+ this.updateVolumeIcon();
1601
+ this.setAttribute("volume", String(this._volume));
1602
+ });
1603
+ this.seekBar.addEventListener("mousedown", () => this.onSeekStart());
1604
+ this.seekBar.addEventListener("touchstart", () => this.onSeekStart(), {
1605
+ passive: true
1606
+ });
1607
+ this.seekBar.addEventListener("input", () => this.onSeekInput());
1608
+ this.seekBar.addEventListener("mouseup", () => this.onSeekEnd());
1609
+ this.seekBar.addEventListener("touchend", () => this.onSeekEnd());
1610
+ this.seekBar.addEventListener("mouseleave", (e) => {
1611
+ if (e.buttons === 0 && this._isSeeking) {
1612
+ this.onSeekEnd();
1613
+ }
1614
+ });
1615
+ this.fullscreenBtn.addEventListener("click", () => {
1616
+ this.toggleFullscreen();
1617
+ });
1618
+ this.addEventListener("keydown", this.handleKeydown);
1619
+ }
1620
+ onSeekStart() {
1621
+ if (!this._isSeeking) {
1622
+ this._isSeeking = true;
1623
+ this._wasPlayingBeforeSeek = this._state === "playing";
1624
+ if (this._wasPlayingBeforeSeek && this.chartPreview) {
1625
+ this.chartPreview.togglePaused();
1626
+ this.setState("seeking");
1627
+ }
1628
+ }
1629
+ }
1630
+ async onSeekInput() {
1631
+ if (!this.chartPreview)
1632
+ return;
1633
+ this.setState("seeking");
1634
+ await this.chartPreview.seek(parseInt(this.seekBar.value, 10) / 1e3);
1635
+ this.updateTimestamp();
1636
+ }
1637
+ async onSeekEnd() {
1638
+ if (!this._isSeeking)
1639
+ return;
1640
+ this._isSeeking = false;
1641
+ if (!this.chartPreview) {
1642
+ this.setState("paused");
1643
+ return;
1644
+ }
1645
+ if (this._wasPlayingBeforeSeek) {
1646
+ this._wasPlayingBeforeSeek = false;
1647
+ await this.chartPreview.togglePaused();
1648
+ this.setState("playing");
1649
+ this.startTimestampUpdates();
1650
+ } else {
1651
+ this.setState("paused");
1652
+ }
1653
+ }
1654
+ /**
1655
+ * Prepares the viewport for loading by removing messages and setting loading state.
1656
+ */
1657
+ prepareForLoading() {
1658
+ this.setState("loading");
1659
+ const idleMessage = this.viewport.querySelector(".idle-message");
1660
+ if (idleMessage)
1661
+ idleMessage.remove();
1662
+ const errorMessage = this.viewport.querySelector(".error-message");
1663
+ if (errorMessage)
1664
+ errorMessage.remove();
1665
+ }
1666
+ /**
1667
+ * Handles load errors by showing error message and dispatching error event.
1668
+ */
1669
+ handleLoadError(error, context) {
1670
+ if (error instanceof DOMException && error.name === "AbortError") {
1671
+ return;
1672
+ }
1673
+ console.error(`Failed to load chart ${context}:`, error);
1674
+ this.setState("error");
1675
+ const errorEl = document.createElement("div");
1676
+ errorEl.className = "error-message";
1677
+ errorEl.textContent = `Failed to load: ${error instanceof Error ? error.message : "Unknown error"}`;
1678
+ this.viewport.appendChild(errorEl);
1679
+ this.dispatchEvent(
1680
+ new CustomEvent("player-error", {
1681
+ detail: { error },
1682
+ bubbles: true,
1683
+ composed: true
1684
+ })
1685
+ );
1686
+ }
1687
+ setState(state) {
1688
+ const oldState = this._state;
1689
+ this._state = state;
1690
+ this.updatePlayButton();
1691
+ if (oldState !== state) {
1692
+ this.dispatchEvent(
1693
+ new CustomEvent("player-statechange", {
1694
+ detail: { state, previousState: oldState },
1695
+ bubbles: true,
1696
+ composed: true
1697
+ })
1698
+ );
1699
+ }
1700
+ }
1701
+ updatePlayButton() {
1702
+ switch (this._state) {
1703
+ case "idle":
1704
+ this.playBtn.disabled = true;
1705
+ this.playBtn.innerHTML = ICONS.play;
1706
+ this.playBtn.setAttribute("aria-label", "Play");
1707
+ break;
1708
+ case "loading":
1709
+ this.playBtn.disabled = true;
1710
+ this.playBtn.innerHTML = ICONS.spinner;
1711
+ this.playBtn.setAttribute("aria-label", "Loading");
1712
+ break;
1713
+ case "ready":
1714
+ case "paused":
1715
+ case "seeking":
1716
+ this.playBtn.disabled = false;
1717
+ this.playBtn.innerHTML = ICONS.play;
1718
+ this.playBtn.setAttribute("aria-label", "Play");
1719
+ break;
1720
+ case "playing":
1721
+ this.playBtn.disabled = false;
1722
+ this.playBtn.innerHTML = ICONS.pause;
1723
+ this.playBtn.setAttribute("aria-label", "Pause");
1724
+ break;
1725
+ case "ended":
1726
+ this.playBtn.disabled = false;
1727
+ this.playBtn.innerHTML = ICONS.replay;
1728
+ this.playBtn.setAttribute("aria-label", "Replay");
1729
+ break;
1730
+ case "error":
1731
+ this.playBtn.disabled = true;
1732
+ this.playBtn.innerHTML = ICONS.play;
1733
+ this.playBtn.setAttribute("aria-label", "Error");
1734
+ break;
1735
+ }
1736
+ }
1737
+ updateVolumeIcon() {
1738
+ if (this._volume === 0) {
1739
+ this.volumeBtn.innerHTML = ICONS.volumeMute;
1740
+ } else if (this._volume <= 50) {
1741
+ this.volumeBtn.innerHTML = ICONS.volumeLow;
1742
+ } else {
1743
+ this.volumeBtn.innerHTML = ICONS.volumeHigh;
1744
+ }
1745
+ }
1746
+ updateTimestamp() {
1747
+ if (!this.chartPreview) {
1748
+ this.timestampEl.textContent = "0:00 / 0:00";
1749
+ return;
1750
+ }
1751
+ const current = this.formatTime(this.chartPreview.chartCurrentTimeMs);
1752
+ const total = this.formatTime(this.chartPreview.chartEndTimeMs);
1753
+ this.timestampEl.textContent = `${current} / ${total}`;
1754
+ }
1755
+ formatTime(ms) {
1756
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
1757
+ const minutes = Math.floor(totalSeconds / 60);
1758
+ const seconds = totalSeconds % 60;
1759
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
1760
+ }
1761
+ startTimestampUpdates() {
1762
+ this.stopTimestampUpdates();
1763
+ this.timestampInterval = setInterval(() => this.updateTimestamp(), 100);
1764
+ }
1765
+ stopTimestampUpdates() {
1766
+ if (this.timestampInterval) {
1767
+ clearInterval(this.timestampInterval);
1768
+ this.timestampInterval = null;
1769
+ }
1770
+ }
1771
+ // Public API
1772
+ /**
1773
+ * Current player state
1774
+ */
1775
+ get state() {
1776
+ return this._state;
1777
+ }
1778
+ /**
1779
+ * Whether the player is currently playing
1780
+ */
1781
+ get isPlaying() {
1782
+ return this._state === "playing";
1783
+ }
1784
+ /**
1785
+ * Current volume (0-100)
1786
+ */
1787
+ get volume() {
1788
+ return this._volume;
1789
+ }
1790
+ /**
1791
+ * Set volume (0-100)
1792
+ */
1793
+ setVolume(volume) {
1794
+ this._volume = Math.max(0, Math.min(100, volume));
1795
+ this.volumeSlider.value = String(this._volume);
1796
+ if (this.chartPreview) {
1797
+ this.chartPreview.volume = this._volume / 100;
1798
+ }
1799
+ this.updateVolumeIcon();
1800
+ this.setAttribute("volume", String(this._volume));
1801
+ }
1802
+ /**
1803
+ * Current playback position in milliseconds
1804
+ */
1805
+ get currentTimeMs() {
1806
+ return this.chartPreview?.chartCurrentTimeMs ?? 0;
1807
+ }
1808
+ /**
1809
+ * Total duration in milliseconds
1810
+ */
1811
+ get durationMs() {
1812
+ return this.chartPreview?.chartEndTimeMs ?? 0;
1813
+ }
1814
+ /**
1815
+ * Whether the player is in fullscreen mode
1816
+ */
1817
+ get isFullscreen() {
1818
+ return this._isFullscreen;
1819
+ }
1820
+ /**
1821
+ * Load a chart into the player from pre-processed data.
1822
+ * Use this when you've already parsed the chart and loaded textures.
1823
+ *
1824
+ * @example
1825
+ * ```ts
1826
+ * const textures = await ChartPreview.loadTextures(instrumentType);
1827
+ * await player.loadChart({
1828
+ * parsedChart,
1829
+ * textures,
1830
+ * audioFiles,
1831
+ * instrument: 'guitar',
1832
+ * difficulty: 'expert',
1833
+ * startDelayMs: 0,
1834
+ * audioLengthMs: 180000,
1835
+ * });
1836
+ * ```
1837
+ */
1838
+ async loadChart(config) {
1839
+ try {
1840
+ if (this.chartPreview) {
1841
+ this.chartPreview.dispose();
1842
+ this.chartPreview = null;
1843
+ }
1844
+ this.stopTimestampUpdates();
1845
+ this.prepareForLoading();
1846
+ let previewContainer = this.viewport.querySelector(
1847
+ ".preview-render"
1848
+ );
1849
+ if (!previewContainer) {
1850
+ previewContainer = document.createElement("div");
1851
+ previewContainer.className = "preview-render";
1852
+ previewContainer.style.cssText = "width: 100%; height: 100%;";
1853
+ this.viewport.appendChild(previewContainer);
1854
+ }
1855
+ this.chartPreview = await ChartPreview.create({
1856
+ parsedChart: config.parsedChart,
1857
+ textures: config.textures,
1858
+ audioFiles: config.audioFiles,
1859
+ instrument: config.instrument,
1860
+ difficulty: config.difficulty,
1861
+ startDelayMs: config.startDelayMs,
1862
+ audioLengthMs: config.audioLengthMs,
1863
+ container: previewContainer
1864
+ });
1865
+ this.chartPreview.volume = this._volume / 100;
1866
+ this.chartPreview.on("progress", (percent) => {
1867
+ this.seekBar.value = String(Math.round(percent * 1e3));
1868
+ this.dispatchEvent(
1869
+ new CustomEvent("player-progress", {
1870
+ detail: {
1871
+ percent,
1872
+ currentMs: this.chartPreview.chartCurrentTimeMs,
1873
+ totalMs: this.chartPreview.chartEndTimeMs
1874
+ },
1875
+ bubbles: true,
1876
+ composed: true
1877
+ })
1878
+ );
1879
+ });
1880
+ this.chartPreview.on("end", async () => {
1881
+ await this.chartPreview.togglePaused();
1882
+ this.setState("ended");
1883
+ this.stopTimestampUpdates();
1884
+ this.dispatchEvent(
1885
+ new CustomEvent("player-end", {
1886
+ bubbles: true,
1887
+ composed: true
1888
+ })
1889
+ );
1890
+ });
1891
+ if (config.initialSeekPercent !== void 0 && config.initialSeekPercent > 0) {
1892
+ await this.chartPreview.seek(config.initialSeekPercent);
1893
+ this.seekBar.value = String(
1894
+ Math.round(config.initialSeekPercent * 1e3)
1895
+ );
1896
+ }
1897
+ this.seekBar.disabled = false;
1898
+ this.startTimestampUpdates();
1899
+ this.setState("ready");
1900
+ } catch (error) {
1901
+ this.handleLoadError(error, "");
1902
+ }
1903
+ }
1904
+ /**
1905
+ * Load a chart from a URL pointing to a .sng file.
1906
+ * This is the simplest way to load a chart - just provide a URL and instrument/difficulty.
1907
+ *
1908
+ * @example
1909
+ * ```ts
1910
+ * await player.loadFromUrl({
1911
+ * url: 'https://files.enchor.us/abc123.sng',
1912
+ * instrument: 'guitar',
1913
+ * difficulty: 'expert',
1914
+ * });
1915
+ * ```
1916
+ */
1917
+ async loadFromUrl(config) {
1918
+ try {
1919
+ if (this._currentAbortController) {
1920
+ this._currentAbortController.abort();
1921
+ }
1922
+ this._currentAbortController = new AbortController();
1923
+ const signal = config.signal ?? this._currentAbortController.signal;
1924
+ this.prepareForLoading();
1925
+ const sngData = await fetchSngFile(config.url, signal);
1926
+ const files = await extractSngFile(sngData);
1927
+ const preparedData = await prepareChartData(
1928
+ files,
1929
+ config.instrument,
1930
+ config.difficulty,
1931
+ config.initialSeekPercent
1932
+ );
1933
+ await this.loadChart({
1934
+ parsedChart: preparedData.parsedChart,
1935
+ textures: preparedData.textures,
1936
+ audioFiles: preparedData.audioFiles,
1937
+ instrument: preparedData.instrument,
1938
+ difficulty: preparedData.difficulty,
1939
+ startDelayMs: preparedData.startDelayMs,
1940
+ audioLengthMs: preparedData.audioLengthMs,
1941
+ initialSeekPercent: preparedData.initialSeekPercent
1942
+ });
1943
+ this._currentAbortController = null;
1944
+ } catch (error) {
1945
+ this._currentAbortController = null;
1946
+ this.handleLoadError(error, "from URL");
1947
+ }
1948
+ }
1949
+ /**
1950
+ * Load a chart from raw .sng file data.
1951
+ * Use this when you've already fetched the .sng file yourself.
1952
+ *
1953
+ * @example
1954
+ * ```ts
1955
+ * const response = await fetch('https://files.enchor.us/abc123.sng');
1956
+ * const sngData = new Uint8Array(await response.arrayBuffer());
1957
+ *
1958
+ * await player.loadFromSngFile({
1959
+ * sngFile: sngData,
1960
+ * instrument: 'guitar',
1961
+ * difficulty: 'expert',
1962
+ * });
1963
+ * ```
1964
+ */
1965
+ async loadFromSngFile(config) {
1966
+ try {
1967
+ this.prepareForLoading();
1968
+ const files = await extractSngFile(config.sngFile);
1969
+ const preparedData = await prepareChartData(
1970
+ files,
1971
+ config.instrument,
1972
+ config.difficulty,
1973
+ config.initialSeekPercent
1974
+ );
1975
+ await this.loadChart({
1976
+ parsedChart: preparedData.parsedChart,
1977
+ textures: preparedData.textures,
1978
+ audioFiles: preparedData.audioFiles,
1979
+ instrument: preparedData.instrument,
1980
+ difficulty: preparedData.difficulty,
1981
+ startDelayMs: preparedData.startDelayMs,
1982
+ audioLengthMs: preparedData.audioLengthMs,
1983
+ initialSeekPercent: preparedData.initialSeekPercent
1984
+ });
1985
+ } catch (error) {
1986
+ this.handleLoadError(error, "from .sng file");
1987
+ }
1988
+ }
1989
+ /**
1990
+ * Load a chart from individual files (chart file + audio files).
1991
+ * Use this when loading from a folder or when you have the files separately.
1992
+ *
1993
+ * @example
1994
+ * ```ts
1995
+ * // From file picker
1996
+ * const files = await getFilesFromFolder();
1997
+ *
1998
+ * await player.loadFromChartFiles({
1999
+ * files: files.map(f => ({ fileName: f.name, data: new Uint8Array(f.data) })),
2000
+ * instrument: 'guitar',
2001
+ * difficulty: 'expert',
2002
+ * });
2003
+ * ```
2004
+ */
2005
+ async loadFromChartFiles(config) {
2006
+ try {
2007
+ this.prepareForLoading();
2008
+ const preparedData = await prepareChartData(
2009
+ config.files,
2010
+ config.instrument,
2011
+ config.difficulty,
2012
+ config.initialSeekPercent
2013
+ );
2014
+ await this.loadChart({
2015
+ parsedChart: preparedData.parsedChart,
2016
+ textures: preparedData.textures,
2017
+ audioFiles: preparedData.audioFiles,
2018
+ instrument: preparedData.instrument,
2019
+ difficulty: preparedData.difficulty,
2020
+ startDelayMs: preparedData.startDelayMs,
2021
+ audioLengthMs: preparedData.audioLengthMs,
2022
+ initialSeekPercent: preparedData.initialSeekPercent
2023
+ });
2024
+ } catch (error) {
2025
+ this.handleLoadError(error, "from files");
2026
+ }
2027
+ }
2028
+ /**
2029
+ * Toggle between playing and paused states
2030
+ */
2031
+ async togglePlayPause() {
2032
+ if (!this.chartPreview)
2033
+ return;
2034
+ if (this._state === "ended") {
2035
+ await this.chartPreview.seek(0);
2036
+ this.seekBar.value = "0";
2037
+ this.setState("paused");
2038
+ }
2039
+ if (this._state === "paused" || this._state === "ready") {
2040
+ this.setState("loading");
2041
+ await this.chartPreview.togglePaused();
2042
+ this.setState("playing");
2043
+ this.startTimestampUpdates();
2044
+ } else if (this._state === "playing") {
2045
+ this.setState("loading");
2046
+ await this.chartPreview.togglePaused();
2047
+ this.setState("paused");
2048
+ }
2049
+ }
2050
+ /**
2051
+ * Play the chart
2052
+ */
2053
+ async play() {
2054
+ if (this._state === "paused" || this._state === "ready" || this._state === "ended") {
2055
+ await this.togglePlayPause();
2056
+ }
2057
+ }
2058
+ /**
2059
+ * Pause the chart
2060
+ */
2061
+ async pause() {
2062
+ if (this._state === "playing") {
2063
+ await this.togglePlayPause();
2064
+ }
2065
+ }
2066
+ /**
2067
+ * Seek to a specific position
2068
+ * @param percent Position as a percentage (0-1)
2069
+ */
2070
+ async seek(percent) {
2071
+ if (!this.chartPreview)
2072
+ return;
2073
+ const wasPlaying = this._state === "playing";
2074
+ if (wasPlaying) {
2075
+ await this.chartPreview.togglePaused();
2076
+ }
2077
+ await this.chartPreview.seek(Math.max(0, Math.min(1, percent)));
2078
+ this.seekBar.value = String(Math.round(percent * 1e3));
2079
+ this.updateTimestamp();
2080
+ this.setState("paused");
2081
+ }
2082
+ /**
2083
+ * Seek relative to current position
2084
+ * @param deltaMs Milliseconds to seek (positive = forward, negative = backward)
2085
+ */
2086
+ async seekRelative(deltaMs) {
2087
+ if (!this.chartPreview)
2088
+ return;
2089
+ const currentMs = this.chartPreview.chartCurrentTimeMs;
2090
+ const totalMs = this.chartPreview.chartEndTimeMs;
2091
+ const newPercent = Math.max(
2092
+ 0,
2093
+ Math.min(1, (currentMs + deltaMs) / totalMs)
2094
+ );
2095
+ await this.seek(newPercent);
2096
+ }
2097
+ /**
2098
+ * Toggle mute
2099
+ */
2100
+ toggleMute() {
2101
+ if (this._volume > 0) {
2102
+ this._lastVolumeBeforeMute = this._volume;
2103
+ this.setVolume(0);
2104
+ } else {
2105
+ this.setVolume(this._lastVolumeBeforeMute || 50);
2106
+ }
2107
+ }
2108
+ /**
2109
+ * Toggle fullscreen mode
2110
+ */
2111
+ async toggleFullscreen() {
2112
+ if (!document.fullscreenEnabled)
2113
+ return;
2114
+ if (this._isFullscreen) {
2115
+ await document.exitFullscreen();
2116
+ } else {
2117
+ await this.container.requestFullscreen();
2118
+ }
2119
+ }
2120
+ /**
2121
+ * Dispose of the player and clean up resources
2122
+ */
2123
+ dispose() {
2124
+ if (this._currentAbortController) {
2125
+ this._currentAbortController.abort();
2126
+ this._currentAbortController = null;
2127
+ }
2128
+ this.stopTimestampUpdates();
2129
+ if (this.chartPreview) {
2130
+ this.chartPreview.dispose();
2131
+ this.chartPreview = null;
2132
+ }
2133
+ this._isSeeking = false;
2134
+ this._wasPlayingBeforeSeek = false;
2135
+ this.setState("idle");
2136
+ this.seekBar.disabled = true;
2137
+ this.seekBar.value = "0";
2138
+ }
2139
+ };
2140
+ if (typeof window !== "undefined" && !customElements.get("chart-preview-player")) {
2141
+ customElements.define("chart-preview-player", ChartPreviewPlayer);
2142
+ }
2143
+
2144
+ // src/index.ts
2145
+ var import_scan_chart3 = require("scan-chart");
2146
+ // Annotate the CommonJS export names for ESM import in node:
2147
+ 0 && (module.exports = {
2148
+ ChartPreview,
2149
+ ChartPreviewPlayer,
2150
+ extractSngFile,
2151
+ fetchSngFile,
2152
+ findAudioFiles,
2153
+ findChartFile,
2154
+ getInstrumentType,
2155
+ instrumentTypes,
2156
+ isVideoFile,
2157
+ prepareChartData
2158
+ });