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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +38 -0
- package/.vscode/extensions.json +6 -0
- package/.vscode/settings.json +8 -0
- package/README.md +541 -0
- package/dev/index.html +299 -0
- package/dev/main.ts +525 -0
- package/dev/tsconfig.json +16 -0
- package/dist/index.d.ts +496 -0
- package/dist/index.js +2158 -0
- package/dist/index.mjs +2125 -0
- package/package.json +57 -0
- package/src/ChartPreview.ts +1346 -0
- package/src/ChartPreviewPlayer.ts +1163 -0
- package/src/SharedAudioContext.ts +83 -0
- package/src/SngLoader.ts +330 -0
- package/src/index.ts +37 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +18 -0
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
|
+
});
|