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
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChartPreview,
|
|
3
|
+
type ChartPreviewConfig,
|
|
4
|
+
type ParsedChart,
|
|
5
|
+
} from "./ChartPreview";
|
|
6
|
+
import {
|
|
7
|
+
extractSngFile,
|
|
8
|
+
fetchSngFile,
|
|
9
|
+
prepareChartData,
|
|
10
|
+
type LoadFromUrlConfig,
|
|
11
|
+
type LoadFromSngFileConfig,
|
|
12
|
+
type LoadFromChartFilesConfig,
|
|
13
|
+
} from "./SngLoader";
|
|
14
|
+
|
|
15
|
+
export type PlayerState =
|
|
16
|
+
| "idle"
|
|
17
|
+
| "loading"
|
|
18
|
+
| "ready"
|
|
19
|
+
| "playing"
|
|
20
|
+
| "paused"
|
|
21
|
+
| "seeking"
|
|
22
|
+
| "ended"
|
|
23
|
+
| "error";
|
|
24
|
+
|
|
25
|
+
export interface ChartPreviewPlayerConfig {
|
|
26
|
+
parsedChart: ChartPreviewConfig["parsedChart"];
|
|
27
|
+
textures: ChartPreviewConfig["textures"];
|
|
28
|
+
audioFiles: ChartPreviewConfig["audioFiles"];
|
|
29
|
+
instrument: ChartPreviewConfig["instrument"];
|
|
30
|
+
difficulty: ChartPreviewConfig["difficulty"];
|
|
31
|
+
startDelayMs: ChartPreviewConfig["startDelayMs"];
|
|
32
|
+
audioLengthMs: ChartPreviewConfig["audioLengthMs"];
|
|
33
|
+
/** Initial seek position as a percentage (0-1). Defaults to 0. */
|
|
34
|
+
initialSeekPercent?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// SVG Icons
|
|
38
|
+
const ICONS = {
|
|
39
|
+
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>`,
|
|
40
|
+
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>`,
|
|
41
|
+
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>`,
|
|
42
|
+
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>`,
|
|
43
|
+
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>`,
|
|
44
|
+
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>`,
|
|
45
|
+
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>`,
|
|
46
|
+
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>`,
|
|
47
|
+
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>`,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const STYLES = `
|
|
51
|
+
:host {
|
|
52
|
+
/* Themeable CSS custom properties */
|
|
53
|
+
--player-bg: #000;
|
|
54
|
+
--controls-bg: #2a2a3a;
|
|
55
|
+
--controls-hover-bg: #3a3a4a;
|
|
56
|
+
--slider-track-bg: #1a1a2a;
|
|
57
|
+
--accent-color: #3b82f6;
|
|
58
|
+
--text-color: #e0e0e0;
|
|
59
|
+
--text-muted: #aaa;
|
|
60
|
+
--text-idle: #666;
|
|
61
|
+
--error-color: #ef4444;
|
|
62
|
+
--shadow-color: rgba(0, 0, 0, 0.3);
|
|
63
|
+
|
|
64
|
+
display: block;
|
|
65
|
+
width: 100%;
|
|
66
|
+
height: 100%;
|
|
67
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
* {
|
|
71
|
+
box-sizing: border-box;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.player-container {
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
width: 100%;
|
|
78
|
+
height: 100%;
|
|
79
|
+
background: var(--player-bg);
|
|
80
|
+
position: relative;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.player-container.fullscreen {
|
|
84
|
+
position: fixed;
|
|
85
|
+
top: 0;
|
|
86
|
+
left: 0;
|
|
87
|
+
right: 0;
|
|
88
|
+
bottom: 0;
|
|
89
|
+
z-index: 9999;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.preview-viewport {
|
|
93
|
+
flex: 1;
|
|
94
|
+
width: 100%;
|
|
95
|
+
background: var(--player-bg);
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
position: relative;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.preview-viewport canvas {
|
|
105
|
+
max-width: 100%;
|
|
106
|
+
max-height: 100%;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.fullscreen-btn svg {
|
|
110
|
+
width: 20px;
|
|
111
|
+
height: 20px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.controls {
|
|
115
|
+
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
background: var(--controls-bg);
|
|
118
|
+
padding: 0;
|
|
119
|
+
height: 40px;
|
|
120
|
+
flex-shrink: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.control-btn {
|
|
124
|
+
background: var(--controls-bg);
|
|
125
|
+
border: none;
|
|
126
|
+
color: var(--text-color);
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
height: 40px;
|
|
132
|
+
width: 38px;
|
|
133
|
+
padding: 8px;
|
|
134
|
+
transition: background 0.15s;
|
|
135
|
+
flex-shrink: 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.control-btn:hover:not(:disabled) {
|
|
139
|
+
background: var(--controls-hover-bg);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.control-btn:disabled {
|
|
143
|
+
opacity: 0.5;
|
|
144
|
+
cursor: not-allowed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.control-btn svg {
|
|
148
|
+
width: 20px;
|
|
149
|
+
height: 20px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.control-btn .spinner {
|
|
153
|
+
animation: spin 1s linear infinite;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@keyframes spin {
|
|
157
|
+
from { transform: rotate(0deg); }
|
|
158
|
+
to { transform: rotate(360deg); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.volume-wrapper {
|
|
162
|
+
position: relative;
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-shrink: 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.volume-dropdown {
|
|
168
|
+
position: absolute;
|
|
169
|
+
bottom: 100%;
|
|
170
|
+
left: 50%;
|
|
171
|
+
transform: translateX(-50%);
|
|
172
|
+
background: var(--controls-bg);
|
|
173
|
+
border-radius: 4px;
|
|
174
|
+
padding: 10px 6px;
|
|
175
|
+
box-shadow: 0 -2px 10px var(--shadow-color);
|
|
176
|
+
display: none;
|
|
177
|
+
z-index: 20;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.volume-wrapper:hover .volume-dropdown,
|
|
181
|
+
.volume-dropdown:hover {
|
|
182
|
+
display: block;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.volume-slider {
|
|
186
|
+
writing-mode: vertical-lr;
|
|
187
|
+
direction: rtl;
|
|
188
|
+
width: 6px;
|
|
189
|
+
height: 80px;
|
|
190
|
+
-webkit-appearance: none;
|
|
191
|
+
appearance: none;
|
|
192
|
+
background: var(--slider-track-bg);
|
|
193
|
+
border-radius: 3px;
|
|
194
|
+
cursor: pointer;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.volume-slider::-webkit-slider-thumb {
|
|
198
|
+
-webkit-appearance: none;
|
|
199
|
+
width: 14px;
|
|
200
|
+
height: 14px;
|
|
201
|
+
background: var(--accent-color);
|
|
202
|
+
border-radius: 50%;
|
|
203
|
+
cursor: pointer;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.volume-slider::-moz-range-thumb {
|
|
207
|
+
width: 14px;
|
|
208
|
+
height: 14px;
|
|
209
|
+
background: var(--accent-color);
|
|
210
|
+
border-radius: 50%;
|
|
211
|
+
cursor: pointer;
|
|
212
|
+
border: none;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.timestamp {
|
|
216
|
+
color: var(--text-muted);
|
|
217
|
+
font-size: 12px;
|
|
218
|
+
padding: 0 8px;
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
flex-shrink: 0;
|
|
221
|
+
min-width: 85px;
|
|
222
|
+
text-align: center;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.seek-wrapper {
|
|
226
|
+
flex: 1;
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
padding: 0 8px;
|
|
230
|
+
min-width: 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.seek-bar {
|
|
234
|
+
width: 100%;
|
|
235
|
+
height: 6px;
|
|
236
|
+
-webkit-appearance: none;
|
|
237
|
+
appearance: none;
|
|
238
|
+
background: var(--slider-track-bg);
|
|
239
|
+
border-radius: 3px;
|
|
240
|
+
cursor: pointer;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.seek-bar::-webkit-slider-thumb {
|
|
244
|
+
-webkit-appearance: none;
|
|
245
|
+
width: 14px;
|
|
246
|
+
height: 14px;
|
|
247
|
+
background: var(--accent-color);
|
|
248
|
+
border-radius: 50%;
|
|
249
|
+
cursor: pointer;
|
|
250
|
+
transition: transform 0.1s;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.seek-bar::-webkit-slider-thumb:hover {
|
|
254
|
+
transform: scale(1.2);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.seek-bar::-moz-range-thumb {
|
|
258
|
+
width: 14px;
|
|
259
|
+
height: 14px;
|
|
260
|
+
background: var(--accent-color);
|
|
261
|
+
border-radius: 50%;
|
|
262
|
+
cursor: pointer;
|
|
263
|
+
border: none;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.seek-bar:disabled {
|
|
267
|
+
opacity: 0.5;
|
|
268
|
+
cursor: not-allowed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.idle-message {
|
|
272
|
+
color: var(--text-idle);
|
|
273
|
+
font-size: 14px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.error-message {
|
|
277
|
+
color: var(--error-color);
|
|
278
|
+
font-size: 14px;
|
|
279
|
+
text-align: center;
|
|
280
|
+
padding: 20px;
|
|
281
|
+
}
|
|
282
|
+
`;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A Web Component that provides a complete chart preview player with controls.
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* ```html
|
|
289
|
+
* <chart-preview-player id="player"></chart-preview-player>
|
|
290
|
+
*
|
|
291
|
+
* <script type="module">
|
|
292
|
+
* import { ChartPreviewPlayer, ChartPreview } from 'chart-preview';
|
|
293
|
+
*
|
|
294
|
+
* const player = document.getElementById('player');
|
|
295
|
+
* const textures = await ChartPreview.loadTextures(instrumentType);
|
|
296
|
+
*
|
|
297
|
+
* await player.loadChart({
|
|
298
|
+
* parsedChart,
|
|
299
|
+
* textures,
|
|
300
|
+
* audioFiles,
|
|
301
|
+
* instrument: 'guitar',
|
|
302
|
+
* difficulty: 'expert',
|
|
303
|
+
* startDelayMs: 0,
|
|
304
|
+
* audioLengthMs: 180000,
|
|
305
|
+
* });
|
|
306
|
+
* </script>
|
|
307
|
+
* ```
|
|
308
|
+
*
|
|
309
|
+
* @fires player-statechange - When the player state changes
|
|
310
|
+
* @fires player-progress - During playback with current position info
|
|
311
|
+
* @fires player-end - When playback ends
|
|
312
|
+
* @fires player-error - When an error occurs
|
|
313
|
+
*
|
|
314
|
+
* @attr {string} volume - Initial volume (0-100), defaults to 50
|
|
315
|
+
*/
|
|
316
|
+
export class ChartPreviewPlayer extends HTMLElement {
|
|
317
|
+
private shadow: ShadowRoot;
|
|
318
|
+
private chartPreview: ChartPreview | null = null;
|
|
319
|
+
private _state: PlayerState = "idle";
|
|
320
|
+
private _volume = 50;
|
|
321
|
+
private _lastVolumeBeforeMute = 50;
|
|
322
|
+
private _isFullscreen = false;
|
|
323
|
+
private timestampInterval: ReturnType<typeof setInterval> | null = null;
|
|
324
|
+
private _wasPlayingBeforeSeek = false;
|
|
325
|
+
private _isSeeking = false;
|
|
326
|
+
private _currentAbortController: AbortController | null = null;
|
|
327
|
+
|
|
328
|
+
// DOM references
|
|
329
|
+
private container: HTMLDivElement;
|
|
330
|
+
private viewport: HTMLDivElement;
|
|
331
|
+
private playBtn: HTMLButtonElement;
|
|
332
|
+
private volumeBtn: HTMLButtonElement;
|
|
333
|
+
private volumeSlider: HTMLInputElement;
|
|
334
|
+
private seekBar: HTMLInputElement;
|
|
335
|
+
private timestampEl: HTMLSpanElement;
|
|
336
|
+
private fullscreenBtn: HTMLButtonElement;
|
|
337
|
+
|
|
338
|
+
constructor() {
|
|
339
|
+
super();
|
|
340
|
+
this.shadow = this.attachShadow({ mode: "open" });
|
|
341
|
+
this.render();
|
|
342
|
+
this.setupEventListeners();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
static get observedAttributes() {
|
|
346
|
+
return ["volume"];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
|
|
350
|
+
if (name === "volume" && newValue !== null) {
|
|
351
|
+
const parsed = parseInt(newValue, 10);
|
|
352
|
+
this._volume = Math.max(0, Math.min(100, isNaN(parsed) ? 50 : parsed));
|
|
353
|
+
if (this.volumeSlider) {
|
|
354
|
+
this.volumeSlider.value = String(this._volume);
|
|
355
|
+
}
|
|
356
|
+
if (this.chartPreview) {
|
|
357
|
+
this.chartPreview.volume = this._volume / 100;
|
|
358
|
+
}
|
|
359
|
+
this.updateVolumeIcon();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
connectedCallback() {
|
|
364
|
+
this.setAttribute("tabindex", "0");
|
|
365
|
+
document.addEventListener("fullscreenchange", this.handleFullscreenChange);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
disconnectedCallback() {
|
|
369
|
+
this.dispose();
|
|
370
|
+
document.removeEventListener(
|
|
371
|
+
"fullscreenchange",
|
|
372
|
+
this.handleFullscreenChange
|
|
373
|
+
);
|
|
374
|
+
this.removeEventListener("keydown", this.handleKeydown);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private render() {
|
|
378
|
+
this.shadow.innerHTML = `
|
|
379
|
+
<style>${STYLES}</style>
|
|
380
|
+
<div class="player-container">
|
|
381
|
+
<div class="preview-viewport">
|
|
382
|
+
<span class="idle-message">Click play to start</span>
|
|
383
|
+
</div>
|
|
384
|
+
<div class="controls">
|
|
385
|
+
<button class="control-btn play-btn" aria-label="Play" disabled>
|
|
386
|
+
${ICONS.play}
|
|
387
|
+
</button>
|
|
388
|
+
<div class="volume-wrapper">
|
|
389
|
+
<button class="control-btn volume-btn" aria-label="Volume">
|
|
390
|
+
${ICONS.volumeHigh}
|
|
391
|
+
</button>
|
|
392
|
+
<div class="volume-dropdown">
|
|
393
|
+
<input type="range" class="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
<span class="timestamp">0:00 / 0:00</span>
|
|
397
|
+
<div class="seek-wrapper">
|
|
398
|
+
<input type="range" class="seek-bar" min="0" max="1000" value="0" disabled aria-label="Seek">
|
|
399
|
+
</div>
|
|
400
|
+
<button class="control-btn fullscreen-btn" aria-label="Toggle fullscreen" title="Toggle fullscreen">
|
|
401
|
+
${ICONS.fullscreen}
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
`;
|
|
406
|
+
|
|
407
|
+
// Cache DOM references
|
|
408
|
+
this.container = this.shadow.querySelector(".player-container")!;
|
|
409
|
+
this.viewport = this.shadow.querySelector(".preview-viewport")!;
|
|
410
|
+
this.playBtn = this.shadow.querySelector(".play-btn")!;
|
|
411
|
+
this.volumeBtn = this.shadow.querySelector(".volume-btn")!;
|
|
412
|
+
this.volumeSlider = this.shadow.querySelector(".volume-slider")!;
|
|
413
|
+
this.seekBar = this.shadow.querySelector(".seek-bar")!;
|
|
414
|
+
this.timestampEl = this.shadow.querySelector(".timestamp")!;
|
|
415
|
+
this.fullscreenBtn = this.shadow.querySelector(
|
|
416
|
+
".controls .fullscreen-btn"
|
|
417
|
+
)!;
|
|
418
|
+
|
|
419
|
+
// Set initial volume from attribute
|
|
420
|
+
const volumeAttr = this.getAttribute("volume");
|
|
421
|
+
if (volumeAttr !== null) {
|
|
422
|
+
const parsed = parseInt(volumeAttr, 10);
|
|
423
|
+
this._volume = Math.max(0, Math.min(100, isNaN(parsed) ? 50 : parsed));
|
|
424
|
+
}
|
|
425
|
+
this.volumeSlider.value = String(this._volume);
|
|
426
|
+
this.updateVolumeIcon();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private setupEventListeners() {
|
|
430
|
+
// Play button
|
|
431
|
+
this.playBtn.addEventListener("click", () => this.togglePlayPause());
|
|
432
|
+
|
|
433
|
+
// Viewport click to play/pause
|
|
434
|
+
this.viewport.addEventListener("click", () => {
|
|
435
|
+
if (
|
|
436
|
+
this._state !== "idle" &&
|
|
437
|
+
this._state !== "loading" &&
|
|
438
|
+
this._state !== "error"
|
|
439
|
+
) {
|
|
440
|
+
this.togglePlayPause();
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Volume button (mute toggle)
|
|
445
|
+
this.volumeBtn.addEventListener("click", () => this.toggleMute());
|
|
446
|
+
|
|
447
|
+
// Volume slider
|
|
448
|
+
this.volumeSlider.addEventListener("input", () => {
|
|
449
|
+
const newVolume = parseInt(this.volumeSlider.value, 10);
|
|
450
|
+
// Save the volume before it goes to 0 for mute toggle functionality
|
|
451
|
+
if (this._volume > 0 && newVolume === 0) {
|
|
452
|
+
this._lastVolumeBeforeMute = this._volume;
|
|
453
|
+
}
|
|
454
|
+
this._volume = newVolume;
|
|
455
|
+
if (this.chartPreview) {
|
|
456
|
+
this.chartPreview.volume = this._volume / 100;
|
|
457
|
+
}
|
|
458
|
+
this.updateVolumeIcon();
|
|
459
|
+
this.setAttribute("volume", String(this._volume));
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Seek bar - handle both dragging and clicking
|
|
463
|
+
this.seekBar.addEventListener("mousedown", () => this.onSeekStart());
|
|
464
|
+
this.seekBar.addEventListener("touchstart", () => this.onSeekStart(), {
|
|
465
|
+
passive: true,
|
|
466
|
+
});
|
|
467
|
+
this.seekBar.addEventListener("input", () => this.onSeekInput());
|
|
468
|
+
this.seekBar.addEventListener("mouseup", () => this.onSeekEnd());
|
|
469
|
+
this.seekBar.addEventListener("touchend", () => this.onSeekEnd());
|
|
470
|
+
// Handle case where user drags outside the element
|
|
471
|
+
this.seekBar.addEventListener("mouseleave", (e) => {
|
|
472
|
+
if (e.buttons === 0 && this._isSeeking) {
|
|
473
|
+
this.onSeekEnd();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Fullscreen button
|
|
478
|
+
this.fullscreenBtn.addEventListener("click", () => {
|
|
479
|
+
this.toggleFullscreen();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Keyboard controls
|
|
483
|
+
this.addEventListener("keydown", this.handleKeydown);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private handleKeydown = (e: KeyboardEvent) => {
|
|
487
|
+
// Don't handle if user is interacting with inputs
|
|
488
|
+
if (e.target instanceof HTMLInputElement) return;
|
|
489
|
+
|
|
490
|
+
switch (e.code) {
|
|
491
|
+
case "Space":
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
if (
|
|
494
|
+
this._state !== "idle" &&
|
|
495
|
+
this._state !== "loading" &&
|
|
496
|
+
this._state !== "error"
|
|
497
|
+
) {
|
|
498
|
+
this.togglePlayPause();
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
case "ArrowLeft":
|
|
502
|
+
e.preventDefault();
|
|
503
|
+
this.seekRelative(-5000);
|
|
504
|
+
break;
|
|
505
|
+
case "ArrowRight":
|
|
506
|
+
e.preventDefault();
|
|
507
|
+
this.seekRelative(5000);
|
|
508
|
+
break;
|
|
509
|
+
case "ArrowUp":
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
this.setVolume(Math.min(100, this._volume + 10));
|
|
512
|
+
break;
|
|
513
|
+
case "ArrowDown":
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
this.setVolume(Math.max(0, this._volume - 10));
|
|
516
|
+
break;
|
|
517
|
+
case "KeyM":
|
|
518
|
+
e.preventDefault();
|
|
519
|
+
this.toggleMute();
|
|
520
|
+
break;
|
|
521
|
+
case "KeyF":
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
this.toggleFullscreen();
|
|
524
|
+
break;
|
|
525
|
+
case "Escape":
|
|
526
|
+
if (this._isFullscreen) {
|
|
527
|
+
e.preventDefault();
|
|
528
|
+
this.toggleFullscreen();
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
private handleFullscreenChange = () => {
|
|
535
|
+
this._isFullscreen = document.fullscreenElement === this.container;
|
|
536
|
+
this.container.classList.toggle("fullscreen", this._isFullscreen);
|
|
537
|
+
this.fullscreenBtn.innerHTML = this._isFullscreen
|
|
538
|
+
? ICONS.fullscreenExit
|
|
539
|
+
: ICONS.fullscreen;
|
|
540
|
+
this.fullscreenBtn.title = this._isFullscreen
|
|
541
|
+
? "Exit fullscreen"
|
|
542
|
+
: "Toggle fullscreen";
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
private onSeekStart() {
|
|
546
|
+
if (!this._isSeeking) {
|
|
547
|
+
this._isSeeking = true;
|
|
548
|
+
this._wasPlayingBeforeSeek = this._state === "playing";
|
|
549
|
+
// Pause if playing to allow smooth seeking
|
|
550
|
+
if (this._wasPlayingBeforeSeek && this.chartPreview) {
|
|
551
|
+
this.chartPreview.togglePaused();
|
|
552
|
+
this.setState("seeking");
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private async onSeekInput() {
|
|
558
|
+
if (!this.chartPreview) return;
|
|
559
|
+
this.setState("seeking");
|
|
560
|
+
await this.chartPreview.seek(parseInt(this.seekBar.value, 10) / 1000);
|
|
561
|
+
this.updateTimestamp();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private async onSeekEnd() {
|
|
565
|
+
if (!this._isSeeking) return;
|
|
566
|
+
this._isSeeking = false;
|
|
567
|
+
|
|
568
|
+
if (!this.chartPreview) {
|
|
569
|
+
this.setState("paused");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Resume playback if user was playing before seeking
|
|
574
|
+
if (this._wasPlayingBeforeSeek) {
|
|
575
|
+
this._wasPlayingBeforeSeek = false;
|
|
576
|
+
await this.chartPreview.togglePaused();
|
|
577
|
+
this.setState("playing");
|
|
578
|
+
this.startTimestampUpdates();
|
|
579
|
+
} else {
|
|
580
|
+
this.setState("paused");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Prepares the viewport for loading by removing messages and setting loading state.
|
|
586
|
+
*/
|
|
587
|
+
private prepareForLoading(): void {
|
|
588
|
+
this.setState("loading");
|
|
589
|
+
|
|
590
|
+
// Remove idle message
|
|
591
|
+
const idleMessage = this.viewport.querySelector(".idle-message");
|
|
592
|
+
if (idleMessage) idleMessage.remove();
|
|
593
|
+
|
|
594
|
+
// Remove error message if present
|
|
595
|
+
const errorMessage = this.viewport.querySelector(".error-message");
|
|
596
|
+
if (errorMessage) errorMessage.remove();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Handles load errors by showing error message and dispatching error event.
|
|
601
|
+
*/
|
|
602
|
+
private handleLoadError(error: unknown, context: string): void {
|
|
603
|
+
// Don't show error for aborted requests
|
|
604
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
console.error(`Failed to load chart ${context}:`, error);
|
|
609
|
+
this.setState("error");
|
|
610
|
+
|
|
611
|
+
// Show error message
|
|
612
|
+
const errorEl = document.createElement("div");
|
|
613
|
+
errorEl.className = "error-message";
|
|
614
|
+
errorEl.textContent = `Failed to load: ${
|
|
615
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
616
|
+
}`;
|
|
617
|
+
this.viewport.appendChild(errorEl);
|
|
618
|
+
|
|
619
|
+
this.dispatchEvent(
|
|
620
|
+
new CustomEvent("player-error", {
|
|
621
|
+
detail: { error },
|
|
622
|
+
bubbles: true,
|
|
623
|
+
composed: true,
|
|
624
|
+
})
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private setState(state: PlayerState) {
|
|
629
|
+
const oldState = this._state;
|
|
630
|
+
this._state = state;
|
|
631
|
+
this.updatePlayButton();
|
|
632
|
+
|
|
633
|
+
if (oldState !== state) {
|
|
634
|
+
this.dispatchEvent(
|
|
635
|
+
new CustomEvent("player-statechange", {
|
|
636
|
+
detail: { state, previousState: oldState },
|
|
637
|
+
bubbles: true,
|
|
638
|
+
composed: true,
|
|
639
|
+
})
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private updatePlayButton() {
|
|
645
|
+
switch (this._state) {
|
|
646
|
+
case "idle":
|
|
647
|
+
this.playBtn.disabled = true;
|
|
648
|
+
this.playBtn.innerHTML = ICONS.play;
|
|
649
|
+
this.playBtn.setAttribute("aria-label", "Play");
|
|
650
|
+
break;
|
|
651
|
+
case "loading":
|
|
652
|
+
this.playBtn.disabled = true;
|
|
653
|
+
this.playBtn.innerHTML = ICONS.spinner;
|
|
654
|
+
this.playBtn.setAttribute("aria-label", "Loading");
|
|
655
|
+
break;
|
|
656
|
+
case "ready":
|
|
657
|
+
case "paused":
|
|
658
|
+
case "seeking":
|
|
659
|
+
this.playBtn.disabled = false;
|
|
660
|
+
this.playBtn.innerHTML = ICONS.play;
|
|
661
|
+
this.playBtn.setAttribute("aria-label", "Play");
|
|
662
|
+
break;
|
|
663
|
+
case "playing":
|
|
664
|
+
this.playBtn.disabled = false;
|
|
665
|
+
this.playBtn.innerHTML = ICONS.pause;
|
|
666
|
+
this.playBtn.setAttribute("aria-label", "Pause");
|
|
667
|
+
break;
|
|
668
|
+
case "ended":
|
|
669
|
+
this.playBtn.disabled = false;
|
|
670
|
+
this.playBtn.innerHTML = ICONS.replay;
|
|
671
|
+
this.playBtn.setAttribute("aria-label", "Replay");
|
|
672
|
+
break;
|
|
673
|
+
case "error":
|
|
674
|
+
this.playBtn.disabled = true;
|
|
675
|
+
this.playBtn.innerHTML = ICONS.play;
|
|
676
|
+
this.playBtn.setAttribute("aria-label", "Error");
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private updateVolumeIcon() {
|
|
682
|
+
if (this._volume === 0) {
|
|
683
|
+
this.volumeBtn.innerHTML = ICONS.volumeMute;
|
|
684
|
+
} else if (this._volume <= 50) {
|
|
685
|
+
this.volumeBtn.innerHTML = ICONS.volumeLow;
|
|
686
|
+
} else {
|
|
687
|
+
this.volumeBtn.innerHTML = ICONS.volumeHigh;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private updateTimestamp() {
|
|
692
|
+
if (!this.chartPreview) {
|
|
693
|
+
this.timestampEl.textContent = "0:00 / 0:00";
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const current = this.formatTime(this.chartPreview.chartCurrentTimeMs);
|
|
697
|
+
const total = this.formatTime(this.chartPreview.chartEndTimeMs);
|
|
698
|
+
this.timestampEl.textContent = `${current} / ${total}`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private formatTime(ms: number): string {
|
|
702
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
703
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
704
|
+
const seconds = totalSeconds % 60;
|
|
705
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private startTimestampUpdates() {
|
|
709
|
+
this.stopTimestampUpdates();
|
|
710
|
+
this.timestampInterval = setInterval(() => this.updateTimestamp(), 100);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private stopTimestampUpdates() {
|
|
714
|
+
if (this.timestampInterval) {
|
|
715
|
+
clearInterval(this.timestampInterval);
|
|
716
|
+
this.timestampInterval = null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Public API
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Current player state
|
|
724
|
+
*/
|
|
725
|
+
get state(): PlayerState {
|
|
726
|
+
return this._state;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Whether the player is currently playing
|
|
731
|
+
*/
|
|
732
|
+
get isPlaying(): boolean {
|
|
733
|
+
return this._state === "playing";
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Current volume (0-100)
|
|
738
|
+
*/
|
|
739
|
+
get volume(): number {
|
|
740
|
+
return this._volume;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Set volume (0-100)
|
|
745
|
+
*/
|
|
746
|
+
setVolume(volume: number) {
|
|
747
|
+
this._volume = Math.max(0, Math.min(100, volume));
|
|
748
|
+
this.volumeSlider.value = String(this._volume);
|
|
749
|
+
if (this.chartPreview) {
|
|
750
|
+
this.chartPreview.volume = this._volume / 100;
|
|
751
|
+
}
|
|
752
|
+
this.updateVolumeIcon();
|
|
753
|
+
this.setAttribute("volume", String(this._volume));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Current playback position in milliseconds
|
|
758
|
+
*/
|
|
759
|
+
get currentTimeMs(): number {
|
|
760
|
+
return this.chartPreview?.chartCurrentTimeMs ?? 0;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Total duration in milliseconds
|
|
765
|
+
*/
|
|
766
|
+
get durationMs(): number {
|
|
767
|
+
return this.chartPreview?.chartEndTimeMs ?? 0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Whether the player is in fullscreen mode
|
|
772
|
+
*/
|
|
773
|
+
get isFullscreen(): boolean {
|
|
774
|
+
return this._isFullscreen;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Load a chart into the player from pre-processed data.
|
|
779
|
+
* Use this when you've already parsed the chart and loaded textures.
|
|
780
|
+
*
|
|
781
|
+
* @example
|
|
782
|
+
* ```ts
|
|
783
|
+
* const textures = await ChartPreview.loadTextures(instrumentType);
|
|
784
|
+
* await player.loadChart({
|
|
785
|
+
* parsedChart,
|
|
786
|
+
* textures,
|
|
787
|
+
* audioFiles,
|
|
788
|
+
* instrument: 'guitar',
|
|
789
|
+
* difficulty: 'expert',
|
|
790
|
+
* startDelayMs: 0,
|
|
791
|
+
* audioLengthMs: 180000,
|
|
792
|
+
* });
|
|
793
|
+
* ```
|
|
794
|
+
*/
|
|
795
|
+
async loadChart(config: ChartPreviewPlayerConfig): Promise<void> {
|
|
796
|
+
try {
|
|
797
|
+
// Clean up previous instance
|
|
798
|
+
if (this.chartPreview) {
|
|
799
|
+
this.chartPreview.dispose();
|
|
800
|
+
this.chartPreview = null;
|
|
801
|
+
}
|
|
802
|
+
this.stopTimestampUpdates();
|
|
803
|
+
|
|
804
|
+
this.prepareForLoading();
|
|
805
|
+
|
|
806
|
+
// Create a container div for the ChartPreview
|
|
807
|
+
let previewContainer = this.viewport.querySelector(
|
|
808
|
+
".preview-render"
|
|
809
|
+
) as HTMLDivElement;
|
|
810
|
+
if (!previewContainer) {
|
|
811
|
+
previewContainer = document.createElement("div");
|
|
812
|
+
previewContainer.className = "preview-render";
|
|
813
|
+
previewContainer.style.cssText = "width: 100%; height: 100%;";
|
|
814
|
+
this.viewport.appendChild(previewContainer);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Create the chart preview
|
|
818
|
+
this.chartPreview = await ChartPreview.create({
|
|
819
|
+
parsedChart: config.parsedChart,
|
|
820
|
+
textures: config.textures,
|
|
821
|
+
audioFiles: config.audioFiles,
|
|
822
|
+
instrument: config.instrument,
|
|
823
|
+
difficulty: config.difficulty,
|
|
824
|
+
startDelayMs: config.startDelayMs,
|
|
825
|
+
audioLengthMs: config.audioLengthMs,
|
|
826
|
+
container: previewContainer,
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// Set volume
|
|
830
|
+
this.chartPreview.volume = this._volume / 100;
|
|
831
|
+
|
|
832
|
+
// Setup event listeners
|
|
833
|
+
this.chartPreview.on("progress", (percent) => {
|
|
834
|
+
this.seekBar.value = String(Math.round(percent * 1000));
|
|
835
|
+
this.dispatchEvent(
|
|
836
|
+
new CustomEvent("player-progress", {
|
|
837
|
+
detail: {
|
|
838
|
+
percent,
|
|
839
|
+
currentMs: this.chartPreview!.chartCurrentTimeMs,
|
|
840
|
+
totalMs: this.chartPreview!.chartEndTimeMs,
|
|
841
|
+
},
|
|
842
|
+
bubbles: true,
|
|
843
|
+
composed: true,
|
|
844
|
+
})
|
|
845
|
+
);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
this.chartPreview.on("end", async () => {
|
|
849
|
+
await this.chartPreview!.togglePaused();
|
|
850
|
+
this.setState("ended");
|
|
851
|
+
this.stopTimestampUpdates();
|
|
852
|
+
this.dispatchEvent(
|
|
853
|
+
new CustomEvent("player-end", {
|
|
854
|
+
bubbles: true,
|
|
855
|
+
composed: true,
|
|
856
|
+
})
|
|
857
|
+
);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Seek to initial position if provided
|
|
861
|
+
if (
|
|
862
|
+
config.initialSeekPercent !== undefined &&
|
|
863
|
+
config.initialSeekPercent > 0
|
|
864
|
+
) {
|
|
865
|
+
await this.chartPreview.seek(config.initialSeekPercent);
|
|
866
|
+
this.seekBar.value = String(
|
|
867
|
+
Math.round(config.initialSeekPercent * 1000)
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Enable controls
|
|
872
|
+
this.seekBar.disabled = false;
|
|
873
|
+
this.startTimestampUpdates();
|
|
874
|
+
|
|
875
|
+
this.setState("ready");
|
|
876
|
+
} catch (error) {
|
|
877
|
+
this.handleLoadError(error, "");
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Load a chart from a URL pointing to a .sng file.
|
|
883
|
+
* This is the simplest way to load a chart - just provide a URL and instrument/difficulty.
|
|
884
|
+
*
|
|
885
|
+
* @example
|
|
886
|
+
* ```ts
|
|
887
|
+
* await player.loadFromUrl({
|
|
888
|
+
* url: 'https://files.enchor.us/abc123.sng',
|
|
889
|
+
* instrument: 'guitar',
|
|
890
|
+
* difficulty: 'expert',
|
|
891
|
+
* });
|
|
892
|
+
* ```
|
|
893
|
+
*/
|
|
894
|
+
async loadFromUrl(config: LoadFromUrlConfig): Promise<void> {
|
|
895
|
+
try {
|
|
896
|
+
// Cancel any previous pending fetch
|
|
897
|
+
if (this._currentAbortController) {
|
|
898
|
+
this._currentAbortController.abort();
|
|
899
|
+
}
|
|
900
|
+
this._currentAbortController = new AbortController();
|
|
901
|
+
const signal = config.signal ?? this._currentAbortController.signal;
|
|
902
|
+
|
|
903
|
+
this.prepareForLoading();
|
|
904
|
+
|
|
905
|
+
// Fetch the .sng file
|
|
906
|
+
const sngData = await fetchSngFile(config.url, signal);
|
|
907
|
+
|
|
908
|
+
// Extract and prepare the chart
|
|
909
|
+
const files = await extractSngFile(sngData);
|
|
910
|
+
const preparedData = await prepareChartData(
|
|
911
|
+
files,
|
|
912
|
+
config.instrument,
|
|
913
|
+
config.difficulty,
|
|
914
|
+
config.initialSeekPercent
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
// Load the chart
|
|
918
|
+
await this.loadChart({
|
|
919
|
+
parsedChart: preparedData.parsedChart,
|
|
920
|
+
textures: preparedData.textures,
|
|
921
|
+
audioFiles: preparedData.audioFiles,
|
|
922
|
+
instrument: preparedData.instrument,
|
|
923
|
+
difficulty: preparedData.difficulty,
|
|
924
|
+
startDelayMs: preparedData.startDelayMs,
|
|
925
|
+
audioLengthMs: preparedData.audioLengthMs,
|
|
926
|
+
initialSeekPercent: preparedData.initialSeekPercent,
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
this._currentAbortController = null;
|
|
930
|
+
} catch (error) {
|
|
931
|
+
this._currentAbortController = null;
|
|
932
|
+
this.handleLoadError(error, "from URL");
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Load a chart from raw .sng file data.
|
|
938
|
+
* Use this when you've already fetched the .sng file yourself.
|
|
939
|
+
*
|
|
940
|
+
* @example
|
|
941
|
+
* ```ts
|
|
942
|
+
* const response = await fetch('https://files.enchor.us/abc123.sng');
|
|
943
|
+
* const sngData = new Uint8Array(await response.arrayBuffer());
|
|
944
|
+
*
|
|
945
|
+
* await player.loadFromSngFile({
|
|
946
|
+
* sngFile: sngData,
|
|
947
|
+
* instrument: 'guitar',
|
|
948
|
+
* difficulty: 'expert',
|
|
949
|
+
* });
|
|
950
|
+
* ```
|
|
951
|
+
*/
|
|
952
|
+
async loadFromSngFile(config: LoadFromSngFileConfig): Promise<void> {
|
|
953
|
+
try {
|
|
954
|
+
this.prepareForLoading();
|
|
955
|
+
|
|
956
|
+
// Extract and prepare the chart
|
|
957
|
+
const files = await extractSngFile(config.sngFile);
|
|
958
|
+
const preparedData = await prepareChartData(
|
|
959
|
+
files,
|
|
960
|
+
config.instrument,
|
|
961
|
+
config.difficulty,
|
|
962
|
+
config.initialSeekPercent
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
// Load the chart
|
|
966
|
+
await this.loadChart({
|
|
967
|
+
parsedChart: preparedData.parsedChart,
|
|
968
|
+
textures: preparedData.textures,
|
|
969
|
+
audioFiles: preparedData.audioFiles,
|
|
970
|
+
instrument: preparedData.instrument,
|
|
971
|
+
difficulty: preparedData.difficulty,
|
|
972
|
+
startDelayMs: preparedData.startDelayMs,
|
|
973
|
+
audioLengthMs: preparedData.audioLengthMs,
|
|
974
|
+
initialSeekPercent: preparedData.initialSeekPercent,
|
|
975
|
+
});
|
|
976
|
+
} catch (error) {
|
|
977
|
+
this.handleLoadError(error, "from .sng file");
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Load a chart from individual files (chart file + audio files).
|
|
983
|
+
* Use this when loading from a folder or when you have the files separately.
|
|
984
|
+
*
|
|
985
|
+
* @example
|
|
986
|
+
* ```ts
|
|
987
|
+
* // From file picker
|
|
988
|
+
* const files = await getFilesFromFolder();
|
|
989
|
+
*
|
|
990
|
+
* await player.loadFromChartFiles({
|
|
991
|
+
* files: files.map(f => ({ fileName: f.name, data: new Uint8Array(f.data) })),
|
|
992
|
+
* instrument: 'guitar',
|
|
993
|
+
* difficulty: 'expert',
|
|
994
|
+
* });
|
|
995
|
+
* ```
|
|
996
|
+
*/
|
|
997
|
+
async loadFromChartFiles(config: LoadFromChartFilesConfig): Promise<void> {
|
|
998
|
+
try {
|
|
999
|
+
this.prepareForLoading();
|
|
1000
|
+
|
|
1001
|
+
// Prepare the chart data
|
|
1002
|
+
const preparedData = await prepareChartData(
|
|
1003
|
+
config.files,
|
|
1004
|
+
config.instrument,
|
|
1005
|
+
config.difficulty,
|
|
1006
|
+
config.initialSeekPercent
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
// Load the chart
|
|
1010
|
+
await this.loadChart({
|
|
1011
|
+
parsedChart: preparedData.parsedChart,
|
|
1012
|
+
textures: preparedData.textures,
|
|
1013
|
+
audioFiles: preparedData.audioFiles,
|
|
1014
|
+
instrument: preparedData.instrument,
|
|
1015
|
+
difficulty: preparedData.difficulty,
|
|
1016
|
+
startDelayMs: preparedData.startDelayMs,
|
|
1017
|
+
audioLengthMs: preparedData.audioLengthMs,
|
|
1018
|
+
initialSeekPercent: preparedData.initialSeekPercent,
|
|
1019
|
+
});
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
this.handleLoadError(error, "from files");
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Toggle between playing and paused states
|
|
1027
|
+
*/
|
|
1028
|
+
async togglePlayPause(): Promise<void> {
|
|
1029
|
+
if (!this.chartPreview) return;
|
|
1030
|
+
|
|
1031
|
+
if (this._state === "ended") {
|
|
1032
|
+
// Restart from beginning
|
|
1033
|
+
await this.chartPreview.seek(0);
|
|
1034
|
+
this.seekBar.value = "0";
|
|
1035
|
+
this.setState("paused");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (this._state === "paused" || this._state === "ready") {
|
|
1039
|
+
this.setState("loading");
|
|
1040
|
+
await this.chartPreview.togglePaused();
|
|
1041
|
+
this.setState("playing");
|
|
1042
|
+
this.startTimestampUpdates();
|
|
1043
|
+
} else if (this._state === "playing") {
|
|
1044
|
+
this.setState("loading");
|
|
1045
|
+
await this.chartPreview.togglePaused();
|
|
1046
|
+
this.setState("paused");
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Play the chart
|
|
1052
|
+
*/
|
|
1053
|
+
async play(): Promise<void> {
|
|
1054
|
+
if (
|
|
1055
|
+
this._state === "paused" ||
|
|
1056
|
+
this._state === "ready" ||
|
|
1057
|
+
this._state === "ended"
|
|
1058
|
+
) {
|
|
1059
|
+
await this.togglePlayPause();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Pause the chart
|
|
1065
|
+
*/
|
|
1066
|
+
async pause(): Promise<void> {
|
|
1067
|
+
if (this._state === "playing") {
|
|
1068
|
+
await this.togglePlayPause();
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Seek to a specific position
|
|
1074
|
+
* @param percent Position as a percentage (0-1)
|
|
1075
|
+
*/
|
|
1076
|
+
async seek(percent: number): Promise<void> {
|
|
1077
|
+
if (!this.chartPreview) return;
|
|
1078
|
+
const wasPlaying = this._state === "playing";
|
|
1079
|
+
if (wasPlaying) {
|
|
1080
|
+
await this.chartPreview.togglePaused();
|
|
1081
|
+
}
|
|
1082
|
+
await this.chartPreview.seek(Math.max(0, Math.min(1, percent)));
|
|
1083
|
+
this.seekBar.value = String(Math.round(percent * 1000));
|
|
1084
|
+
this.updateTimestamp();
|
|
1085
|
+
this.setState("paused");
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Seek relative to current position
|
|
1090
|
+
* @param deltaMs Milliseconds to seek (positive = forward, negative = backward)
|
|
1091
|
+
*/
|
|
1092
|
+
async seekRelative(deltaMs: number): Promise<void> {
|
|
1093
|
+
if (!this.chartPreview) return;
|
|
1094
|
+
const currentMs = this.chartPreview.chartCurrentTimeMs;
|
|
1095
|
+
const totalMs = this.chartPreview.chartEndTimeMs;
|
|
1096
|
+
const newPercent = Math.max(
|
|
1097
|
+
0,
|
|
1098
|
+
Math.min(1, (currentMs + deltaMs) / totalMs)
|
|
1099
|
+
);
|
|
1100
|
+
await this.seek(newPercent);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Toggle mute
|
|
1105
|
+
*/
|
|
1106
|
+
toggleMute(): void {
|
|
1107
|
+
if (this._volume > 0) {
|
|
1108
|
+
this._lastVolumeBeforeMute = this._volume;
|
|
1109
|
+
this.setVolume(0);
|
|
1110
|
+
} else {
|
|
1111
|
+
this.setVolume(this._lastVolumeBeforeMute || 50);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Toggle fullscreen mode
|
|
1117
|
+
*/
|
|
1118
|
+
async toggleFullscreen(): Promise<void> {
|
|
1119
|
+
if (!document.fullscreenEnabled) return;
|
|
1120
|
+
|
|
1121
|
+
if (this._isFullscreen) {
|
|
1122
|
+
await document.exitFullscreen();
|
|
1123
|
+
} else {
|
|
1124
|
+
await this.container.requestFullscreen();
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Dispose of the player and clean up resources
|
|
1130
|
+
*/
|
|
1131
|
+
dispose(): void {
|
|
1132
|
+
// Cancel any pending network requests
|
|
1133
|
+
if (this._currentAbortController) {
|
|
1134
|
+
this._currentAbortController.abort();
|
|
1135
|
+
this._currentAbortController = null;
|
|
1136
|
+
}
|
|
1137
|
+
this.stopTimestampUpdates();
|
|
1138
|
+
if (this.chartPreview) {
|
|
1139
|
+
this.chartPreview.dispose();
|
|
1140
|
+
this.chartPreview = null;
|
|
1141
|
+
}
|
|
1142
|
+
this._isSeeking = false;
|
|
1143
|
+
this._wasPlayingBeforeSeek = false;
|
|
1144
|
+
this.setState("idle");
|
|
1145
|
+
this.seekBar.disabled = true;
|
|
1146
|
+
this.seekBar.value = "0";
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Register the custom element
|
|
1151
|
+
if (
|
|
1152
|
+
typeof window !== "undefined" &&
|
|
1153
|
+
!customElements.get("chart-preview-player")
|
|
1154
|
+
) {
|
|
1155
|
+
customElements.define("chart-preview-player", ChartPreviewPlayer);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// TypeScript declaration merging for global HTMLElementTagNameMap
|
|
1159
|
+
declare global {
|
|
1160
|
+
interface HTMLElementTagNameMap {
|
|
1161
|
+
"chart-preview-player": ChartPreviewPlayer;
|
|
1162
|
+
}
|
|
1163
|
+
}
|