izuemon commited on
Commit
945cc2e
·
verified ·
1 Parent(s): 7d491a0

Create index.html

Browse files
Files changed (1) hide show
  1. p2/index.html +1284 -0
p2/index.html ADDED
@@ -0,0 +1,1284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>統合メディアプレイヤー</title>
7
+
8
+ <!-- 背景レンダリング (bg.js) -->
9
+ <script src="bg.js" defer></script>
10
+
11
+ <!-- ライブラリ -->
12
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
13
+ <script src="https://unpkg.com/@tonejs/midi@2.0.28/build/Midi.js"></script>
14
+
15
+ <!-- 各プレイヤーモジュール -->
16
+ <script type="module" src="midiplay.js"></script>
17
+ <script type="module" src="mxml.js"></script>
18
+
19
+ <style>
20
+ * { box-sizing: border-box; }
21
+ body {
22
+ margin: 0;
23
+ background: #0b0e14;
24
+ color: #e6f1ff;
25
+ font-family: 'M PLUS Rounded 1c', sans-serif;
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ padding: 12px;
30
+ min-height: 100vh;
31
+ }
32
+
33
+ /* 背景は bg.js が担当 */
34
+ #bg-canvas {
35
+ position: fixed;
36
+ top: 0; left: 0;
37
+ width: 100%; height: 100%;
38
+ z-index: -1;
39
+ pointer-events: none;
40
+ }
41
+
42
+ .app {
43
+ max-width: 1400px;
44
+ width: 100%;
45
+ background: rgba(17, 34, 64, 0.25);
46
+ backdrop-filter: blur(6px);
47
+ border-radius: 16px;
48
+ padding: 16px;
49
+ border: 1px solid rgba(100, 255, 218, 0.15);
50
+ box-shadow: 0 8px 32px rgba(0,0,0,0.6);
51
+ }
52
+
53
+ /* ヘッダー:保存ボタン&プリセット */
54
+ .header {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ align-items: center;
58
+ gap: 12px;
59
+ margin-bottom: 12px;
60
+ }
61
+ .header .preset-area {
62
+ display: flex;
63
+ flex-wrap: wrap;
64
+ align-items: center;
65
+ gap: 8px;
66
+ flex: 1;
67
+ }
68
+ .preset-item {
69
+ background: #1e2a47;
70
+ padding: 4px 14px;
71
+ border-radius: 20px;
72
+ font-size: 13px;
73
+ cursor: pointer;
74
+ border: 1px solid #64ffda55;
75
+ transition: 0.2s;
76
+ position: relative;
77
+ display: inline-flex;
78
+ align-items: center;
79
+ gap: 6px;
80
+ }
81
+ .preset-item:hover { background: #2a3a5f; }
82
+ .preset-item .del {
83
+ display: none;
84
+ color: #ff6b6b;
85
+ font-weight: bold;
86
+ cursor: pointer;
87
+ font-size: 16px;
88
+ line-height: 1;
89
+ }
90
+ .preset-item:hover .del { display: inline; }
91
+
92
+ .btn {
93
+ background: #1e2a47;
94
+ border: 1px solid #64ffda66;
95
+ color: #e6f1ff;
96
+ padding: 6px 18px;
97
+ border-radius: 30px;
98
+ cursor: pointer;
99
+ font-size: 14px;
100
+ transition: 0.2s;
101
+ }
102
+ .btn:hover { background: #2a3a5f; border-color: #64ffda; }
103
+
104
+ /* プレイヤー本体 */
105
+ #player-container {
106
+ position: relative;
107
+ width: 100%;
108
+ background: #0a0f1a;
109
+ border-radius: 12px;
110
+ overflow: hidden;
111
+ border: 1px solid #1e2a47;
112
+ min-height: 480px;
113
+ display: flex;
114
+ flex-direction: column;
115
+ }
116
+
117
+ /* 要素を配置するエリア(自由配置) */
118
+ #stage {
119
+ position: relative;
120
+ width: 100%;
121
+ flex: 1;
122
+ min-height: 420px;
123
+ background: #0d121f;
124
+ overflow: hidden;
125
+ }
126
+
127
+ /* 各要素(ウィジェット) */
128
+ .widget {
129
+ position: absolute;
130
+ background: #141c2b;
131
+ border: 1px solid #2a3a5f;
132
+ border-radius: 8px;
133
+ min-width: 120px;
134
+ min-height: 80px;
135
+ display: flex;
136
+ flex-direction: column;
137
+ resize: both;
138
+ overflow: hidden;
139
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
140
+ transition: box-shadow 0.2s;
141
+ cursor: default;
142
+ }
143
+ .widget:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.7); }
144
+ .widget .title-bar {
145
+ display: flex;
146
+ justify-content: space-between;
147
+ align-items: center;
148
+ background: #1a2538;
149
+ padding: 2px 8px;
150
+ font-size: 12px;
151
+ border-bottom: 1px solid #2a3a5f;
152
+ cursor: move;
153
+ user-select: none;
154
+ flex-shrink: 0;
155
+ }
156
+ .widget .title-bar .wtitle {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 6px;
160
+ font-weight: 500;
161
+ color: #a2c2e8;
162
+ }
163
+ .widget .title-bar .wtitle .type-badge {
164
+ font-size: 10px;
165
+ background: #2a3a5f;
166
+ padding: 0 8px;
167
+ border-radius: 10px;
168
+ color: #64ffda;
169
+ }
170
+ .widget .title-bar .wctrl {
171
+ display: flex;
172
+ gap: 6px;
173
+ }
174
+ .widget .title-bar .wctrl button {
175
+ background: none;
176
+ border: none;
177
+ color: #8aa4c8;
178
+ cursor: pointer;
179
+ font-size: 14px;
180
+ padding: 0 4px;
181
+ }
182
+ .widget .title-bar .wctrl button:hover { color: #fff; }
183
+ .widget .content {
184
+ flex: 1;
185
+ overflow: hidden;
186
+ position: relative;
187
+ background: #0a0f1a;
188
+ min-height: 40px;
189
+ }
190
+ .widget .content > * {
191
+ width: 100%;
192
+ height: 100%;
193
+ object-fit: contain;
194
+ }
195
+
196
+ /* コントロールバー(下部) */
197
+ #controls-bar {
198
+ background: #111b2a;
199
+ border-top: 1px solid #1e2a47;
200
+ padding: 6px 16px;
201
+ display: flex;
202
+ flex-wrap: wrap;
203
+ align-items: center;
204
+ gap: 12px;
205
+ flex-shrink: 0;
206
+ }
207
+ #controls-bar .ctrl-group {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 6px;
211
+ }
212
+ #controls-bar .ctrl-group button {
213
+ background: none;
214
+ border: none;
215
+ color: #ccd6f6;
216
+ font-size: 20px;
217
+ cursor: pointer;
218
+ padding: 2px 6px;
219
+ border-radius: 6px;
220
+ display: flex;
221
+ align-items: center;
222
+ }
223
+ #controls-bar .ctrl-group button:hover { background: #1e2a47; }
224
+ #controls-bar .time-display {
225
+ font-size: 14px;
226
+ font-variant-numeric: tabular-nums;
227
+ min-width: 140px;
228
+ color: #a2c2e8;
229
+ }
230
+ #controls-bar .volume-wrap {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 6px;
234
+ }
235
+ #controls-bar .volume-wrap input[type="range"] {
236
+ width: 80px;
237
+ height: 4px;
238
+ -webkit-appearance: none;
239
+ background: #1e2a47;
240
+ border-radius: 4px;
241
+ }
242
+ #controls-bar .volume-wrap input[type="range"]::-webkit-slider-thumb {
243
+ -webkit-appearance: none;
244
+ width: 12px; height: 12px;
245
+ background: #64ffda;
246
+ border-radius: 50%;
247
+ cursor: pointer;
248
+ }
249
+
250
+ /* プログレスバー */
251
+ #progress-area {
252
+ position: relative;
253
+ width: 100%;
254
+ height: 20px;
255
+ background: #0d121f;
256
+ cursor: pointer;
257
+ border-top: 1px solid #1e2a47;
258
+ }
259
+ #progress-area .progress-bg {
260
+ position: absolute;
261
+ top: 50%; left: 0;
262
+ transform: translateY(-50%);
263
+ width: 100%;
264
+ height: 6px;
265
+ background: #1e2a47;
266
+ border-radius: 4px;
267
+ }
268
+ #progress-area .progress-fill {
269
+ position: absolute;
270
+ top: 50%; left: 0;
271
+ transform: translateY(-50%);
272
+ height: 6px;
273
+ background: #64ffda;
274
+ border-radius: 4px;
275
+ width: 0%;
276
+ pointer-events: none;
277
+ }
278
+ /* マーカー */
279
+ .marker {
280
+ position: absolute;
281
+ top: 0;
282
+ width: 3px;
283
+ height: 100%;
284
+ background: #ff6b6b;
285
+ z-index: 5;
286
+ pointer-events: none;
287
+ transform: translateX(-50%);
288
+ }
289
+ .marker.start { background: #64ffda; }
290
+ .marker.end { background: #ff6b6b; }
291
+ .marker.draggable {
292
+ pointer-events: auto;
293
+ cursor: ew-resize;
294
+ width: 12px;
295
+ height: 100%;
296
+ background: transparent;
297
+ z-index: 10;
298
+ }
299
+ .marker.draggable::after {
300
+ content: '';
301
+ position: absolute;
302
+ top: 50%; left: 50%;
303
+ transform: translate(-50%, -50%);
304
+ width: 8px; height: 8px;
305
+ background: #fff;
306
+ border-radius: 50%;
307
+ border: 2px solid #64ffda;
308
+ }
309
+ .marker.draggable.end::after { border-color: #ff6b6b; }
310
+
311
+ /* ホバー表示 */
312
+ #hover-time {
313
+ position: absolute;
314
+ top: -24px;
315
+ left: 0;
316
+ background: #0a0f1aee;
317
+ padding: 2px 8px;
318
+ border-radius: 4px;
319
+ font-size: 12px;
320
+ color: #64ffda;
321
+ pointer-events: none;
322
+ transform: translateX(-50%);
323
+ display: none;
324
+ z-index: 20;
325
+ }
326
+
327
+ /* 設定ポップアップ */
328
+ .settings-popup {
329
+ position: absolute;
330
+ bottom: 100%;
331
+ right: 0;
332
+ background: #141c2b;
333
+ border: 1px solid #2a3a5f;
334
+ border-radius: 10px;
335
+ padding: 16px;
336
+ min-width: 260px;
337
+ display: none;
338
+ z-index: 100;
339
+ box-shadow: 0 8px 24px rgba(0,0,0,0.7);
340
+ backdrop-filter: blur(4px);
341
+ }
342
+ .settings-popup.open { display: block; }
343
+ .settings-popup .row {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 10px;
347
+ margin-bottom: 8px;
348
+ }
349
+ .settings-popup .row label { min-width: 70px; font-size: 13px; color: #a2c2e8; }
350
+ .settings-popup .row input[type="number"] {
351
+ width: 70px;
352
+ background: #0a0f1a;
353
+ border: 1px solid #2a3a5f;
354
+ color: #fff;
355
+ padding: 4px 6px;
356
+ border-radius: 4px;
357
+ }
358
+ .settings-popup .row input[type="range"] { flex: 1; }
359
+
360
+ /* その他 */
361
+ .resize-handle {
362
+ position: absolute;
363
+ bottom: 0; right: 0;
364
+ width: 16px; height: 16px;
365
+ cursor: nwse-resize;
366
+ background: transparent;
367
+ z-index: 20;
368
+ }
369
+ .widget .content video, .widget .content canvas, .widget .content iframe {
370
+ width: 100%;
371
+ height: 100%;
372
+ display: block;
373
+ }
374
+ .widget .content .midi-container, .widget .content .mxml-container {
375
+ width: 100%;
376
+ height: 100%;
377
+ overflow: hidden;
378
+ position: relative;
379
+ }
380
+
381
+ /* コンテキストメニュー */
382
+ .ctx-menu {
383
+ position: fixed;
384
+ background: #141c2b;
385
+ border: 1px solid #2a3a5f;
386
+ border-radius: 8px;
387
+ padding: 6px 0;
388
+ min-width: 180px;
389
+ z-index: 999;
390
+ box-shadow: 0 8px 24px rgba(0,0,0,0.8);
391
+ }
392
+ .ctx-menu .item {
393
+ padding: 6px 18px;
394
+ cursor: pointer;
395
+ font-size: 13px;
396
+ color: #ccd6f6;
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 8px;
400
+ }
401
+ .ctx-menu .item:hover { background: #1e2a47; }
402
+ .ctx-menu .item .icon { font-size: 16px; }
403
+ </style>
404
+ </head>
405
+ <body>
406
+
407
+ <canvas id="bg-canvas"></canvas>
408
+
409
+ <div class="app">
410
+ <!-- ヘッダー -->
411
+ <div class="header">
412
+ <div class="preset-area" id="preset-area">
413
+ <span style="font-size:13px; color:#64ffda;">プリセット:</span>
414
+ <!-- 動的にプリセットを挿入 -->
415
+ </div>
416
+ <button class="btn" id="save-layout-btn">💾 レイアウト保存</button>
417
+ </div>
418
+
419
+ <!-- プレイヤー -->
420
+ <div id="player-container">
421
+ <div id="stage"></div>
422
+
423
+ <!-- プログレスバー -->
424
+ <div id="progress-area">
425
+ <div class="progress-bg"></div>
426
+ <div class="progress-fill" id="progress-fill"></div>
427
+ <div class="marker start" id="marker-start" style="left:0%;"></div>
428
+ <div class="marker end" id="marker-end" style="left:100%;"></div>
429
+ <div class="marker draggable start" id="marker-start-drag" style="left:0%;"></div>
430
+ <div class="marker draggable end" id="marker-end-drag" style="left:100%;"></div>
431
+ <div id="hover-time"></div>
432
+ </div>
433
+
434
+ <!-- コントロールバー -->
435
+ <div id="controls-bar">
436
+ <div class="ctrl-group">
437
+ <button id="play-toggle">▶</button>
438
+ <button id="reset-btn">⏮</button>
439
+ <span class="time-display" id="time-display">00:00.00 / 00:00.00</span>
440
+ </div>
441
+ <div class="ctrl-group volume-wrap">
442
+ <span>🔊</span>
443
+ <input type="range" id="volume-slider" min="0" max="1" step="0.01" value="0.8">
444
+ </div>
445
+ <div class="ctrl-group" style="margin-left:auto; position:relative;">
446
+ <button id="settings-toggle">⚙️</button>
447
+ <div class="settings-popup" id="settings-popup">
448
+ <div class="row"><label>速度</label><input type="number" id="speed-input" value="1.0" step="0.05" min="0.1" max="4.0"> <input type="range" id="speed-slider" min="0.1" max="4.0" step="0.05" value="1.0"></div>
449
+ <div class="row"><label>テンポ</label><input type="number" id="tempo-input" value="84" step="1" min="20" max="300"></div>
450
+ <hr style="border-color:#2a3a5f;">
451
+ <div class="row"><label>開始(秒)</label><input type="number" id="range-start" value="0" step="0.1" min="0"></div>
452
+ <div class="row"><label>終了(秒)</label><input type="number" id="range-end" value="0" step="0.1" min="0"></div>
453
+ <div style="display:flex; gap:8px; margin-top:6px;">
454
+ <button class="btn" id="apply-range" style="flex:1;">範囲を適用</button>
455
+ </div>
456
+ <div style="margin-top:8px; font-size:12px; color:#8aa4c8;">
457
+ 🎯 マーカーをドラッグして範囲設定もできます
458
+ </div>
459
+ </div>
460
+ </div>
461
+ <div class="ctrl-group">
462
+ <button id="add-widget-btn">➕</button>
463
+ </div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+
468
+ <script type="module">
469
+ // ------------------------------------------------------------
470
+ // メインアプリケーション
471
+ // ------------------------------------------------------------
472
+ import { MidiPlayer } from './midiplay.js';
473
+ import { MxmlPlayer } from './mxml.js';
474
+
475
+ // ----- グローバル状態 -----
476
+ const state = {
477
+ widgets: [], // { id, type, x, y, w, h, data: { src, ... } }
478
+ nextId: 1,
479
+ currentTime: 0,
480
+ duration: 0,
481
+ isPlaying: false,
482
+ speed: 1.0,
483
+ tempo: 84,
484
+ startTime: 0,
485
+ endTime: 0,
486
+ volume: 0.8,
487
+ audioContext: null,
488
+ // 合成設定
489
+ mix: { a: 0.5, s: 0.5, t: 0.5, p: 0.5 },
490
+ preset: 'custom',
491
+ };
492
+
493
+ // DOM refs
494
+ const stage = document.getElementById('stage');
495
+ const progressFill = document.getElementById('progress-fill');
496
+ const timeDisplay = document.getElementById('time-display');
497
+ const playToggle = document.getElementById('play-toggle');
498
+ const resetBtn = document.getElementById('reset-btn');
499
+ const volumeSlider = document.getElementById('volume-slider');
500
+ const speedSlider = document.getElementById('speed-slider');
501
+ const speedInput = document.getElementById('speed-input');
502
+ const tempoInput = document.getElementById('tempo-input');
503
+ const settingsToggle = document.getElementById('settings-toggle');
504
+ const settingsPopup = document.getElementById('settings-popup');
505
+ const rangeStart = document.getElementById('range-start');
506
+ const rangeEnd = document.getElementById('range-end');
507
+ const applyRange = document.getElementById('apply-range');
508
+ const addWidgetBtn = document.getElementById('add-widget-btn');
509
+ const saveLayoutBtn = document.getElementById('save-layout-btn');
510
+ const presetArea = document.getElementById('preset-area');
511
+ const markerStart = document.getElementById('marker-start');
512
+ const markerEnd = document.getElementById('marker-end');
513
+ const markerStartDrag = document.getElementById('marker-start-drag');
514
+ const markerEndDrag = document.getElementById('marker-end-drag');
515
+ const hoverTime = document.getElementById('hover-time');
516
+ const progressArea = document.getElementById('progress-area');
517
+
518
+ // ----- 合成設定 (プリセット) -----
519
+ const presets = {
520
+ 'テノール���習用': { a: 0.5, s: 0.5, t: 1.0, p: 0.9 },
521
+ 'ソプラノ練習用': { a: 0.5, s: 1.0, t: 0.5, p: 0.9 },
522
+ 'アルト練習用': { a: 1.0, s: 0.5, t: 0.5, p: 0.9 },
523
+ 'ピアノ練習用': { a: 0.5, s: 0.5, t: 0.5, p: 1.0 },
524
+ 'カスタム': { a: 0.5, s: 0.5, t: 0.5, p: 0.5 },
525
+ };
526
+ let currentPresetName = 'カスタム';
527
+
528
+ // ----- 要素タイプ -----
529
+ const WIDGET_TYPES = {
530
+ VIDEO: 'video',
531
+ MIDI: 'midi',
532
+ MXML: 'mxml',
533
+ OTHER_VIDEO: 'other_video',
534
+ };
535
+
536
+ // ----- ウィジェット管理 -----
537
+ function createWidget(type, opts = {}) {
538
+ const id = state.nextId++;
539
+ const el = document.createElement('div');
540
+ el.className = 'widget';
541
+ el.dataset.id = id;
542
+ el.style.left = (opts.x ?? 20) + 'px';
543
+ el.style.top = (opts.y ?? 20) + 'px';
544
+ el.style.width = (opts.w ?? 280) + 'px';
545
+ el.style.height = (opts.h ?? 200) + 'px';
546
+
547
+ // タイトルバー
548
+ const titleBar = document.createElement('div');
549
+ titleBar.className = 'title-bar';
550
+ const titleSpan = document.createElement('span');
551
+ titleSpan.className = 'wtitle';
552
+ const typeMap = {
553
+ [WIDGET_TYPES.VIDEO]: '歌唱映像',
554
+ [WIDGET_TYPES.MIDI]: 'MIDIピアノ',
555
+ [WIDGET_TYPES.MXML]: 'MusicXML',
556
+ [WIDGET_TYPES.OTHER_VIDEO]: 'その他動画',
557
+ };
558
+ titleSpan.innerHTML = `<span class="type-badge">${typeMap[type] || type}</span> <span class="wname">${opts.name || ''}</span>`;
559
+ const ctrl = document.createElement('span');
560
+ ctrl.className = 'wctrl';
561
+ // 設定ボタン (MIDI/MXML用)
562
+ if (type === WIDGET_TYPES.MIDI || type === WIDGET_TYPES.MXML) {
563
+ const setBtn = document.createElement('button');
564
+ setBtn.textContent = '⚙';
565
+ setBtn.title = '設定';
566
+ setBtn.onclick = (e) => { e.stopPropagation(); openWidgetSettings(id); };
567
+ ctrl.appendChild(setBtn);
568
+ }
569
+ // 閉じる
570
+ const closeBtn = document.createElement('button');
571
+ closeBtn.textContent = '✕';
572
+ closeBtn.onclick = (e) => { e.stopPropagation(); removeWidget(id); };
573
+ ctrl.appendChild(closeBtn);
574
+ titleBar.append(titleSpan, ctrl);
575
+ el.appendChild(titleBar);
576
+
577
+ // コンテンツ
578
+ const content = document.createElement('div');
579
+ content.className = 'content';
580
+ el.appendChild(content);
581
+
582
+ // リサイズハンドル
583
+ const handle = document.createElement('div');
584
+ handle.className = 'resize-handle';
585
+ el.appendChild(handle);
586
+
587
+ stage.appendChild(el);
588
+
589
+ // ドラッグ移動
590
+ let drag = false, ox, oy, sx, sy;
591
+ titleBar.addEventListener('mousedown', (e) => {
592
+ if (e.target.closest('button')) return;
593
+ drag = true;
594
+ const rect = el.getBoundingClientRect();
595
+ ox = e.clientX - rect.left;
596
+ oy = e.clientY - rect.top;
597
+ sx = parseFloat(el.style.left);
598
+ sy = parseFloat(el.style.top);
599
+ document.addEventListener('mousemove', onMove);
600
+ document.addEventListener('mouseup', onUp);
601
+ e.preventDefault();
602
+ });
603
+ function onMove(e) {
604
+ if (!drag) return;
605
+ const px = e.clientX - ox;
606
+ const py = e.clientY - oy;
607
+ el.style.left = Math.max(0, px) + 'px';
608
+ el.style.top = Math.max(0, py) + 'px';
609
+ // ステージ内に制限
610
+ const stageRect = stage.getBoundingClientRect();
611
+ const elRect = el.getBoundingClientRect();
612
+ if (elRect.right > stageRect.right) el.style.left = (stageRect.width - el.offsetWidth) + 'px';
613
+ if (elRect.bottom > stageRect.bottom) el.style.top = (stageRect.height - el.offsetHeight) + 'px';
614
+ }
615
+ function onUp() { drag = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
616
+
617
+ // リサイズ (右下)
618
+ let resize = false;
619
+ handle.addEventListener('mousedown', (e) => {
620
+ resize = true;
621
+ const rect = el.getBoundingClientRect();
622
+ const sx = rect.width, sy = rect.height;
623
+ const ox = e.clientX, oy = e.clientY;
624
+ document.addEventListener('mousemove', onResize);
625
+ document.addEventListener('mouseup', onResizeUp);
626
+ e.preventDefault();
627
+ });
628
+ function onResize(e) {
629
+ if (!resize) return;
630
+ const dw = e.clientX - ox, dh = e.clientY - oy;
631
+ el.style.width = Math.max(120, sx + dw) + 'px';
632
+ el.style.height = Math.max(80, sy + dh) + 'px';
633
+ // コンテンツにリサイズ通知
634
+ const widget = state.widgets.find(w => w.id === id);
635
+ if (widget) onWidgetResize(widget);
636
+ }
637
+ function onResizeUp() { resize = false; document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', onResizeUp); }
638
+
639
+ // コンテンツ生成
640
+ const widgetData = { id, type, el, content, titleBar, name: opts.name || '' };
641
+ state.widgets.push(widgetData);
642
+
643
+ // タイプ別初期化
644
+ initWidgetContent(widgetData, opts);
645
+
646
+ // コンテキストメニュー
647
+ el.addEventListener('contextmenu', (e) => {
648
+ e.preventDefault();
649
+ showContextMenu(e.clientX, e.clientY, id);
650
+ });
651
+
652
+ return widgetData;
653
+ }
654
+
655
+ function initWidgetContent(widget, opts) {
656
+ const { id, type, content } = widget;
657
+ content.innerHTML = '';
658
+ const data = { src: opts.src || '', name: opts.name || '' };
659
+
660
+ if (type === WIDGET_TYPES.VIDEO || type === WIDGET_TYPES.OTHER_VIDEO) {
661
+ const video = document.createElement('video');
662
+ video.src = data.src || (type === WIDGET_TYPES.VIDEO ? 'sample_vocal.mp4' : 'sample_other.mp4');
663
+ video.controls = false;
664
+ video.muted = true;
665
+ video.loop = false;
666
+ video.playsInline = true;
667
+ video.style.objectFit = 'contain';
668
+ content.appendChild(video);
669
+ // ドロップダウンでソース変更 (右上の設定っぽく)
670
+ const sel = document.createElement('select');
671
+ sel.style.position = 'absolute';
672
+ sel.style.top = '4px';
673
+ sel.style.right = '30px';
674
+ sel.style.background = '#1a2538';
675
+ sel.style.color = '#fff';
676
+ sel.style.border = '1px solid #2a3a5f';
677
+ sel.style.borderRadius = '4px';
678
+ sel.style.fontSize = '11px';
679
+ sel.style.zIndex = '5';
680
+ const sources = type === WIDGET_TYPES.VIDEO
681
+ ? ['sample_vocal.mp4', 'vocal2.mp4', 'vocal3.mp4']
682
+ : ['sample_other.mp4', 'other2.mp4', 'other3.mp4'];
683
+ sources.forEach(s => {
684
+ const opt = document.createElement('option');
685
+ opt.value = s; opt.textContent = s;
686
+ if (s === data.src) opt.selected = true;
687
+ sel.appendChild(opt);
688
+ });
689
+ sel.addEventListener('change', () => {
690
+ video.src = sel.value;
691
+ video.load();
692
+ widget.data = { ...widget.data, src: sel.value };
693
+ });
694
+ content.appendChild(sel);
695
+ widget.data = { src: data.src, type: type };
696
+ widget.videoElement = video;
697
+ }
698
+ else if (type === WIDGET_TYPES.MIDI) {
699
+ const container = document.createElement('div');
700
+ container.className = 'midi-container';
701
+ content.appendChild(container);
702
+ // MIDIプレイヤー起動 (midiplay.js)
703
+ const midiPlayer = new MidiPlayer(container, opts.midiSrc || 'sample.mid');
704
+ widget.midiPlayer = midiPlayer;
705
+ widget.data = { src: opts.midiSrc || 'sample.mid' };
706
+ // 設定用
707
+ widget._settings = {
708
+ parts: midiPlayer.getParts ? midiPlayer.getParts() : []
709
+ };
710
+ }
711
+ else if (type === WIDGET_TYPES.MXML) {
712
+ const container = document.createElement('div');
713
+ container.className = 'mxml-container';
714
+ content.appendChild(container);
715
+ const mxmlPlayer = new MxmlPlayer(container, opts.mxmlSrc || 'sample.musicxml');
716
+ widget.mxmlPlayer = mxmlPlayer;
717
+ widget.data = { src: opts.mxmlSrc || 'sample.musicxml' };
718
+ widget._settings = {};
719
+ }
720
+
721
+ // 初期サイズ調整
722
+ setTimeout(() => onWidgetResize(widget), 50);
723
+ }
724
+
725
+ function onWidgetResize(widget) {
726
+ // 子要素にリサイズ通知
727
+ if (widget.midiPlayer && widget.midiPlayer.resize) widget.midiPlayer.resize();
728
+ if (widget.mxmlPlayer && widget.mxmlPlayer.resize) widget.mxmlPlayer.resize();
729
+ if (widget.videoElement) {
730
+ // 何もしない
731
+ }
732
+ }
733
+
734
+ function removeWidget(id) {
735
+ const idx = state.widgets.findIndex(w => w.id === id);
736
+ if (idx === -1) return;
737
+ const w = state.widgets[idx];
738
+ if (w.midiPlayer && w.midiPlayer.destroy) w.midiPlayer.destroy();
739
+ if (w.mxmlPlayer && w.mxmlPlayer.destroy) w.mxmlPlayer.destroy();
740
+ w.el.remove();
741
+ state.widgets.splice(idx, 1);
742
+ }
743
+
744
+ // ----- コンテキストメニュー -----
745
+ let ctxMenu = null;
746
+ function showContextMenu(x, y, widgetId) {
747
+ if (ctxMenu) ctxMenu.remove();
748
+ ctxMenu = document.createElement('div');
749
+ ctxMenu.className = 'ctx-menu';
750
+ ctxMenu.style.left = x + 'px';
751
+ ctxMenu.style.top = y + 'px';
752
+ const items = [
753
+ { icon: '📹', label: '歌唱映像を追加', action: () => addWidget(WIDGET_TYPES.VIDEO) },
754
+ { icon: '🎹', label: 'MIDIピアノを追加', action: () => addWidget(WIDGET_TYPES.MIDI) },
755
+ { icon: '🎼', label: 'MusicXMLを追加', action: () => addWidget(WIDGET_TYPES.MXML) },
756
+ { icon: '🎬', label: 'その他動画を追加', action: () => addWidget(WIDGET_TYPES.OTHER_VIDEO) },
757
+ { icon: '✕', label: 'この要素を削除', action: () => removeWidget(widgetId) },
758
+ ];
759
+ items.forEach(it => {
760
+ const div = document.createElement('div');
761
+ div.className = 'item';
762
+ div.innerHTML = `<span class="icon">${it.icon}</span> ${it.label}`;
763
+ div.onclick = () => { it.action(); ctxMenu.remove(); ctxMenu = null; };
764
+ ctxMenu.appendChild(div);
765
+ });
766
+ document.body.appendChild(ctxMenu);
767
+ const close = () => { if (ctxMenu) { ctxMenu.remove(); ctxMenu = null; } };
768
+ setTimeout(() => document.addEventListener('click', close, { once: true }), 10);
769
+ }
770
+
771
+ // ----- ウィジェット追加 -----
772
+ function addWidget(type, opts = {}) {
773
+ const count = state.widgets.length;
774
+ const x = 20 + (count % 3) * 40;
775
+ const y = 20 + Math.floor(count / 3) * 40;
776
+ const w = type === WIDGET_TYPES.MIDI || type === WIDGET_TYPES.MXML ? 360 : 280;
777
+ const h = type === WIDGET_TYPES.MIDI || type === WIDGET_TYPES.MXML ? 260 : 200;
778
+ return createWidget(type, { x, y, w, h, ...opts });
779
+ }
780
+
781
+ // ----- 設定ポップアップ (ウィジェット個別) -----
782
+ function openWidgetSettings(id) {
783
+ const w = state.widgets.find(w => w.id === id);
784
+ if (!w) return;
785
+ // MIDI設定
786
+ if (w.type === WIDGET_TYPES.MIDI && w.midiPlayer) {
787
+ // 簡易的にパート表���切り替えのUIをポップアップ表示
788
+ const parts = w.midiPlayer.getParts ? w.midiPlayer.getParts() : [];
789
+ if (!parts.length) return;
790
+ const popup = document.createElement('div');
791
+ popup.style.cssText = 'position:fixed; background:#141c2b; border:1px solid #2a3a5f; border-radius:10px; padding:16px; z-index:999; min-width:240px; max-height:300px; overflow-y:auto;';
792
+ popup.style.left = '50%'; popup.style.top = '50%';
793
+ popup.style.transform = 'translate(-50%, -50%)';
794
+ popup.innerHTML = `<h4 style="margin:0 0 10px;color:#64ffda;">MIDIパート設定</h4>`;
795
+ parts.forEach((p, i) => {
796
+ const row = document.createElement('div');
797
+ row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '8px'; row.style.marginBottom = '4px';
798
+ const cb = document.createElement('input');
799
+ cb.type = 'checkbox';
800
+ cb.checked = p.visible !== false;
801
+ cb.onchange = () => { p.visible = cb.checked; if (w.midiPlayer.updatePart) w.midiPlayer.updatePart(i, { visible: cb.checked }); };
802
+ const lbl = document.createElement('span');
803
+ lbl.textContent = p.name || `パート${i+1}`;
804
+ lbl.style.fontSize = '13px';
805
+ const vol = document.createElement('input');
806
+ vol.type = 'range';
807
+ vol.min = -60; vol.max = 12; vol.step = 1;
808
+ vol.value = p.volumeDb || -12;
809
+ vol.style.width = '80px';
810
+ vol.oninput = () => { p.volumeDb = Number(vol.value); if (w.midiPlayer.updatePart) w.midiPlayer.updatePart(i, { volumeDb: Number(vol.value) }); };
811
+ const vlabel = document.createElement('span');
812
+ vlabel.textContent = vol.value + 'dB';
813
+ vlabel.style.fontSize = '11px'; vlabel.style.minWidth = '40px';
814
+ vol.oninput = () => { vlabel.textContent = vol.value + 'dB'; };
815
+ row.append(cb, lbl, vol, vlabel);
816
+ popup.appendChild(row);
817
+ });
818
+ const close = document.createElement('button');
819
+ close.textContent = '閉じる';
820
+ close.style.cssText = 'margin-top:10px; background:#1e2a47; border:1px solid #64ffda66; color:#fff; padding:4px 16px; border-radius:20px; cursor:pointer;';
821
+ close.onclick = () => popup.remove();
822
+ popup.appendChild(close);
823
+ document.body.appendChild(popup);
824
+ popup.addEventListener('click', (e) => e.stopPropagation());
825
+ // クリックで閉じる
826
+ setTimeout(() => document.addEventListener('click', () => popup.remove(), { once: true }), 10);
827
+ }
828
+ // MXML設定 (簡易)
829
+ if (w.type === WIDGET_TYPES.MXML && w.mxmlPlayer) {
830
+ alert('MusicXMLの設定は現在簡易対応です。\n再生速度はメインの速度と連動します。');
831
+ }
832
+ }
833
+
834
+ // ----- メディア同期 (音声は合成済み音声を想定) -----
835
+ let masterAudio = null; // 合成音声 (AUDIO_SRC)
836
+ let masterDuration = 0;
837
+ let playStartTime = 0;
838
+ let isDraggingMarker = false;
839
+
840
+ function loadMasterAudio(src) {
841
+ if (masterAudio) { masterAudio.pause(); masterAudio.src = ''; }
842
+ masterAudio = new Audio(src);
843
+ masterAudio.preload = 'auto';
844
+ masterAudio.volume = state.volume;
845
+ masterAudio.addEventListener('loadedmetadata', () => {
846
+ masterDuration = masterAudio.duration;
847
+ state.duration = masterDuration;
848
+ rangeEnd.value = masterDuration;
849
+ state.endTime = masterDuration;
850
+ updateMarkers();
851
+ updateTimeDisplay();
852
+ });
853
+ masterAudio.addEventListener('timeupdate', () => {
854
+ if (!state.isPlaying) return;
855
+ state.currentTime = masterAudio.currentTime;
856
+ // 範囲チェック
857
+ if (state.endTime > 0 && state.currentTime >= state.endTime) {
858
+ if (state.loopEnabled) {
859
+ seekTo(state.startTime);
860
+ if (state.isPlaying) masterAudio.play();
861
+ } else {
862
+ pauseMedia();
863
+ state.currentTime = state.endTime;
864
+ masterAudio.currentTime = state.endTime;
865
+ updateUI();
866
+ }
867
+ }
868
+ updateUI();
869
+ });
870
+ masterAudio.addEventListener('ended', () => {
871
+ if (state.loopEnabled) {
872
+ seekTo(state.startTime);
873
+ masterAudio.play();
874
+ } else {
875
+ pauseMedia();
876
+ state.currentTime = state.duration;
877
+ updateUI();
878
+ }
879
+ });
880
+ }
881
+
882
+ function playMedia() {
883
+ if (!masterAudio) return;
884
+ if (state.isPlaying) return;
885
+ if (state.currentTime >= state.endTime && state.endTime > 0) {
886
+ seekTo(state.startTime);
887
+ }
888
+ masterAudio.play();
889
+ state.isPlaying = true;
890
+ playToggle.textContent = '⏸';
891
+ // 子要素も再生
892
+ state.widgets.forEach(w => {
893
+ if (w.videoElement && !w.videoElement.paused) {
894
+ try { w.videoElement.play(); } catch(e) {}
895
+ }
896
+ if (w.midiPlayer && w.midiPlayer.play) w.midiPlayer.play();
897
+ if (w.mxmlPlayer && w.mxmlPlayer.play) w.mxmlPlayer.play();
898
+ });
899
+ }
900
+
901
+ function pauseMedia() {
902
+ if (masterAudio) masterAudio.pause();
903
+ state.isPlaying = false;
904
+ playToggle.textContent = '▶';
905
+ state.widgets.forEach(w => {
906
+ if (w.videoElement) try { w.videoElement.pause(); } catch(e) {}
907
+ if (w.midiPlayer && w.midiPlayer.pause) w.midiPlayer.pause();
908
+ if (w.mxmlPlayer && w.mxmlPlayer.pause) w.mxmlPlayer.pause();
909
+ });
910
+ }
911
+
912
+ function seekTo(time) {
913
+ const t = Math.min(Math.max(time, 0), state.duration);
914
+ state.currentTime = t;
915
+ if (masterAudio) masterAudio.currentTime = t;
916
+ state.widgets.forEach(w => {
917
+ if (w.videoElement) w.videoElement.currentTime = t;
918
+ if (w.midiPlayer && w.midiPlayer.seek) w.midiPlayer.seek(t);
919
+ if (w.mxmlPlayer && w.mxmlPlayer.seek) w.mxmlPlayer.seek(t);
920
+ });
921
+ updateUI();
922
+ }
923
+
924
+ function updateUI() {
925
+ const pct = state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0;
926
+ progressFill.style.width = pct + '%';
927
+ updateTimeDisplay();
928
+ // マーカー位置
929
+ updateMarkers();
930
+ }
931
+
932
+ function updateTimeDisplay() {
933
+ const cur = formatTime(state.currentTime);
934
+ const dur = formatTime(state.duration);
935
+ timeDisplay.textContent = `${cur} / ${dur}`;
936
+ }
937
+
938
+ function formatTime(sec) {
939
+ if (!sec || isNaN(sec)) return '00:00.00';
940
+ const m = Math.floor(sec / 60);
941
+ const s = (sec % 60).toFixed(2);
942
+ return `${String(m).padStart(2,'0')}:${String(s).padStart(5,'0')}`;
943
+ }
944
+
945
+ function updateMarkers() {
946
+ const dur = state.duration || 1;
947
+ const startPct = (state.startTime / dur) * 100;
948
+ const endPct = (state.endTime / dur) * 100;
949
+ markerStart.style.left = startPct + '%';
950
+ markerEnd.style.left = endPct + '%';
951
+ markerStartDrag.style.left = startPct + '%';
952
+ markerEndDrag.style.left = endPct + '%';
953
+ }
954
+
955
+ // ----- マーカードラッグ -----
956
+ function initMarkerDrag() {
957
+ let dragType = null;
958
+ const onStart = (e, type) => {
959
+ e.preventDefault();
960
+ isDraggingMarker = true;
961
+ dragType = type;
962
+ const rect = progressArea.getBoundingClientRect();
963
+ const onMove = (ev) => {
964
+ if (!isDraggingMarker) return;
965
+ const x = (ev.clientX - rect.left) / rect.width;
966
+ const pct = Math.min(1, Math.max(0, x));
967
+ const time = pct * state.duration;
968
+ if (dragType === 'start') {
969
+ if (time < state.endTime - 0.1) {
970
+ state.startTime = time;
971
+ rangeStart.value = time;
972
+ }
973
+ } else {
974
+ if (time > state.startTime + 0.1) {
975
+ state.endTime = time;
976
+ rangeEnd.value = time;
977
+ }
978
+ }
979
+ updateMarkers();
980
+ };
981
+ const onUp = () => {
982
+ isDraggingMarker = false;
983
+ document.removeEventListener('mousemove', onMove);
984
+ document.removeEventListener('mouseup', onUp);
985
+ };
986
+ document.addEventListener('mousemove', onMove);
987
+ document.addEventListener('mouseup', onUp);
988
+ };
989
+ markerStartDrag.addEventListener('mousedown', (e) => onStart(e, 'start'));
990
+ markerEndDrag.addEventListener('mousedown', (e) => onStart(e, 'end'));
991
+ }
992
+
993
+ // ----- プログレスバークリックシーク -----
994
+ progressArea.addEventListener('click', (e) => {
995
+ if (isDraggingMarker) return;
996
+ const rect = progressArea.getBoundingClientRect();
997
+ const pct = (e.clientX - rect.left) / rect.width;
998
+ const time = pct * state.duration;
999
+ if (time < state.startTime) seekTo(state.startTime);
1000
+ else if (time > state.endTime && state.endTime > 0) seekTo(state.endTime);
1001
+ else seekTo(time);
1002
+ if (state.isPlaying) {
1003
+ masterAudio.play();
1004
+ }
1005
+ });
1006
+
1007
+ // ホバー表示
1008
+ progressArea.addEventListener('mousemove', (e) => {
1009
+ const rect = progressArea.getBoundingClientRect();
1010
+ const pct = (e.clientX - rect.left) / rect.width;
1011
+ if (pct >= 0 && pct <= 1) {
1012
+ const time = pct * state.duration;
1013
+ hoverTime.textContent = formatTime(time);
1014
+ hoverTime.style.left = (e.clientX - rect.left) + 'px';
1015
+ hoverTime.style.display = 'block';
1016
+ }
1017
+ });
1018
+ progressArea.addEventListener('mouseleave', () => { hoverTime.style.display = 'none'; });
1019
+
1020
+ // ----- 設定ポップアップ -----
1021
+ settingsToggle.addEventListener('click', () => {
1022
+ settingsPopup.classList.toggle('open');
1023
+ });
1024
+ document.addEventListener('click', (e) => {
1025
+ if (!settingsPopup.contains(e.target) && e.target !== settingsToggle) {
1026
+ settingsPopup.classList.remove('open');
1027
+ }
1028
+ });
1029
+
1030
+ // 速度/テンポ連携
1031
+ function updateSpeedFromSlider() {
1032
+ const val = parseFloat(speedSlider.value);
1033
+ speedInput.value = val;
1034
+ state.speed = val;
1035
+ // テンポ = 84 * val
1036
+ const tempo = Math.round(84 * val);
1037
+ tempoInput.value = tempo;
1038
+ applySpeedToAll(val);
1039
+ }
1040
+ function updateSpeedFromInput() {
1041
+ let val = parseFloat(speedInput.value);
1042
+ if (isNaN(val) || val < 0.1) val = 0.1;
1043
+ if (val > 4.0) val = 4.0;
1044
+ speedInput.value = val;
1045
+ speedSlider.value = val;
1046
+ state.speed = val;
1047
+ const tempo = Math.round(84 * val);
1048
+ tempoInput.value = tempo;
1049
+ applySpeedToAll(val);
1050
+ }
1051
+ function updateTempoFromInput() {
1052
+ let tempo = parseFloat(tempoInput.value);
1053
+ if (isNaN(tempo) || tempo < 20) tempo = 20;
1054
+ if (tempo > 300) tempo = 300;
1055
+ tempoInput.value = tempo;
1056
+ const speed = tempo / 84;
1057
+ speedSlider.value = speed;
1058
+ speedInput.value = speed.toFixed(2);
1059
+ state.speed = speed;
1060
+ applySpeedToAll(speed);
1061
+ }
1062
+
1063
+ function applySpeedToAll(speed) {
1064
+ // 全要素に速度適用
1065
+ state.widgets.forEach(w => {
1066
+ if (w.videoElement) w.videoElement.playbackRate = speed;
1067
+ if (w.midiPlayer && w.midiPlayer.setSpeed) w.midiPlayer.setSpeed(speed);
1068
+ if (w.mxmlPlayer && w.mxmlPlayer.setSpeed) w.mxmlPlayer.setSpeed(speed);
1069
+ });
1070
+ if (masterAudio) masterAudio.playbackRate = speed;
1071
+ }
1072
+
1073
+ speedSlider.addEventListener('input', updateSpeedFromSlider);
1074
+ speedInput.addEventListener('change', updateSpeedFromInput);
1075
+ tempoInput.addEventListener('change', updateTempoFromInput);
1076
+
1077
+ // 範囲適用
1078
+ applyRange.addEventListener('click', () => {
1079
+ const s = parseFloat(rangeStart.value) || 0;
1080
+ const e = parseFloat(rangeEnd.value) || state.duration;
1081
+ if (s < e && e <= state.duration) {
1082
+ state.startTime = s;
1083
+ state.endTime = e;
1084
+ if (state.currentTime < s) seekTo(s);
1085
+ else if (state.currentTime > e) seekTo(e);
1086
+ updateMarkers();
1087
+ updateUI();
1088
+ } else {
1089
+ alert('開始 < 終了 かつ 終了 <= 全体長さ にしてください');
1090
+ }
1091
+ });
1092
+
1093
+ // プリセット
1094
+ function applyPreset(name) {
1095
+ const p = presets[name];
1096
+ if (!p) return;
1097
+ state.mix = { ...p };
1098
+ currentPresetName = name;
1099
+ // UI更新 (スライダーなど)
1100
+ document.querySelectorAll('.preset-item').forEach(el => el.style.borderColor = '#2a3a5f');
1101
+ const items = document.querySelectorAll('.preset-item');
1102
+ items.forEach(el => { if (el.dataset.name === name) el.style.borderColor = '#64ffda'; });
1103
+ }
1104
+
1105
+ function renderPresets() {
1106
+ presetArea.innerHTML = '<span style="font-size:13px; color:#64ffda;">プリセット:</span>';
1107
+ Object.keys(presets).forEach(name => {
1108
+ const div = document.createElement('span');
1109
+ div.className = 'preset-item';
1110
+ div.dataset.name = name;
1111
+ div.innerHTML = `${name} <span class="del" data-name="${name}">×</span>`;
1112
+ div.addEventListener('click', (e) => {
1113
+ if (e.target.classList.contains('del')) return;
1114
+ applyPreset(name);
1115
+ });
1116
+ div.querySelector('.del').addEventListener('click', (e) => {
1117
+ e.stopPropagation();
1118
+ if (name === 'カスタム') return;
1119
+ delete presets[name];
1120
+ renderPresets();
1121
+ });
1122
+ if (name === currentPresetName) div.style.borderColor = '#64ffda';
1123
+ presetArea.appendChild(div);
1124
+ });
1125
+ // カスタムがなければ追加
1126
+ if (!presets['カスタム']) {
1127
+ presets['カスタム'] = { a: 0.5, s: 0.5, t: 0.5, p: 0.5 };
1128
+ renderPresets();
1129
+ }
1130
+ }
1131
+
1132
+ // ----- レイアウト保存/読み込み -----
1133
+ function saveLayout() {
1134
+ const name = prompt('レイアウト名を入力', 'my_layout');
1135
+ if (!name) return;
1136
+ const data = {
1137
+ name,
1138
+ widgets: state.widgets.map(w => ({
1139
+ id: w.id,
1140
+ type: w.type,
1141
+ x: parseFloat(w.el.style.left),
1142
+ y: parseFloat(w.el.style.top),
1143
+ w: parseFloat(w.el.style.width),
1144
+ h: parseFloat(w.el.style.height),
1145
+ data: w.data || {},
1146
+ })),
1147
+ };
1148
+ // プリセットに保存
1149
+ const layouts = JSON.parse(localStorage.getItem('layouts') || '{}');
1150
+ layouts[name] = data;
1151
+ localStorage.setItem('layouts', JSON.stringify(layouts));
1152
+ // プリセットとして表示
1153
+ presets[name] = { a: 0.5, s: 0.5, t: 0.5, p: 0.5 };
1154
+ renderPresets();
1155
+ alert('レイアウトを保存しました');
1156
+ }
1157
+
1158
+ function loadLayout(name) {
1159
+ const layouts = JSON.parse(localStorage.getItem('layouts') || '{}');
1160
+ const data = layouts[name];
1161
+ if (!data) return;
1162
+ // 既存ウィジェット削除
1163
+ state.widgets.forEach(w => { w.el.remove(); if (w.midiPlayer && w.midiPlayer.destroy) w.midiPlayer.destroy(); if (w.mxmlPlayer && w.mxmlPlayer.destroy) w.mxmlPlayer.destroy(); });
1164
+ state.widgets = [];
1165
+ data.widgets.forEach(w => {
1166
+ const widget = createWidget(w.type, {
1167
+ x: w.x, y: w.y, w: w.w, h: w.h,
1168
+ src: w.data?.src || '',
1169
+ name: w.data?.name || '',
1170
+ });
1171
+ if (w.data) widget.data = { ...w.data };
1172
+ // 動画ソース再設定
1173
+ if (w.type === WIDGET_TYPES.VIDEO || w.type === WIDGET_TYPES.OTHER_VIDEO) {
1174
+ const video = widget.content.querySelector('video');
1175
+ if (video && w.data?.src) {
1176
+ video.src = w.data.src;
1177
+ video.load();
1178
+ }
1179
+ }
1180
+ // MIDI/MXML再読み込み
1181
+ if (w.type === WIDGET_TYPES.MIDI && w.data?.src && widget.midiPlayer) {
1182
+ widget.midiPlayer.load(w.data.src);
1183
+ }
1184
+ if (w.type === WIDGET_TYPES.MXML && w.data?.src && widget.mxmlPlayer) {
1185
+ widget.mxmlPlayer.load(w.data.src);
1186
+ }
1187
+ });
1188
+ // マスターミュージック再読み込み
1189
+ if (data.masterAudio) {
1190
+ loadMasterAudio(data.masterAudio);
1191
+ }
1192
+ }
1193
+
1194
+ // ----- 初期化 -----
1195
+ function init() {
1196
+ // デフォルトウィジェット
1197
+ addWidget(WIDGET_TYPES.VIDEO, { x: 20, y: 20, w: 280, h: 200, src: 'sample_vocal.mp4' });
1198
+ addWidget(WIDGET_TYPES.MIDI, { x: 320, y: 20, w: 360, h: 240, src: 'sample.mid' });
1199
+ addWidget(WIDGET_TYPES.MXML, { x: 700, y: 20, w: 360, h: 240, src: 'sample.musicxml' });
1200
+
1201
+ // マスター音声 (合成済み)
1202
+ loadMasterAudio('sample_master.mp3');
1203
+
1204
+ // コントロール
1205
+ playToggle.addEventListener('click', () => {
1206
+ if (state.isPlaying) pauseMedia();
1207
+ else playMedia();
1208
+ });
1209
+ resetBtn.addEventListener('click', () => {
1210
+ seekTo(state.startTime);
1211
+ if (state.isPlaying) masterAudio.play();
1212
+ });
1213
+ volumeSlider.addEventListener('input', () => {
1214
+ state.volume = parseFloat(volumeSlider.value);
1215
+ if (masterAudio) masterAudio.volume = state.volume;
1216
+ });
1217
+
1218
+ // 追加ボタン
1219
+ addWidgetBtn.addEventListener('click', () => {
1220
+ // サブメニュー表示
1221
+ const menu = document.createElement('div');
1222
+ menu.className = 'ctx-menu';
1223
+ menu.style.left = '50%';
1224
+ menu.style.top = '50%';
1225
+ menu.style.transform = 'translate(-50%, -50%)';
1226
+ const types = [
1227
+ { icon: '📹', label: '歌唱映像', type: WIDGET_TYPES.VIDEO },
1228
+ { icon: '🎹', label: 'MIDIピアノ', type: WIDGET_TYPES.MIDI },
1229
+ { icon: '🎼', label: 'MusicXML', type: WIDGET_TYPES.MXML },
1230
+ { icon: '🎬', label: 'その他動画', type: WIDGET_TYPES.OTHER_VIDEO },
1231
+ ];
1232
+ types.forEach(t => {
1233
+ const div = document.createElement('div');
1234
+ div.className = 'item';
1235
+ div.innerHTML = `<span class="icon">${t.icon}</span> ${t.label}`;
1236
+ div.onclick = () => { addWidget(t.type); menu.remove(); };
1237
+ menu.appendChild(div);
1238
+ });
1239
+ document.body.appendChild(menu);
1240
+ setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
1241
+ });
1242
+
1243
+ // 保存
1244
+ saveLayoutBtn.addEventListener('click', saveLayout);
1245
+
1246
+ // プリセットレンダリング
1247
+ renderPresets();
1248
+
1249
+ // マーカードラッグ
1250
+ initMarkerDrag();
1251
+
1252
+ // ステージコンテキストメニュー
1253
+ stage.addEventListener('contextmenu', (e) => {
1254
+ e.preventDefault();
1255
+ if (e.target === stage) {
1256
+ showContextMenu(e.clientX, e.clientY, null);
1257
+ }
1258
+ });
1259
+
1260
+ // デフォルト範囲
1261
+ state.startTime = 0;
1262
+ state.endTime = masterDuration || 60;
1263
+ rangeStart.value = 0;
1264
+ rangeEnd.value = state.endTime;
1265
+ updateMarkers();
1266
+ updateUI();
1267
+
1268
+ // 初期速度
1269
+ speedSlider.value = 1.0;
1270
+ speedInput.value = 1.0;
1271
+ tempoInput.value = 84;
1272
+ }
1273
+
1274
+ // ウィンドウロード
1275
+ window.addEventListener('load', init);
1276
+
1277
+ // グローバル公開
1278
+ window.__app = { state, addWidget, removeWidget, seekTo, playMedia, pauseMedia, loadLayout, saveLayout };
1279
+
1280
+ // レイアウト読み込み用 (デバッグ)
1281
+ console.log('🔧 レイアウト保存/読み込み: __app.loadLayout("name")');
1282
+ </script>
1283
+ </body>
1284
+ </html>