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/dev/main.ts ADDED
@@ -0,0 +1,525 @@
1
+ import {
2
+ ChartPreview,
3
+ ChartPreviewPlayer,
4
+ extractSngFile,
5
+ getInstrumentType,
6
+ type Difficulty,
7
+ type Instrument,
8
+ } from "../src";
9
+ import { parseChartFile, scanChartFolder, type ScannedChart } from "scan-chart";
10
+
11
+ // LocalStorage keys
12
+ const STORAGE_KEY_LAST_CHART = "chartPreview_lastChart";
13
+ const STORAGE_KEY_LAST_INSTRUMENT = "chartPreview_lastInstrument";
14
+ const STORAGE_KEY_LAST_DIFFICULTY = "chartPreview_lastDifficulty";
15
+
16
+ // Instrument display names
17
+ const instrumentNames: Record<Instrument, string> = {
18
+ guitar: "Lead Guitar",
19
+ guitarcoop: "Co-op Guitar",
20
+ rhythm: "Rhythm Guitar",
21
+ bass: "Bass Guitar",
22
+ drums: "Drums",
23
+ keys: "Keys",
24
+ guitarghl: "GHL Guitar",
25
+ guitarcoopghl: "GHL Co-op Guitar",
26
+ rhythmghl: "GHL Rhythm Guitar",
27
+ bassghl: "GHL Bass",
28
+ };
29
+
30
+ const difficultyNames: Record<Difficulty, string> = {
31
+ expert: "Expert",
32
+ hard: "Hard",
33
+ medium: "Medium",
34
+ easy: "Easy",
35
+ };
36
+
37
+ // DOM elements
38
+ const chooseFolderBtn = document.getElementById(
39
+ "choose-folder-btn"
40
+ ) as HTMLButtonElement;
41
+ const chooseSngBtn = document.getElementById(
42
+ "choose-sng-btn"
43
+ ) as HTMLButtonElement;
44
+ const folderInput = document.getElementById("folder-input") as HTMLInputElement;
45
+ const sngInput = document.getElementById("sng-input") as HTMLInputElement;
46
+ const lastChartHint = document.getElementById(
47
+ "last-chart-hint"
48
+ ) as HTMLDivElement;
49
+ const lastChartName = document.getElementById(
50
+ "last-chart-name"
51
+ ) as HTMLSpanElement;
52
+ const chartInfoEl = document.getElementById("chart-info") as HTMLDivElement;
53
+ const songNameEl = document.getElementById("song-name") as HTMLParagraphElement;
54
+ const songArtistEl = document.getElementById(
55
+ "song-artist"
56
+ ) as HTMLParagraphElement;
57
+ const songCharterEl = document.getElementById(
58
+ "song-charter"
59
+ ) as HTMLParagraphElement;
60
+ const songLengthEl = document.getElementById(
61
+ "song-length"
62
+ ) as HTMLParagraphElement;
63
+ const trackOptionsEl = document.getElementById(
64
+ "track-options"
65
+ ) as HTMLDivElement;
66
+ const instrumentSelect = document.getElementById(
67
+ "instrument"
68
+ ) as HTMLSelectElement;
69
+ const difficultySelect = document.getElementById(
70
+ "difficulty"
71
+ ) as HTMLSelectElement;
72
+ const loadBtn = document.getElementById("load-btn") as HTMLButtonElement;
73
+ const statusEl = document.getElementById("status") as HTMLDivElement;
74
+ const playerEventsEl = document.getElementById(
75
+ "player-events"
76
+ ) as HTMLDivElement;
77
+
78
+ // Get the Web Component player
79
+ const player = document.getElementById("player") as ChartPreviewPlayer;
80
+
81
+ // State
82
+ let currentChartFiles: { fileName: string; data: Uint8Array }[] = [];
83
+ let currentScannedChart: ScannedChart | null = null;
84
+ let currentChartName = "";
85
+
86
+ // Available tracks from the scanned chart
87
+ interface AvailableTrack {
88
+ instrument: Instrument;
89
+ difficulties: Difficulty[];
90
+ }
91
+ let availableTracks: AvailableTrack[] = [];
92
+
93
+ // Log player events
94
+ function logEvent(type: string, detail?: unknown) {
95
+ const eventEl = document.createElement("div");
96
+ eventEl.className = `event ${type}`;
97
+ const time = new Date().toLocaleTimeString();
98
+ eventEl.textContent = `[${time}] ${type}${
99
+ detail ? `: ${JSON.stringify(detail)}` : ""
100
+ }`;
101
+ playerEventsEl.insertBefore(eventEl, playerEventsEl.firstChild);
102
+ // Keep only last 20 events
103
+ while (playerEventsEl.children.length > 20) {
104
+ playerEventsEl.removeChild(playerEventsEl.lastChild!);
105
+ }
106
+ }
107
+
108
+ // Setup player event listeners
109
+ player.addEventListener("player-statechange", (e: Event) => {
110
+ const detail = (e as CustomEvent).detail;
111
+ logEvent("state", detail);
112
+ });
113
+
114
+ player.addEventListener("player-progress", (e: Event) => {
115
+ // Only log every ~10%
116
+ const detail = (e as CustomEvent).detail;
117
+ if (
118
+ Math.round(detail.percent * 10) % 1 === 0 &&
119
+ Math.round(detail.percent * 100) % 10 === 0
120
+ ) {
121
+ logEvent("progress", { percent: Math.round(detail.percent * 100) + "%" });
122
+ }
123
+ });
124
+
125
+ player.addEventListener("player-end", () => {
126
+ logEvent("end");
127
+ });
128
+
129
+ player.addEventListener("player-error", (e: Event) => {
130
+ const detail = (e as CustomEvent).detail;
131
+ logEvent("error", detail.error?.message || "Unknown error");
132
+ });
133
+
134
+ // Initialize: Show last chart hint if available
135
+ function initializeLastChartHint() {
136
+ const lastChart = localStorage.getItem(STORAGE_KEY_LAST_CHART);
137
+ if (lastChart) {
138
+ lastChartName.textContent = lastChart;
139
+ lastChartHint.classList.add("visible");
140
+ }
141
+ }
142
+ initializeLastChartHint();
143
+
144
+ // Button click handlers
145
+ chooseFolderBtn.addEventListener("click", () => folderInput.click());
146
+ chooseSngBtn.addEventListener("click", () => sngInput.click());
147
+
148
+ // Folder selection
149
+ folderInput.addEventListener("change", async () => {
150
+ const files = folderInput.files;
151
+ if (!files || files.length === 0) return;
152
+
153
+ setStatus("Reading chart folder...");
154
+
155
+ try {
156
+ // Convert FileList to our format
157
+ const chartFiles: { fileName: string; data: Uint8Array }[] = [];
158
+
159
+ // Get the folder name from the first file's path
160
+ const firstFilePath = files[0].webkitRelativePath;
161
+ const folderName = firstFilePath.split("/")[0];
162
+
163
+ for (const file of files) {
164
+ // Get relative path within the folder
165
+ const relativePath = file.webkitRelativePath
166
+ .split("/")
167
+ .slice(1)
168
+ .join("/");
169
+ if (!relativePath) continue; // Skip if it's the folder itself
170
+
171
+ const data = new Uint8Array(await file.arrayBuffer());
172
+ chartFiles.push({ fileName: relativePath, data });
173
+ }
174
+
175
+ await processChartFiles(chartFiles, folderName);
176
+ } catch (error) {
177
+ console.error("Error reading folder:", error);
178
+ setStatus(
179
+ `Error: ${
180
+ error instanceof Error ? error.message : "Failed to read folder"
181
+ }`,
182
+ true
183
+ );
184
+ }
185
+ });
186
+
187
+ // .sng file selection
188
+ sngInput.addEventListener("change", async () => {
189
+ const file = sngInput.files?.[0];
190
+ if (!file) return;
191
+
192
+ setStatus("Reading .sng file...");
193
+
194
+ try {
195
+ const data = new Uint8Array(await file.arrayBuffer());
196
+ const chartFiles = await extractSngFile(data);
197
+ const chartName = file.name.replace(/\.sng$/i, "");
198
+ await processChartFiles(chartFiles, chartName);
199
+ } catch (error) {
200
+ console.error("Error reading .sng file:", error);
201
+ setStatus(
202
+ `Error: ${
203
+ error instanceof Error ? error.message : "Failed to read .sng file"
204
+ }`,
205
+ true
206
+ );
207
+ }
208
+ });
209
+
210
+ // Process chart files (from folder or .sng)
211
+ async function processChartFiles(
212
+ files: { fileName: string; data: Uint8Array }[],
213
+ chartName: string
214
+ ) {
215
+ try {
216
+ setStatus("Scanning chart...");
217
+
218
+ // Store for later use
219
+ currentChartFiles = files;
220
+ currentChartName = chartName;
221
+
222
+ // Scan the chart folder
223
+ currentScannedChart = scanChartFolder(files);
224
+
225
+ if (!currentScannedChart.playable || !currentScannedChart.notesData) {
226
+ setStatus("This chart has no playable tracks.", true);
227
+ return;
228
+ }
229
+
230
+ // Extract available tracks
231
+ availableTracks = [];
232
+ const instruments = currentScannedChart.notesData.instruments;
233
+ const noteCounts = currentScannedChart.notesData.noteCounts;
234
+
235
+ for (const instrument of instruments) {
236
+ const difficulties = noteCounts
237
+ .filter((nc) => nc.instrument === instrument && nc.count > 0)
238
+ .map((nc) => nc.difficulty);
239
+
240
+ if (difficulties.length > 0) {
241
+ availableTracks.push({ instrument, difficulties });
242
+ }
243
+ }
244
+
245
+ if (availableTracks.length === 0) {
246
+ setStatus("No tracks with notes found in this chart.", true);
247
+ return;
248
+ }
249
+
250
+ // Update UI
251
+ updateChartInfo(currentScannedChart);
252
+ populateInstrumentSelect();
253
+
254
+ // Save to localStorage
255
+ localStorage.setItem(STORAGE_KEY_LAST_CHART, chartName);
256
+ lastChartName.textContent = chartName;
257
+ lastChartHint.classList.add("visible");
258
+
259
+ // Try to restore last used instrument/difficulty
260
+ const lastInstrument = localStorage.getItem(STORAGE_KEY_LAST_INSTRUMENT);
261
+ const lastDifficulty = localStorage.getItem(STORAGE_KEY_LAST_DIFFICULTY);
262
+
263
+ if (
264
+ lastInstrument &&
265
+ availableTracks.some((t) => t.instrument === lastInstrument)
266
+ ) {
267
+ instrumentSelect.value = lastInstrument;
268
+ populateDifficultySelect();
269
+
270
+ const track = availableTracks.find(
271
+ (t) => t.instrument === lastInstrument
272
+ );
273
+ if (
274
+ lastDifficulty &&
275
+ track?.difficulties.includes(lastDifficulty as Difficulty)
276
+ ) {
277
+ difficultySelect.value = lastDifficulty;
278
+ }
279
+ }
280
+
281
+ updateLoadButtonState();
282
+ setStatus(
283
+ `Loaded: ${chartName} - ${availableTracks.length} instrument(s) available`,
284
+ false,
285
+ true
286
+ );
287
+ } catch (error) {
288
+ console.error("Error processing chart:", error);
289
+ setStatus(
290
+ `Error: ${
291
+ error instanceof Error ? error.message : "Failed to process chart"
292
+ }`,
293
+ true
294
+ );
295
+ }
296
+ }
297
+
298
+ // Update chart info display
299
+ function updateChartInfo(chart: ScannedChart) {
300
+ songNameEl.textContent = chart.name || currentChartName || "Unknown Song";
301
+ songArtistEl.textContent = chart.artist || "Unknown Artist";
302
+ songCharterEl.textContent = chart.charter ? `Charter: ${chart.charter}` : "";
303
+
304
+ if (chart.song_length) {
305
+ const minutes = Math.floor(chart.song_length / 60000);
306
+ const seconds = Math.floor((chart.song_length % 60000) / 1000);
307
+ songLengthEl.textContent = `Length: ${minutes}:${seconds
308
+ .toString()
309
+ .padStart(2, "0")}`;
310
+ } else {
311
+ songLengthEl.textContent = "";
312
+ }
313
+
314
+ chartInfoEl.classList.add("visible");
315
+ trackOptionsEl.classList.add("visible");
316
+ }
317
+
318
+ // Populate instrument dropdown
319
+ function populateInstrumentSelect() {
320
+ instrumentSelect.innerHTML = "";
321
+
322
+ for (const track of availableTracks) {
323
+ const option = document.createElement("option");
324
+ option.value = track.instrument;
325
+ option.textContent = instrumentNames[track.instrument] || track.instrument;
326
+ instrumentSelect.appendChild(option);
327
+ }
328
+
329
+ instrumentSelect.disabled = false;
330
+ populateDifficultySelect();
331
+ }
332
+
333
+ // Populate difficulty dropdown based on selected instrument
334
+ function populateDifficultySelect() {
335
+ const selectedInstrument = instrumentSelect.value as Instrument;
336
+ const track = availableTracks.find(
337
+ (t) => t.instrument === selectedInstrument
338
+ );
339
+
340
+ difficultySelect.innerHTML = "";
341
+
342
+ if (track) {
343
+ // Sort difficulties: expert, hard, medium, easy
344
+ const sortOrder: Difficulty[] = ["expert", "hard", "medium", "easy"];
345
+ const sortedDifficulties = track.difficulties.sort(
346
+ (a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)
347
+ );
348
+
349
+ for (const difficulty of sortedDifficulties) {
350
+ const option = document.createElement("option");
351
+ option.value = difficulty;
352
+ option.textContent = difficultyNames[difficulty] || difficulty;
353
+ difficultySelect.appendChild(option);
354
+ }
355
+ difficultySelect.disabled = false;
356
+ } else {
357
+ difficultySelect.disabled = true;
358
+ }
359
+
360
+ updateLoadButtonState();
361
+ }
362
+
363
+ // Update load button state
364
+ function updateLoadButtonState() {
365
+ loadBtn.disabled =
366
+ !instrumentSelect.value ||
367
+ !difficultySelect.value ||
368
+ currentChartFiles.length === 0;
369
+ }
370
+
371
+ // Instrument selection change
372
+ instrumentSelect.addEventListener("change", () => {
373
+ populateDifficultySelect();
374
+ localStorage.setItem(STORAGE_KEY_LAST_INSTRUMENT, instrumentSelect.value);
375
+ });
376
+
377
+ // Difficulty selection change
378
+ difficultySelect.addEventListener("change", () => {
379
+ localStorage.setItem(STORAGE_KEY_LAST_DIFFICULTY, difficultySelect.value);
380
+ updateLoadButtonState();
381
+ });
382
+
383
+ // Load preview
384
+ loadBtn.addEventListener("click", async () => {
385
+ if (currentChartFiles.length === 0 || !currentScannedChart) {
386
+ setStatus("Please select a chart first.", true);
387
+ return;
388
+ }
389
+
390
+ const instrument = instrumentSelect.value as Instrument;
391
+ const difficulty = difficultySelect.value as Difficulty;
392
+
393
+ if (!instrument || !difficulty) {
394
+ setStatus("Please select an instrument and difficulty.", true);
395
+ return;
396
+ }
397
+
398
+ try {
399
+ setStatus("Loading chart preview...");
400
+ loadBtn.disabled = true;
401
+
402
+ // Dispose previous player state
403
+ player.dispose();
404
+
405
+ // Find chart file
406
+ const chartFile = currentChartFiles.find(
407
+ (f) =>
408
+ f.fileName.toLowerCase().endsWith(".chart") ||
409
+ f.fileName.toLowerCase().endsWith(".mid")
410
+ );
411
+ if (!chartFile) {
412
+ setStatus("No .chart or .mid file found in the chart folder.", true);
413
+ loadBtn.disabled = false;
414
+ return;
415
+ }
416
+
417
+ const format = chartFile.fileName.toLowerCase().endsWith(".mid")
418
+ ? "mid"
419
+ : "chart";
420
+
421
+ // Build chart modifiers from scanned chart
422
+ const modifiers = {
423
+ song_length: currentScannedChart.song_length || 0,
424
+ hopo_frequency: currentScannedChart.hopo_frequency || 0,
425
+ eighthnote_hopo: currentScannedChart.eighthnote_hopo || false,
426
+ multiplier_note: currentScannedChart.multiplier_note || 0,
427
+ sustain_cutoff_threshold:
428
+ currentScannedChart.sustain_cutoff_threshold ?? -1,
429
+ chord_snap_threshold: currentScannedChart.chord_snap_threshold || 0,
430
+ five_lane_drums: currentScannedChart.five_lane_drums || false,
431
+ pro_drums: currentScannedChart.pro_drums || false,
432
+ };
433
+
434
+ // Parse chart
435
+ const parsedChart = parseChartFile(chartFile.data, format, modifiers);
436
+
437
+ // Check if the track exists
438
+ const track = parsedChart.trackData.find(
439
+ (t) => t.instrument === instrument && t.difficulty === difficulty
440
+ );
441
+ if (!track) {
442
+ setStatus(
443
+ `No ${difficulty} ${instrument} track found in this chart.`,
444
+ true
445
+ );
446
+ loadBtn.disabled = false;
447
+ return;
448
+ }
449
+
450
+ // Find audio files
451
+ const audioExtensions = [".ogg", ".mp3", ".wav", ".opus"];
452
+ const audioFiles: Uint8Array[] = [];
453
+ for (const file of currentChartFiles) {
454
+ const ext = file.fileName
455
+ .toLowerCase()
456
+ .slice(file.fileName.lastIndexOf("."));
457
+ if (audioExtensions.includes(ext)) {
458
+ audioFiles.push(file.data);
459
+ }
460
+ }
461
+
462
+ // Load textures
463
+ setStatus("Loading textures...");
464
+ const instrumentType = getInstrumentType(instrument);
465
+ const textures = await ChartPreview.loadTextures(instrumentType);
466
+
467
+ // Calculate audio length
468
+ const lastNote = parsedChart.trackData
469
+ .flatMap((t) => t.noteEventGroups.flat())
470
+ .reduce((max, note) => Math.max(max, note.msTime + note.msLength), 0);
471
+ const audioLengthMs = currentScannedChart.song_length || lastNote + 5000;
472
+
473
+ // Get start delay from chart metadata
474
+ const startDelayMs =
475
+ (currentScannedChart.delay || 0) +
476
+ (currentScannedChart.chart_offset || 0) * 1000;
477
+
478
+ // Calculate initial seek position from preview_start_time if available
479
+ const initialSeekPercent = currentScannedChart.preview_start_time
480
+ ? currentScannedChart.preview_start_time / audioLengthMs
481
+ : 0;
482
+
483
+ // Load the chart into the Web Component player
484
+ setStatus("Creating preview...");
485
+ await player.loadChart({
486
+ parsedChart,
487
+ textures,
488
+ audioFiles,
489
+ instrument,
490
+ difficulty,
491
+ startDelayMs,
492
+ audioLengthMs,
493
+ initialSeekPercent,
494
+ });
495
+
496
+ // Save selections
497
+ localStorage.setItem(STORAGE_KEY_LAST_INSTRUMENT, instrument);
498
+ localStorage.setItem(STORAGE_KEY_LAST_DIFFICULTY, difficulty);
499
+
500
+ loadBtn.disabled = false;
501
+
502
+ const displayName = currentScannedChart.name || currentChartName;
503
+ setStatus(
504
+ `Loaded: ${displayName} - ${instrumentNames[instrument]} (${difficultyNames[difficulty]})`,
505
+ false,
506
+ true
507
+ );
508
+ } catch (error) {
509
+ console.error("Error loading preview:", error);
510
+ setStatus(
511
+ `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
512
+ true
513
+ );
514
+ loadBtn.disabled = false;
515
+ }
516
+ });
517
+
518
+ function setStatus(message: string, isError = false, isSuccess = false) {
519
+ statusEl.textContent = message;
520
+ statusEl.className = isError
521
+ ? "status error"
522
+ : isSuccess
523
+ ? "status success"
524
+ : "status";
525
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
11
+ "paths": {
12
+ "chart-preview": ["../src/index.ts"]
13
+ }
14
+ },
15
+ "include": ["./**/*"]
16
+ }