| <!DOCTYPE html> |
| <html lang="ja"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <script src="/eruda.js"></script> |
| <script> |
| eruda.init(); |
| </script> |
| <script src="/draggabilly.pkgd.min.js"></script> |
| <script src="/midipane.js"></script> |
| <script src="/musicxmlpane.js"></script> |
| <script src="/tools-bg-sw-metronome.js"></script> |
| <script src="/Midi.js"></script> |
| <script src="/verovio-toolkit-wasm.js" defer></script> |
|
|
| <link rel="icon" type="image/svg+xml" href='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgbGFuZz0iamEiPjxkZWZzPiA8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQxIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjAlIiB5Mj0iMTAwJSI+IDxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiMzNDc0ZWIiIC8+IDxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzM0YThlYiIgLz4gPC9saW5lYXJHcmFkaWVudD4gPC9kZWZzPiA8IS0tIOWQhOS6uuWbvuWkjeS9nOWumuS7tuWkjeaWsOWIl+OBjOaWsOWIl+OBq+OBhOOBq+OBvuOBq+OCi+OCieWbvuWkjeOBjOOCjOOCieOCk+ODq+ODvOODrOODs+ODg+OCiwotLT4gPHBvbHlnb24gcG9pbnRzPSI1MCw1MCA1MCwxNTAgMTUwLDEwMCIgZmlsbD0idXJsKCNncmFkMSkiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIgLz48L3N2Zz4=' /> |
|
|
| <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgbGFuZz0iamEiPjxkZWZzPiA8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQxIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjAlIiB5Mj0iMTAwJSI+IDxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiMzNDc0ZWIiIC8+IDxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzM0YThlYiIgLz4gPC9saW5lYXJHcmFkaWVudD4gPC9kZWZzPiA8IS0tIOWQhOS6uuWbvuWkjeS9nOWumuS7tuWkjeaWsOWIl+OBjOaWsOWIl+OBq+OBhOOBq+OBvuOBq+OCi+OCieWbvuWkjeOBjOOCjOOCieOCk+ODq+ODvOODrOODs+ODg+OCiwotLT4gPHBvbHlnb24gcG9pbnRzPSI1MCw1MCA1MCwxNTAgMTUwLDEwMCIgZmlsbD0idXJsKCNncmFkMSkiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIgLz48L3N2Zz4="> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <style> |
| @font-face { |
| font-family: "M PLUS Rounded 1c"; |
| src: url("/fonts/MPLUSRounded1c-Medium.ttf") format("truetype"); |
| font-weight: 500; |
| font-style: normal; |
| } |
| body { |
| font-family: "M PLUS Rounded 1c", sans-serif; |
| } |
| </style> |
| <title>文化発表会動画プレイヤー</title> |
| <style> |
| |
| .player-overlay { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background-color: rgba(10, 25, 47, 0.5); |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| z-index: 20; |
| border-radius: 8px; |
| transition: opacity 0.5s ease; |
| pointer-events: none; |
| |
| } |
| |
| .player-overlay.active { |
| pointer-events: auto; |
| |
| } |
| |
| .player-overlay .overlay-message { |
| background-color: rgba(30, 42, 71, 0.95); |
| padding: 30px 40px; |
| border-radius: 12px; |
| text-align: center; |
| border: 1px solid rgba(100, 255, 218, 0.3); |
| box-shadow: 0 0 40px rgba(100, 255, 218, 0.1); |
| } |
| |
| .player-overlay .overlay-message h2 { |
| color: #64ffda; |
| margin: 0 0 15px 0; |
| font-size: 24px; |
| } |
| |
| .player-overlay .overlay-message p { |
| color: #ccd6f6; |
| margin: 5px 0; |
| font-size: 16px; |
| } |
| |
| .player-overlay .overlay-message .spinner-text { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 12px; |
| margin-top: 15px; |
| } |
| |
| .player-overlay .overlay-message .spinner-text .loader { |
| width: 30px; |
| height: 30px; |
| border-width: 4px; |
| filter: blur(2px) contrast(10); |
| } |
| |
| |
| .loader-small { |
| width: 30px; |
| aspect-ratio: 1; |
| border: 4px solid #000; |
| box-sizing: border-box; |
| background: radial-gradient(farthest-side, #64ffda 98%, #0000) 50%/10px 10px, |
| radial-gradient(farthest-side, #64ffda 98%, #0000) 50%/10px 10px, |
| radial-gradient(farthest-side, #64ffda 98%, #0000) 50%/10px 10px, |
| radial-gradient(farthest-side, #64ffda 98%, #0000) 50%/10px 10px, |
| radial-gradient(farthest-side, #64ffda 98%, #0000) 50%/80% 80%, #000; |
| background-repeat: no-repeat; |
| filter: blur(3px) contrast(10); |
| animation: squarePulse 0.8s infinite alternate; |
| } |
| |
| body { |
| font-family: "M PLUS Rounded 1c", 'Arial', sans-serif; |
| background-color: rgba(0, 0, 15, 1); |
| color: #e6f1ff; |
| margin: 0; |
| padding: 20px; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| min-height: 100vh |
| } |
| |
| h1 { |
| color: #a2c2e8; |
| text-align: center; |
| margin-bottom: 30px; |
| border-bottom: 1px solid #64ffda; |
| padding-bottom: 10px; |
| width: 100% |
| } |
| |
| .container { |
| display: flex; |
| flex-direction: column; |
| width: 100%; |
| max-width: 1000px; |
| background-color: rgba(17, 34, 64, 0.3); |
| border-radius: 10px; |
| padding: 20px; |
| box-shadow: 0 0 20px rgba(100, 255, 218, 0.2); |
| backdrop-filter: blur(1.7px); |
| border: 1px solid rgba(100, 255, 218, 0.1) |
| } |
| |
| .video-container { |
| position: relative; |
| width: 100%; |
| margin-bottom: 20px |
| } |
| |
| video { |
| width: 100%; |
| border-radius: 5px; |
| background-color: #000; |
| display: block; |
| cursor: pointer |
| } |
| |
| .video-controls { |
| background-color: rgba(17, 34, 64, 0.2); |
| padding: 10px; |
| border-radius: 0 0 5px 5px; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| transition: opacity 0.3s |
| } |
| |
| .progress-container { |
| width: 100%; |
| height: 10px; |
| background-color: #1e2a47; |
| border-radius: 5px; |
| cursor: pointer; |
| position: relative |
| } |
| |
| .progress-bar { |
| height: 100%; |
| background-color: #64ffda; |
| border-radius: 5px; |
| width: 0%; |
| position: relative |
| } |
| |
| .progress-time { |
| position: absolute; |
| top: -25px; |
| transform: translateX(-50%); |
| background-color: rgba(30, 42, 71, 0.9); |
| padding: 3px 6px; |
| border-radius: 3px; |
| font-size: 12px; |
| display: none; |
| white-space: nowrap |
| } |
| |
| .progress-marker { |
| position: absolute; |
| bottom: 0px; |
| width: 0; |
| height: 0; |
| border-left: 5px solid transparent; |
| border-right: 5px solid transparent; |
| border-top: 10px solid #ff5555; |
| transform: translateX(-50%); |
| z-index: 2 |
| } |
| |
| .main-controls { |
| display: flex; |
| align-items: center; |
| gap: 15px |
| } |
| |
| .control-button { |
| background: none; |
| border: none; |
| color: #e6f1ff; |
| font-size: 18px; |
| cursor: pointer; |
| padding: 5px; |
| border-radius: 50%; |
| width: 36px; |
| height: 36px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: background-color 0.3s |
| } |
| |
| .control-button:hover { |
| background-color: rgba(100, 255, 218, 0.2) |
| } |
| |
| .time-display { |
| font-size: 14px; |
| color: #ccd6f6; |
| white-space: nowrap |
| } |
| |
| .volume-control { |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| margin-left: auto |
| } |
| |
| .volume-button { |
| background: none; |
| border: none; |
| color: #e6f1ff; |
| font-size: 18px; |
| cursor: pointer; |
| padding: 5px |
| } |
| |
| .volume-slider { |
| width: 80px; |
| height: 6px; |
| -webkit-appearance: none; |
| background: #1e2a47; |
| border-radius: 3px; |
| outline: none; |
| opacity: 0; |
| transition: opacity 0.3s, width 0.3s; |
| background-image: linear-gradient(#6aebfc, #6aebfc); |
| background-size: 100% 100%; |
| background-repeat: no-repeat |
| } |
| |
| .volume-control:hover .volume-slider { |
| opacity: 1; |
| width: 100px |
| } |
| |
| .volume-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 12px; |
| height: 12px; |
| background: #6aebfc; |
| border-radius: 50%; |
| cursor: pointer |
| } |
| |
| .speed-control { |
| display: flex; |
| align-items: center; |
| gap: 5px |
| } |
| |
| .speed-slider { |
| width: 120px; |
| height: 6px; |
| -webkit-appearance: none; |
| background: #1e2a47; |
| border-radius: 3px; |
| outline: none; |
| background-image: linear-gradient(#64d1ff, #64d1ff); |
| background-size: 100% 100%; |
| background-repeat: no-repeat |
| } |
| |
| .speed-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 12px; |
| height: 12px; |
| background: #5bb7de; |
| border-radius: 50%; |
| cursor: pointer |
| } |
| |
| .speed-value { |
| font-size: 14px; |
| min-width: 30px; |
| text-align: center |
| } |
| |
| .fullscreen-button { |
| margin-left: 10px |
| } |
| |
| .audio-controls { |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| margin-bottom: 15px |
| } |
| |
| .audio-item { |
| display: flex; |
| align-items: center; |
| gap: 10px |
| } |
| |
| .audio-item label { |
| min-width: 50px; |
| color: #9ab3d9 |
| } |
| |
| .audio-slider { |
| flex-grow: 1; |
| height: 8px; |
| -webkit-appearance: none; |
| background: #1e2a47; |
| border-radius: 5px; |
| outline: none; |
| background-image: linear-gradient(#64ffda, #64ffda); |
| background-size: 100% 100%; |
| background-repeat: no-repeat |
| } |
| |
| .audio-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 18px; |
| height: 18px; |
| background: #64ffda; |
| border-radius: 50%; |
| cursor: pointer |
| } |
| |
| .settings { |
| background-color: rbga(30, 42, 71, 0.3); |
| padding: 15px; |
| border-radius: 5px; |
| margin-bottom: 20px |
| } |
| |
| .setting-item { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 10px |
| } |
| |
| .setting-item:last-child { |
| margin-bottom: 0 |
| } |
| |
| .setting-item label { |
| color: #ccd6f6 |
| } |
| |
| .global-volume-container, |
| .playback-speed-container { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| width: 100% |
| } |
| |
| .global-volume-slider, |
| .playback-speed-slider { |
| flex-grow: 1; |
| height: 8px; |
| -webkit-appearance: none; |
| background: #1e2a47; |
| border-radius: 5px; |
| outline: none; |
| background-image: linear-gradient(#64ffda, #64ffda); |
| background-size: 100% 100%; |
| background-repeat: no-repeat |
| } |
| |
| .global-volume-slider::-webkit-slider-thumb, |
| .playback-speed-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 16px; |
| height: 16px; |
| background: #64ffda; |
| border-radius: 50%; |
| cursor: pointer |
| } |
| |
| .slider-value { |
| min-width: 40px; |
| text-align: right |
| } |
| |
| input[type="number"], |
| input[type="checkbox"], |
| select { |
| background-color: #112240; |
| border: 1px solid #64ffda; |
| color: #e6f1ff; |
| padding: 5px; |
| border-radius: 3px |
| } |
| |
| .tech-decoration { |
| width: 100%; |
| height: 2px; |
| background: linear-gradient(90deg, transparent, #64ffda, transparent); |
| margin: 20px 0 |
| } |
| |
| .video-container:-webkit-full-screen { |
| width: 100%; |
| height: 100%; |
| background-color: black |
| } |
| |
| .video-container:-webkit-full-screen video { |
| width: 100%; |
| height: 100% |
| } |
| |
| .video-container:-webkit-full-screen .video-controls { |
| position: fixed; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| width: 100%; |
| border-radius: 0 |
| } |
| |
| .loading-overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background-color: rgba(0, 0, 0, 0.8); |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| z-index: 9998; |
| transition: opacity 1s ease-out |
| } |
| |
| .spinner-box { |
| width: 300px; |
| height: 300px; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| background-color: transparent |
| } |
| |
| .leo { |
| position: absolute; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| border-radius: 50% |
| } |
| |
| .blue-orbit { |
| width: 165px; |
| height: 165px; |
| border: 1px solid #91daffa5; |
| animation: spin3D 3s linear .2s infinite |
| } |
| |
| .green-orbit { |
| width: 120px; |
| height: 120px; |
| border: 1px solid #91ffbfa5; |
| animation: spin3D 2s linear 0s infinite |
| } |
| |
| .red-orbit { |
| width: 90px; |
| height: 90px; |
| border: 1px solid #ffca91a5; |
| animation: spin3D 1s linear 0s infinite |
| } |
| |
| .white-orbit { |
| width: 60px; |
| height: 60px; |
| border: 2px solid #ffffff; |
| animation: spin3D 10s linear 0s infinite |
| } |
| |
| .w1 { |
| transform: rotate3D(1, 1, 1, 90deg) |
| } |
| |
| .w2 { |
| transform: rotate3D(1, 2, .5, 90deg) |
| } |
| |
| .w3 { |
| transform: rotate3D(.5, 1, 2, 90deg) |
| } |
| |
| @keyframes spin3D { |
| from { |
| transform: rotate3d(.5, .5, .5, 360deg) |
| } |
| |
| to { |
| transform: rotate3d(0, 0, 0, 0deg) |
| } |
| } |
| |
| @keyframes spin { |
| from { |
| transform: rotate(0deg) |
| } |
| |
| to { |
| transform: rotate(360deg) |
| } |
| } |
| |
| .time-set-button { |
| background-color: #112240; |
| border: 1px solid #64ffda; |
| color: #e6f1ff; |
| padding: 5px 10px; |
| border-radius: 3px; |
| cursor: pointer; |
| font-size: 12px; |
| margin-left: 5px; |
| transition: background-color 0.3s |
| } |
| |
| .time-set-button:hover { |
| background-color: rgba(100, 255, 218, 0.2) |
| } |
| |
| .combine-button { |
| background-color: #97c2f0; |
| color: #0a192f; |
| border: none; |
| padding: 7px 14px; |
| border-radius: 5px; |
| font-size: 15px; |
| cursor: pointer; |
| transition: all 0.3s; |
| font-weight: bold |
| } |
| |
| .combine-button:hover { |
| background-color: #52e0c4; |
| transform: translateY(-2px); |
| box-shadow: 0 5px 15px rgba(100, 255, 218, 0.4) |
| } |
| |
| .combine-button:disabled { |
| background-color: #3a5a78; |
| cursor: not-allowed; |
| transform: none; |
| box-shadow: none |
| } |
| |
| .combine-status { |
| margin-top: 10px; |
| color: #97c2f0; |
| font-size: 14px; |
| height: 20px |
| } |
| |
| .preview-section { |
| margin-top: 20px; |
| padding: 15px; |
| background-color: rgba(17, 34, 64, 0.7); |
| border-radius: 5px; |
| display: none |
| } |
| |
| .preview-section h3 { |
| margin-top: 0; |
| color: #97c2f0; |
| border-bottom: 1px solid #64ffda; |
| padding-bottom: 5px |
| } |
| |
| .disabled-overlay { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background-color: rgba(10, 25, 47, 0.7); |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| z-index: 10; |
| border-radius: 5px |
| } |
| |
| .disabled-message { |
| background-color: rgba(30, 42, 71, 0.9); |
| padding: 20px; |
| border-radius: 5px; |
| text-align: center; |
| max-width: 80% |
| } |
| |
| .disabled-message p { |
| margin-bottom: 15px |
| } |
| |
| .loader { |
| width: 80px; |
| aspect-ratio: 1; |
| border: 10px solid #000; |
| box-sizing: border-box; |
| background: radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px, |
| radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px, |
| radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px, |
| radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px, |
| radial-gradient(farthest-side, #fff 98%, #0000) 50%/80% 80%, #000; |
| background-repeat: no-repeat; |
| filter: blur(4px) contrast(10); |
| animation: squarePulse 1s infinite alternate |
| } |
| |
| @keyframes squarePulse { |
| 0% { |
| background-position: 50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50% |
| } |
| |
| 25% { |
| background-position: 50% 0, 50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50% |
| } |
| |
| 50% { |
| background-position: 50% 0, 50% 100%, 50% 50%, 50% 50%, 50% 50%, 50% 50% |
| } |
| |
| 75% { |
| background-position: 50% 0, 50% 100%, 0 50%, 50% 50%, 50% 50%, 50% 50% |
| } |
| |
| 100% { |
| background-position: 50% 0, 50% 100%, 0 50%, 100% 50%, 50% 50%, 50% 50% |
| } |
| } |
| |
| #buffering-indicator { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| z-index: 10; |
| display: none |
| } |
| |
| .sync-status { |
| position: absolute; |
| bottom: 100px; |
| left: 10px; |
| width: 150px; |
| height: 30px; |
| background-color: rgba(0, 0, 0, 0.7); |
| color: #97c2f0; |
| padding: 5px 10px; |
| border-radius: 3px; |
| font-size: 12px; |
| z-index: 5; |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| user-select: none; |
| contain: strict |
| } |
| |
| .sync-status button { |
| background: none; |
| border: none; |
| color: #fff; |
| cursor: pointer; |
| font-size: 12px |
| } |
| |
| .lock-controls-btn { |
| position: fixed; |
| bottom: 20px; |
| right: 20px; |
| background-color: rgba(0, 0, 0, 0.7); |
| border: none; |
| color: #fff; |
| width: 36px; |
| height: 36px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| z-index: 100; |
| display: none |
| } |
| |
| .lock-controls-btn.locked { |
| color: #97c2f0 |
| } |
| |
| .time-markers-container { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| margin: 15px 0; |
| padding: 15px; |
| background-color: rgba(17, 34, 64, 0.8); |
| border-radius: 5px |
| } |
| |
| .time-marker { |
| font-size: 80%; |
| padding: 6px 8px; |
| background-color: rgba(100, 255, 218, 0.05); |
| border: 1px solid #64ffda; |
| border-radius: 4px; |
| cursor: grab; |
| user-select: none; |
| transition: background-color 0.3s |
| } |
| |
| .time-marker:hover { |
| background-color: rgba(100, 255, 218, 0.1) |
| } |
| |
| .time-marker.dragging { |
| opacity: 0.7; |
| cursor: grabbing |
| } |
| |
| .time-input-container { |
| display: flex; |
| align-items: center; |
| gap: 5px |
| } |
| |
| .time-input-container input { |
| width: 80px |
| } |
| |
| |
| .container-a { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| z-index: -1 |
| } |
| |
| canvas { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100% |
| } |
| |
| #starCanvas, |
| #effectCanvas { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100% !important; |
| height: 100% !important; |
| display: block |
| } |
| |
| #starCanvas { |
| z-index: 99998 |
| } |
| |
| #effectCanvas { |
| z-index: 99999 |
| } |
| |
| .grid { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background-image: linear-gradient(rgba(0, 100, 255, 0.1) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(0, 100, 255, 0.1) 1px, transparent 1px); |
| background-size: 50px 50px; |
| z-index: 3; |
| pointer-events: none |
| } |
| |
| .audio-item { |
| display: flex; |
| align-items: center; |
| gap: 10px |
| } |
| |
| .audio-item label { |
| min-width: 50px; |
| color: #9ab3d9 |
| } |
| |
| .audio-slider { |
| flex-grow: 1; |
| height: 8px; |
| -webkit-appearance: none; |
| background: #1e2a47; |
| border-radius: 5px; |
| outline: none; |
| background-image: linear-gradient(#64ffda, #64ffda); |
| background-size: 100% 100%; |
| background-repeat: no-repeat |
| } |
| |
| .audio-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 18px; |
| height: 18px; |
| background: #64ffda; |
| border-radius: 10%; |
| cursor: pointer |
| } |
| |
| .slider-value { |
| min-width: 40px; |
| text-align: right |
| } |
| |
| #lightweight-button { |
| position: fixed; |
| top: 10px; |
| right: 10px; |
| z-index: 9999999; |
| padding: 8px 12px; |
| background-color: #007bff; |
| color: white; |
| border: none; |
| border-radius: 4px; |
| font-size: 15px; |
| cursor: pointer; |
| box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.3); |
| transition: background-color 0.3s ease |
| } |
| |
| #lightweight-button:hover { |
| background-color: #0056b3 |
| } |
| |
| .pane-container { |
| position: relative; |
| width: 1000px; |
| |
| height: 600px; |
| |
| min-height: 600px; |
| max-width: 100%; |
| |
| margin: 0 auto; |
| |
| background: #0a192f; |
| border-radius: 8px; |
| overflow: hidden; |
| border: 1px solid rgba(100, 255, 218, 0.2); |
| } |
| |
| .pane { |
| position: absolute; |
| border: 1px solid rgba(100, 255, 218, 0.4); |
| background: rgba(10, 25, 47, 0.9); |
| border-radius: 4px; |
| min-width: 120px; |
| min-height: 80px; |
| overflow: hidden; |
| cursor: move; |
| } |
| |
| .pane .pane-header { |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| height: 32px; |
| background: rgba(0, 0, 0, 0.7); |
| display: flex; |
| align-items: center; |
| padding: 0 8px; |
| gap: 6px; |
| z-index: 10; |
| cursor: grab; |
| user-select: none; |
| border-bottom: 1px solid rgba(100, 255, 218, 0.2); |
| } |
| |
| .pane .pane-header:active { |
| cursor: grabbing; |
| } |
| |
| .pane .pane-header .pane-title { |
| color: #e6f1ff; |
| font-size: 11px; |
| font-weight: bold; |
| flex: 1; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .pane .pane-header .pane-zoom { |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| } |
| |
| .pane .pane-header .pane-zoom button { |
| width: 20px; |
| height: 20px; |
| font-size: 12px; |
| background: rgba(100, 255, 218, 0.15); |
| border: 1px solid rgba(100, 255, 218, 0.3); |
| color: #e6f1ff; |
| border-radius: 3px; |
| cursor: pointer; |
| padding: 0; |
| line-height: 1; |
| } |
| |
| .pane .pane-header .pane-zoom button:hover { |
| background: rgba(100, 255, 218, 0.3); |
| } |
| |
| .pane .pane-header .pane-zoom .zoom-level { |
| color: #e6f1ff; |
| font-size: 10px; |
| min-width: 32px; |
| text-align: center; |
| } |
| |
| |
| .pane .pane-content { |
| position: absolute; |
| top: 32px; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| overflow: hidden; |
| |
| background: #000; |
| cursor: grab; |
| |
| } |
| |
| .pane .pane-content:active { |
| cursor: grabbing; |
| |
| } |
| |
| .pane .pane-content>* { |
| width: 100%; |
| height: 100%; |
| } |
| |
| .pane .pane-content .zoom-wrapper { |
| width: 100%; |
| height: 100%; |
| transform-origin: 0 0; |
| will-change: transform; |
| position: relative; |
| overflow: visible; |
| |
| } |
| |
| |
| .pane.midi-pane .pane-content { |
| background: #0a0a0a; |
| } |
| |
| .pane.midi-pane .midi-container { |
| width: 100%; |
| height: 100%; |
| display: flex; |
| flex-direction: column; |
| background: #0a0a0a; |
| } |
| |
| .pane.midi-pane .midi-container canvas { |
| flex: 1; |
| width: 100% !important; |
| height: auto !important; |
| pointer-events: none; |
| |
| } |
| |
| .pane.midi-pane .midi-container .midi-keyboard { |
| height: 50px; |
| min-height: 50px; |
| position: relative; |
| background: #000; |
| overflow: hidden; |
| } |
| |
| .pane.midi-pane .midi-container .midi-keyboard .key { |
| position: absolute; |
| box-sizing: border-box; |
| border-radius: 2px; |
| } |
| |
| .pane.midi-pane .midi-container .midi-keyboard .key.white { |
| background: #f5f5f5; |
| border: 1px solid rgba(0, 0, 0, 0.2); |
| height: 100%; |
| z-index: 1; |
| } |
| |
| .pane.midi-pane .midi-container .midi-keyboard .key.black { |
| background: #222; |
| height: 55%; |
| z-index: 3; |
| border: 1px solid #000; |
| } |
| |
| .pane.midi-pane .midi-container .midi-keyboard .key.active { |
| background: #64ffda !important; |
| } |
| |
| .pane.midi-pane .midi-controls { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 4px 8px; |
| background: rgba(0, 0, 0, 0.8); |
| border-top: 1px solid rgba(100, 255, 218, 0.1); |
| } |
| |
| .pane.midi-pane .midi-controls button { |
| background: rgba(100, 255, 218, 0.15); |
| border: 1px solid rgba(100, 255, 218, 0.3); |
| color: #e6f1ff; |
| border-radius: 3px; |
| padding: 2px 8px; |
| font-size: 12px; |
| cursor: pointer; |
| } |
| |
| .pane.midi-pane .midi-controls button:hover { |
| background: rgba(100, 255, 218, 0.3); |
| } |
| |
| .pane.midi-pane .midi-controls .midi-time { |
| color: #ccd6f6; |
| font-size: 11px; |
| font-family: monospace; |
| min-width: 60px; |
| } |
| |
| |
| .pane.xml-pane .pane-content { |
| background: #fff; |
| } |
| |
| .pane.xml-pane .xml-container { |
| width: 100%; |
| height: 100%; |
| overflow: auto; |
| background: #fff; |
| } |
| |
| .pane.xml-pane .xml-container .xml-sheet { |
| width: 100%; |
| height: 100%; |
| } |
| |
| .pane.xml-pane .xml-controls { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 4px 8px; |
| background: rgba(0, 0, 0, 0.8); |
| border-top: 1px solid rgba(100, 255, 218, 0.1); |
| } |
| |
| .pane.xml-pane .xml-controls button { |
| background: rgba(100, 255, 218, 0.15); |
| border: 1px solid rgba(100, 255, 218, 0.3); |
| color: #e6f1ff; |
| border-radius: 3px; |
| padding: 2px 8px; |
| font-size: 12px; |
| cursor: pointer; |
| } |
| |
| .pane.xml-pane .xml-controls button:hover { |
| background: rgba(100, 255, 218, 0.3); |
| } |
| |
| .pane.xml-pane .xml-controls .xml-time { |
| color: #ccd6f6; |
| font-size: 11px; |
| font-family: monospace; |
| min-width: 60px; |
| } |
| |
| |
| .pane .resize-handle { |
| position: absolute; |
| bottom: 0; |
| right: 0; |
| width: 20px; |
| height: 20px; |
| cursor: nwse-resize; |
| z-index: 10; |
| background: rgba(100, 255, 218, 0.1); |
| border-radius: 0 0 4px 0; |
| } |
| |
| .pane .resize-handle::after { |
| content: ""; |
| position: absolute; |
| bottom: 4px; |
| right: 4px; |
| width: 10px; |
| height: 10px; |
| border-right: 2px solid rgba(100, 255, 218, 0.5); |
| border-bottom: 2px solid rgba(100, 255, 218, 0.5); |
| } |
| |
| .pane .pane-controls { |
| position: absolute; |
| top: 0; |
| right: 0; |
| display: flex; |
| align-items: center; |
| gap: 2px; |
| background: rgba(0, 0, 0, 0.6); |
| padding: 2px 4px; |
| border-radius: 0 0 0 4px; |
| z-index: 5; |
| } |
| |
| .pane-controls button, |
| .pane-controls select { |
| width: 28px; |
| height: 28px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 0; |
| font-size: 12px; |
| background: #112240; |
| color: #e6f1ff; |
| border: 1px solid #64ffda; |
| border-radius: 3px; |
| } |
| |
| .pane-controls select { |
| width: auto; |
| height: 28px; |
| padding: 0 4px; |
| font-size: 11px; |
| } |
| |
| .pane-controls button:hover { |
| background: rgba(100, 255, 218, 0.2); |
| } |
| |
| .pane .resize-handle { |
| position: absolute; |
| bottom: 0; |
| right: 0; |
| width: 16px; |
| height: 16px; |
| cursor: nw-resize; |
| background: rgba(100, 255, 218, 0.3); |
| border-radius: 0 0 4px 0; |
| } |
| |
| .pane .resize-handle::after { |
| content: ""; |
| position: absolute; |
| bottom: 4px; |
| right: 4px; |
| width: 8px; |
| height: 8px; |
| border-right: 2px solid #64ffda; |
| border-bottom: 2px solid #64ffda; |
| } |
| |
| |
| .settings-popup { |
| position: absolute; |
| bottom: 100%; |
| right: 0; |
| background: #112240; |
| border: 1px solid #64ffda; |
| border-radius: 8px; |
| padding: 16px; |
| z-index: 50; |
| min-width: 280px; |
| display: none; |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8); |
| } |
| |
| .settings-popup.visible { |
| display: block; |
| } |
| |
| .settings-popup .popup-row { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| margin-bottom: 10px; |
| } |
| |
| .settings-popup .popup-row label { |
| min-width: 70px; |
| color: #ccd6f6; |
| font-size: 14px; |
| } |
| |
| .settings-popup .popup-row input[type="range"] { |
| flex: 1; |
| height: 6px; |
| -webkit-appearance: none; |
| background: #1e2a47; |
| border-radius: 3px; |
| outline: none; |
| background-image: linear-gradient(#64ffda, #64ffda); |
| background-size: 100% 100%; |
| background-repeat: no-repeat; |
| } |
| |
| .settings-popup .popup-row input[type="range"]::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 14px; |
| height: 14px; |
| background: #64ffda; |
| border-radius: 50%; |
| cursor: pointer; |
| } |
| |
| .settings-popup .popup-row input[type="number"] { |
| width: 70px; |
| background: #0a192f; |
| border: 1px solid #64ffda; |
| color: #e6f1ff; |
| border-radius: 3px; |
| padding: 4px; |
| } |
| |
| .settings-popup .popup-row .value { |
| min-width: 40px; |
| text-align: right; |
| color: #ccd6f6; |
| font-size: 14px; |
| } |
| |
| |
| .presets-bar { |
| display: flex; |
| gap: 8px; |
| padding: 8px 4px; |
| flex-wrap: wrap; |
| align-items: center; |
| border-bottom: 1px solid rgba(100, 255, 218, 0.2); |
| margin-bottom: 8px; |
| } |
| |
| .preset-item { |
| position: relative; |
| background: rgba(100, 255, 218, 0.1); |
| border: 1px solid rgba(100, 255, 218, 0.3); |
| border-radius: 4px; |
| padding: 4px 12px; |
| font-size: 13px; |
| cursor: pointer; |
| color: #e6f1ff; |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| } |
| |
| .preset-item:hover { |
| background: rgba(100, 255, 218, 0.2); |
| } |
| |
| .preset-item .preset-delete { |
| display: none; |
| background: none; |
| border: none; |
| color: #ff6b6b; |
| cursor: pointer; |
| font-size: 14px; |
| padding: 0 2px; |
| } |
| |
| .preset-item:hover .preset-delete { |
| display: inline; |
| } |
| |
| .preset-save-btn { |
| background: #64ffda; |
| color: #0a192f; |
| border: none; |
| border-radius: 4px; |
| padding: 4px 12px; |
| font-size: 13px; |
| cursor: pointer; |
| font-weight: bold; |
| } |
| |
| .preset-save-btn:hover { |
| background: #52e0c4; |
| } |
| |
| |
| .preset-selector { |
| background: #112240; |
| color: #e6f1ff; |
| border: 1px solid #64ffda; |
| border-radius: 3px; |
| padding: 4px 8px; |
| font-size: 13px; |
| } |
| |
| |
| .player-wrapper { |
| position: relative; |
| } |
| |
| .player-wrapper:-webkit-full-screen { |
| background-color: #000; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| } |
| |
| .player-wrapper:-webkit-full-screen .pane-container { |
| width: 1000px; |
| height: 600px; |
| max-width: 1000px; |
| max-height: 600px; |
| } |
| |
| .player-wrapper:-moz-full-screen { |
| background-color: #000; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| } |
| |
| .player-wrapper:-moz-full-screen .pane-container { |
| width: 1000px; |
| height: 600px; |
| max-width: 1000px; |
| max-height: 600px; |
| } |
| |
| .player-wrapper:fullscreen { |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| background: #000; |
| position: relative; |
| } |
| |
| .player-wrapper:fullscreen .pane-container { |
| width: 100%; |
| height: 100%; |
| max-width: 100%; |
| max-height: 100%; |
| transform: none; |
| |
| flex-shrink: 0; |
| |
| } |
| |
| .player-wrapper:fullscreen .video-controls { |
| position: fixed; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| width: 100%; |
| background: rgba(0, 0, 0, 0.7); |
| border-radius: 8px; |
| padding: 12px 20px; |
| transition: opacity 0.4s ease; |
| opacity: 1; |
| z-index: 20; |
| pointer-events: auto; |
| } |
| |
| .player-wrapper:fullscreen .video-controls.hidden { |
| opacity: 0; |
| pointer-events: none; |
| } |
| |
| .player-wrapper:fullscreen .control-button { |
| color: #fff; |
| } |
| |
| .player-wrapper:fullscreen .time-display { |
| color: #fff; |
| } |
| |
| .pane video { |
| width: 100%; |
| height: 100%; |
| object-fit: contain; |
| background: #000; |
| display: block; |
| pointer-events: none; |
| |
| } |
| |
| .pane .pane-controls { |
| position: absolute; |
| top: 0; |
| right: 0; |
| display: flex; |
| align-items: center; |
| gap: 2px; |
| background: rgba(0, 0, 0, 0.6); |
| padding: 2px 4px; |
| border-radius: 0 0 0 4px; |
| z-index: 5; |
| } |
| |
| .pane-controls button, |
| .pane-controls select { |
| width: 28px; |
| height: 28px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 0; |
| font-size: 12px; |
| background: #112240; |
| color: #e6f1ff; |
| border: 1px solid #64ffda; |
| border-radius: 3px; |
| } |
| |
| .pane-controls select { |
| width: auto; |
| height: 28px; |
| padding: 0 4px; |
| font-size: 11px; |
| } |
| |
| .pane-controls button:hover { |
| background: rgba(100, 255, 218, 0.2); |
| } |
| |
| .pane .resize-handle { |
| position: absolute; |
| bottom: 0; |
| right: 0; |
| width: 16px; |
| height: 16px; |
| cursor: nw-resize; |
| background: rgba(100, 255, 218, 0.3); |
| border-radius: 0 0 4px 0; |
| } |
| |
| .pane .resize-handle::after { |
| content: ""; |
| position: absolute; |
| bottom: 4px; |
| right: 4px; |
| width: 8px; |
| height: 8px; |
| border-right: 2px solid #64ffda; |
| border-bottom: 2px solid #64ffda; |
| } |
| |
| |
| .pane-container { |
| position: relative; |
| width: 100%; |
| min-height: 400px; |
| background: #0a192f; |
| border-radius: 8px; |
| overflow: hidden; |
| border: 1px solid rgba(100, 255, 218, 0.2); |
| } |
| |
| |
| .main-controls .control-button { |
| font-size: 20px; |
| } |
| |
| |
| .settings-wrapper { |
| position: relative; |
| display: inline-block; |
| } |
| |
| |
| .reset-start-btn { |
| font-size: 18px; |
| } |
| |
| #bpmInput { |
| margin-top: 10px; |
| background: #222; |
| border: none; |
| color: #fff; |
| font-size: 16px; |
| text-align: center; |
| width: 80px; |
| padding: 4px; |
| border-radius: 4px; |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <button id="lightweight-button"><b>動画なしの<br>軽量化バージョン</b></button> |
| <script> |
| document.getElementById("lightweight-button").addEventListener("click", function() { |
| window.location.href = "/sender.html" + window.location.search; |
| }); |
| </script> |
|
|
| <div class="container-a"> |
| <canvas id="starCanvas"></canvas> |
| <canvas id="effectCanvas"></canvas> |
| <div class="grid"></div> |
| </div> |
|
|
| |
| <div class="loading-overlay" id="loadingOverlay"> |
| <div class="spinner-box"> |
| <div class="blue-orbit leo"></div> |
| <div class="green-orbit leo"></div> |
| <div class="red-orbit leo"></div> |
| <div class="white-orbit w1 leo"></div> |
| <div class="white-orbit w2 leo"></div> |
| <div class="white-orbit w3 leo"></div> |
| </div> |
| <div id="loading-progress" style="color:#fff;font-size:1.2rem;margin-top:20px;text-align:center;"> |
| 読み込み中 0/3 |
| </div> |
| </div> |
|
|
| <h1 id="title-name">文化発表会動画プレイヤー</h1> |
|
|
| <div class="details-container"> |
| <details id="usageDetails"> |
| <summary style="font-size: 25px">使い方</summary> |
| <hr> |
| <p>プリセット:配置をすぐに呼び出せるプリセットがあります。事前に準備されているプリセットの他に、「保存」ボタンから現在のプリセットを名前を付けて保存することができます。<br> |
| ・すべて:すべてのパートの動画とコンテンツが配置されています。<br> |
| ・合唱練習:すべてのパートと、楽譜が配置されています。<br> |
| ・1パート練習:一つのパートと楽譜が配置されています。パートはデフォルトではテノールです。<br> |
| </p> |
| <hr> |
| <h3>プレイヤーの使い方</h3> |
| <p>・▶/⏸:再生 / 一時停止 (ショートカット:スペースキーまたはkキー)<br> |
| ・↺:最初から再生 (ショートカットキー:0キー)<br> |
| ・🔇/🔊:消音の切り替え(横のスライダーから詳細な調整ができます。) (ショートカット:mキー)<br> |
| ・⚙️:設定<br> |
| ・設定メニュー:<br> |
| ・再生速度/BPM:再生速度とテンポをそれぞれ変更できます。テンポは自動で変換され、テンポを入力することもできます。1倍速のテンポを基準に計算します。<br> |
| ・再生開始秒数/再生終了秒数:再生する範囲を設定できます。ループの場合はその間を繰り返し再生します。プレイヤーの下のボックスをドラッグアンドドロップすることもできます。<br> |
| ※「再生範囲を適用」を押すまで反映されません。<br> |
| ・ループ:最後まで再生すると最初に戻って繰り返し再生します。<br> |
| ・+:コンテンツを追加します。<br> |
| ・⛶:全画面 (ショートカット:fキー)</p> |
| <hr> |
| <h3>コンテンツ</h3> |
| <p> |
| すべてのコンテンツで、左下のドラッグハンドルをドラッグするとサイズを変更できます。また、上部のヘッダー部分をドラッグすると位置を変更できます。コンテンツ内でズームイン・ズームアウトすることができます。ヘッダーの+・ーボタンでも調整できます。<br> |
| ・歌唱映像:ソプラノ・アルト・テノールの歌唱動画です。<br> |
| ・MIDI:ピアノ演奏を見れます。鍵盤は鳴っている音に色が付きます。<br> |
| ・設定ボタンでパートの表示を切り替えたり、色を変更したりできます。Trackは声部です。<br> |
| ・鍵盤の上の部分をドラッグすると鍵盤の高さを変更できます。<br> |
| ・スクロールしたりドラッグしたりすることで音の部分を上下に移動できます。<br> |
| ・楽譜:楽譜を再生します。現在再生中の場所に自動スクロールされます。鳴っている音に色が付きます。楽譜をドラッグすると位置を移動できます。<br> |
| </p> |
| <hr> |
| <h3>オフライン設定</h3> |
| <p>ページをオフラインで使用できるようにします。ただし、保存されるキャッシュが古い場合、更新プログラムがなかったため、正常に動作しない可能性があります。</p> |
| <hr> |
| <h3>キーボードショートカット</h3> |
| <p> |
| ・5秒戻る:左矢印キーまたはjキー(長押し可)<br> |
| ・5秒進む:右矢印キーまたはkキー(長押し可)<br> |
| ・再生/一時停止:スペースキーまたはkキー<br> |
| ・開始~終了を10分割した位置に移動:0~9キー<br> |
| ・フルスクリーン切り替え:fキー<br> |
| ・音量を10%上げる:上向き矢印キー<br> |
| ・音量を10%下げる:下向き矢印キー<br> |
| ・ミュートの切り替え:mキー<br> |
| ・再生速度を5%遅くする:,または<キー<br> |
| ・再生速度を5%速くする:.または>キー<br> |
| </p> |
| </details> |
| </div> |
|
|
| <div class="container"> |
| <span style="font-size: 1.2em;">オフライン設定</span> |
| <div class="swsettings" id="swsettings"> |
| <a>サービスワーカーでページをオフラインで使用できるようにします。<br> |
| ブラウザの仕様で、オフラインで開く際に最初のみ二回再読み込みボタンを押してください。<br> |
| 不安定な機能で、ページの更新などができなくなるバグが発生する可能性があります。</a> |
| <br> |
| <button class="combine-button" id="sw-register-btn" disabled>登録を開始</button> |
| <div class="combine-status" id="sw-status"></div> |
| </div> |
| <br> |
|
|
| |
| <div class="audio-controls"> |
| <h2>音声コントロール</h2> |
| <span>各パートの練習用では、他のパートのタイミングを確認しながら練習できます。<br> |
| ※合成音量係数は大きくすると全体の音量が大きくなります。Chromebookの限界はおおよそ5倍で、それ以上は音割れします。</span> |
|
|
| |
| <div class="audio-item"> |
| <label>プリセット</label> |
| <select id="preset-select"> |
| <option value="custom">カスタム</option> |
| <option value="soprano">ソプラノ練習用</option> |
| <option value="alto">アルト練習用</option> |
| <option value="tenor">テノール練習用</option> |
| <option value="piano">ピアノ練習用</option> |
| </select> |
| </div> |
|
|
| <div class="audio-item"> |
| <label>ソプラノ</label> |
| <input type="range" class="audio-slider" data-audio="s" min="0" max="1" step="0.01" value="1"> |
| <span class="slider-value volume-value">1.00</span> |
| </div> |
| <div class="audio-item"> |
| <label>アルト </label> |
| <input type="range" class="audio-slider" data-audio="a" min="0" max="1" step="0.01" value="1"> |
| <span class="slider-value volume-value">1.00</span> |
| </div> |
| <div class="audio-item"> |
| <label>テノール</label> |
| <input type="range" class="audio-slider" data-audio="t" min="0" max="1" step="0.01" value="1"> |
| <span class="slider-value volume-value">1.00</span> |
| </div> |
| <div class="audio-item"> |
| <label>ピアノ </label> |
| <input type="range" class="audio-slider" data-audio="p" min="0" max="1" step="0.01" value="1"> |
| <span class="slider-value volume-value">1.00</span> |
| </div> |
| <div class="audio-item"> |
| <label>合成音量係数:</label> |
| <input type="range" class="audio-slider" id="ffmpeg-volume" min="0" max="10" step="0.01" value="1"> |
| <span class="slider-value" id="ffmpeg-volume-value">1.00</span> |
| </div> |
| |
| <button class="combine-button" id="combine-button">音声を合成</button> |
| <div class="combine-status" id="combine-status"></div> |
| </div> |
|
|
| <div class="tech-decoration"></div> |
|
|
| |
| <div class="presets-bar" id="presets-bar"> |
| <span style="color:#ccd6f6;font-size:14px;">プリセット:</span> |
| <div id="preset-list" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;"></div> |
| <button class="preset-save-btn" id="save-preset-btn">💾 保存</button> |
| </div> |
|
|
| |
| <div class="player-wrapper"> |
| |
| <div class="pane-container" id="pane-container"> |
| |
| </div> |
|
|
| |
| <div class="video-controls" id="video-controls"> |
| <div class="progress-container" id="progress-container"> |
| <div class="progress-bar" id="progress-bar"></div> |
| <div class="progress-time" id="progress-time">00:00</div> |
| <div class="progress-marker" id="start-marker" style="left: 0%; display: none;"></div> |
| <div class="progress-marker" id="end-marker" style="left: 100%; display: none;"></div> |
| </div> |
| <div class="main-controls"> |
| |
| <button class="control-button" id="play-pause-btn" disabled>▶</button> |
| |
| <button class="control-button reset-start-btn" id="reset-start-btn" disabled title="再生開始秒数に戻る">↺</button> |
| |
| <div class="time-display" id="time-display">00:00.00 / 00:00.00</div> |
| |
| <div class="volume-control"> |
| <button class="volume-button" id="volume-btn" disabled>🔊</button> |
| <input type="range" class="volume-slider" id="volume-slider" min="0" max="1" step="0.01" value="1" disabled> |
| </div> |
| |
| <div class="settings-wrapper"> |
| <button class="control-button" id="settings-toggle-btn" disabled>⚙</button> |
| <div class="settings-popup" id="settings-popup"> |
| <div class="popup-row"> |
| <label>再生速度</label> |
| <input type="range" id="popup-speed" min="0.01" max="5" step="0.01" value="1"> |
| <span class="value" id="popup-speed-value">1.00x</span> |
| </div> |
| <div class="popup-row"> |
| <label>テンポ(BPM)</label> |
| <input type="number" id="popup-tempo" value="84" step="0.1" min="20" max="300"> |
| <span class="value" id="popup-tempo-value">1.00x</span> |
| </div> |
| <div class="popup-row"> |
| <label>開始秒数</label> |
| <input type="number" id="popup-start-time" value="0" step="0.01" min="0"> |
| </div> |
| <div class="popup-row"> |
| <label>終了秒数</label> |
| <input type="number" id="popup-end-time" value="0" step="0.01" min="0"> |
| </div> |
| <div class="popup-row"> |
| <label>ループ</label> |
| <input type="checkbox" id="popup-loop" checked> |
| </div> |
| <div class="popup-row" style="justify-content:flex-end;"> |
| <button class="combine-button" id="popup-apply-time" style="font-size:13px;padding:4px 12px;">再生範囲を適用</button> |
| </div> |
| </div> |
| </div> |
| |
| <button class="control-button" id="add-pane-btn" title="要素を追加">+</button> |
| |
| <button class="control-button fullscreen-button" id="fullscreen-btn" disabled>⛶</button> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="tech-decoration"></div> |
|
|
| |
| <div class="settings"> |
| <div class="time-markers-container" id="time-markers-container"> |
| <span>このボックスをドラッグして「再生開始秒数」や「再生終了秒数」の入力ボックスの上で離すと秒数を簡単に変更できます。</span> |
| </div> |
| <div class="setting-item"> |
| <label for="start-time">再生開始秒数:</label> |
| <div class="time-input-container"> |
| <input type="number" id="start-time" min="0" value="0" step="0.01" disabled> |
| <button class="time-set-button" id="set-start-time" disabled>現在の秒数に設定</button> |
| </div> |
| </div> |
| <div class="setting-item"> |
| <label for="end-time">再生終了秒数:</label> |
| <div class="time-input-container"> |
| <input type="number" id="end-time" min="0" value="0" step="0.01" disabled> |
| <button class="time-set-button" id="set-end-time" disabled>現在の秒数に設定</button> |
| <button class="time-set-button" id="reset-end-time" disabled>動画の長さに戻す</button> |
| </div> |
| </div> |
| <div class="setting-item"> |
| <label for="loop">ループ再生:</label> |
| <input type="checkbox" id="loop" disabled> |
| </div> |
| <div class="setting-item"> |
| <button class="combine-button" id="apply-time-btn" disabled>時間設定を適用</button> |
| </div> |
| <h2>設定</h2> |
| <div class="setting-item"> |
| <div class="global-volume-container"> |
| <label>全体音量係数:</label> |
| <input type="range" class="global-volume-slider" id="global-volume" min="0" max="10" step="0.01" value="5" disabled> |
| <span class="slider-value" id="global-volume-value">0.5</span> |
| </div> |
| </div> |
| <div class="setting-item"> |
| <div class="playback-speed-container"> |
| <label>再生速度:</label> |
| <input type="range" class="playback-speed-slider" id="playback-speed" min="0.01" max="5" step="0.001" value="1" disabled> |
| <span class="slider-value" id="playback-speed-value">1.00x</span> |
| </div> |
| </div> |
| <div class="setting-item"> |
| <label for="tempo">テンポ (BPM):</label> |
| <input type="number" id="tempo" min="40" max="200" value="84" step="0.1"> |
| <span id="tempo-speed-value">1.00x</span> |
| </div> |
| <span>↑練習したいテンポを入力するとそのテンポに合わせて再生速度スライダーが調整されます。1倍速のテンポを基準に計算します。</span> |
| <br> |
| <div class="setting-item"> |
| <button class="combine-button" id="refresh-background-btn">おまけ:背景を更新</button> |
| </div> |
| </div> |
| |
| <button class="lock-controls-btn" id="lock-controls-btn" title="コントロールバーを固定">🔒</button> |
| </div> |
|
|
| |
| <div id="app" style="position:fixed;top:50%;left:50%;width:280px;height:40px;background:rgba(0,0,0,0.5);color:#fff;border-radius:10px;box-sizing:border-box;box-shadow:0 0 20px rgba(0,0,0,0.7);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:5px;overflow:hidden;z-index:999999;cursor:move;"> |
| <div id="header" style="cursor:pointer;user-select:none;font-size:24px;color:#fff;z-index:10;display:flex;align-items:center;justify-content:center;">▼メトロノーム</div> |
| <div id="content" style="display:none;flex:1;flex-direction:column;align-items:center;justify-content:center;width:100%;user-select:none;"> |
| <svg width="108" height="150" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"> |
| <path id="tip" d="M55,1c22,0,0,48,0,48S33,1,55,1z" fill="none" /> |
| <path d="M21.3,117.5c0-17.4,14.1-31.5,31.5-31.5s31.5,14.1,31.5,31.5" stroke="#333" /> |
| <path id="lvl" d="M21.3,117.5c0-17.4,14.1-31.5,31.5-31.5s31.5,14.1,31.5,31.5" /> |
| <g id="arm"> |
| <path d="M2,65 55,117" /> |
| <circle cx="2" cy="65" r="2" /> |
| </g> |
| <g id="toggle"> |
| <path d="M45.5,145 60.5,145" stroke-width="20" stroke="#444" /> |
| <circle cx="45.5" cy="145" r="8" stroke="none" fill="#000" /> |
| </g> |
| </svg> |
| <input id="bpmInput" type="number" value="84" /> BPM |
| </div> |
| <audio id="snd" src="data:audio/mpeg;base64,SUQzBAAAAAAAIlRTU0UAAAAOAAADTGF2ZjYxLjEuMTAwAAAAAAAAAAAAAAD/+1QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABJbmZvAAAADwAAAAUAAASAAFVVVVVVVVVVVVVVVVVVVVVVVVWAgICAgICAgICAgICAgICAgICAgKqqqqqqqqqqqqqqqqqqqqqqqqqq1dXV1dXV1dXV1dXV1dXV1dXV1dX//////////////////////////wAAAABMYXZjNjEuMy4AAAAAAAAAAAAAAAAkAkAAAAAAAAAEgFJnjCP/+1RkAA/wDQCAAeAACAAADSAAAAEASAIADQAAIAAANIAAAAQD+c9Ur/f39/KLpNtVUpRoKhWoMy4UxKDYGAlmATReypqYqNWo+bVVKUZuovOVsYm4fiQntX6bb+vS6FVUoptu/tmZzvpdCqrM0iW1UxiThsDAPzC/K1BmhWAwOCkgrLbHh29sDrznj/qFIc6kdIe+OiLrGtwIkCn/9NMc8TLx48muiDgf2/RBODoZIFX9JUPc6f41SqLFvEPMuf7/+1RkTA/y3TsugYVlEgAADSAAAAENJQz0FGeACAAANIKAAARb1XDj0vAruHuBEgOH1tjT87z6h3U6xTqQghLKv46ncz8OhQaY2f7xm9PAiEICgUDAV0irYCAMBAAADwt5A73wJGAN/ijDg8BqgCONoHkwtMA3ED3PsQcPSAUAzJoVmZw0LhmF1ZKHgIm/RuyZuB+wFrBExSY0wbL/9buwGMYYXC2ZtLpX//1pmgNggOhBsWFyxOhfACv///i3uMz/+1RkN4ADhEjMbkpgAAAADSDAAAAOEPt9uPeAEAAANIMAAAAef19jsZigQCAUBgMCkABIwuN3LMqhG36c3GNxNIZDg1VL9VQbZP8cCxnN3tt0yr76xnGIDneOOSlM7gXlo9O41VQQhkXC/q+s59Lb+byPMel73vTO4mpa4ta2buHVkmsMn/znO9YtrGfjwb4eROGX8i22d2VUVDbcYTAIImcXTQlEE8LqR19UYqMXXuVcZb9xd9XU8ces0s3df3f/+1RkFQDx2x5jfzEABAAADSDgAAEHnHVxx6RmwAAANIAAAAQOAiaTAUBpMqhxt///7m+Bot83TS7MiyMMDaXyOT0eJuHofDzYXiwaHmlm2DHQRrKjGv9//I3tpEf9hgRHfEQ4gR1KEQdQepGsvt/JZWSqyXqniHQ1SSSwMAsGgVBFwlLwRPrWOicfLlyGus0DQNQ6dPA0JgaeIhKFRjyoLR534h8sDRUqC1MS//8qEnifewZ1n+So1zXK1P9MrP3/+1RkJoDx7QrZeYlgEAAADSAAAAEGROUOAIxHQAAANIAAAAQskwY5djO3+UKzpqz1KGOJM7br5SkFBSGXzBSCg0/2gyCQakxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="></audio> |
| </div> |
|
|
| <script src="/gsap.js"></script> |
| <script src="/CustomEase.min.js"></script> |
|
|
| <script> |
| |
| document.addEventListener('DOMContentLoaded', function() { |
| let isInitialized = false; |
| let overlayElement = null; |
| |
| |
| const meiOffset = 1.75; |
| const midioffset = 1.85; |
| let audioContext; |
| try { |
| audioContext = new(window.AudioContext || window.webkitAudioContext)(); |
| } catch (e) { |
| console.error('Web Audio API not supported', e); |
| } |
| |
| const BASE_PATH = './f/'; |
| const videoFiles = { |
| s: 'sv.mp4', |
| a: 'av.mp4', |
| t: 'tv.mp4' |
| }; |
| const audioFiles = ['s', 'a', 't', 'p']; |
| const audioFileMap = { |
| s: 'sa.opus', |
| a: 'aa.opus', |
| t: 'ta.opus', |
| p: 'pa.opus' |
| }; |
| const videoBlobCache = {}; |
| const pendingFetches = {}; |
| let isLooping = false; |
| |
| let panes = []; |
| let paneIdCounter = 0; |
| let isPlaying = false; |
| let currentPlaybackRate = 1; |
| let combinedAudioElement = null; |
| let isAudioCombined = false; |
| let videoDuration = 0; |
| let startTime = 0; |
| let endTime = 0; |
| let loopEnabled = false; |
| let isDragging = false; |
| let draggingMarker = null; |
| let isDraggingMarker = false; |
| |
| const paneContainer = document.getElementById('pane-container'); |
| const playPauseBtn = document.getElementById('play-pause-btn'); |
| const resetStartBtn = document.getElementById('reset-start-btn'); |
| const timeDisplay = document.getElementById('time-display'); |
| const progressBar = document.getElementById('progress-bar'); |
| const progressContainer = document.getElementById('progress-container'); |
| const progressTime = document.getElementById('progress-time'); |
| const volumeSlider = document.getElementById('volume-slider'); |
| const volumeBtn = document.getElementById('volume-btn'); |
| const fullscreenBtn = document.getElementById('fullscreen-btn'); |
| const settingsToggleBtn = document.getElementById('settings-toggle-btn'); |
| const settingsPopup = document.getElementById('settings-popup'); |
| const addPaneBtn = document.getElementById('add-pane-btn'); |
| const startMarker = document.getElementById('start-marker'); |
| const endMarker = document.getElementById('end-marker'); |
| const loopCheckbox = document.getElementById('loop'); |
| const applyTimeBtn = document.getElementById('apply-time-btn'); |
| const startTimeInput = document.getElementById('start-time'); |
| const endTimeInput = document.getElementById('end-time'); |
| const setStartTimeBtn = document.getElementById('set-start-time'); |
| const setEndTimeBtn = document.getElementById('set-end-time'); |
| const resetEndTimeBtn = document.getElementById('reset-end-time'); |
| const globalVolumeSlider = document.getElementById('global-volume'); |
| const globalVolumeValue = document.getElementById('global-volume-value'); |
| const playbackSpeedSlider = document.getElementById('playback-speed'); |
| const playbackSpeedValue = document.getElementById('playback-speed-value'); |
| const tempoInput = document.getElementById('tempo'); |
| const tempoSpeedValue = document.getElementById('tempo-speed-value'); |
| const combineButton = document.getElementById('combine-button'); |
| const combineStatus = document.getElementById('combine-status'); |
| const presetSelect = document.getElementById('preset-select'); |
| const presetList = document.getElementById('preset-list'); |
| const savePresetBtn = document.getElementById('save-preset-btn'); |
| const lockControlsBtn = document.getElementById('lock-controls-btn'); |
| |
| |
| const popupSpeed = document.getElementById('popup-speed'); |
| const popupSpeedValue = document.getElementById('popup-speed-value'); |
| const popupTempo = document.getElementById('popup-tempo'); |
| const popupTempoValue = document.getElementById('popup-tempo-value'); |
| const popupStartTime = document.getElementById('popup-start-time'); |
| const popupEndTime = document.getElementById('popup-end-time'); |
| const popupLoop = document.getElementById('popup-loop'); |
| const popupApplyTime = document.getElementById('popup-apply-time'); |
| |
| |
| const audioSliders = document.querySelectorAll('.audio-slider'); |
| const ffmpegVolumeSlider = document.getElementById('ffmpeg-volume'); |
| const ffmpegVolumeValue = document.getElementById('ffmpeg-volume-value'); |
| |
| const timeMarkers = [{ |
| label: "最初", |
| time: 0.0 |
| }, |
| { |
| label: "A", |
| time: 13.1 |
| }, |
| { |
| label: "B", |
| time: 58.9 |
| }, |
| { |
| label: "C", |
| time: 81.5 |
| }, |
| { |
| label: "D", |
| time: 127.3 |
| }, |
| { |
| label: "E", |
| time: 150.1 |
| }, |
| { |
| label: "F", |
| time: 173.0 |
| }, |
| { |
| label: "G", |
| time: 194.6 |
| }, |
| { |
| label: "最後", |
| time: 230.5 |
| } |
| ]; |
| |
| const PRESET_VOLUMES = { |
| soprano: { |
| s: 1, |
| a: 0.5, |
| t: 0.5, |
| p: 1 |
| }, |
| alto: { |
| s: 0.5, |
| a: 1, |
| t: 0.5, |
| p: 1 |
| }, |
| tenor: { |
| s: 0.5, |
| a: 0.5, |
| t: 1, |
| p: 1 |
| }, |
| piano: { |
| s: 0.3, |
| a: 0.3, |
| t: 0.3, |
| p: 1 |
| }, |
| custom: { |
| s: 1, |
| a: 1, |
| t: 1, |
| p: 1 |
| } |
| }; |
| |
| |
| const defaultPresets = [{ |
| name: 'すべて', |
| panes: [{ |
| type: 'sing', |
| file: 's', |
| x: 31, |
| y: 0, |
| w: 23, |
| h: 43 |
| }, |
| { |
| type: 'musicxml', |
| x: 0, |
| y: 0, |
| w: 31, |
| h: 100 |
| }, |
| { |
| type: 'sing', |
| file: 'a', |
| x: 54, |
| y: 0, |
| w: 23, |
| h: 43 |
| }, |
| { |
| type: 'midi', |
| file: 'p', |
| x: 31, |
| y: 43, |
| w: 69, |
| h: 57 |
| }, |
| { |
| type: 'sing', |
| file: 't', |
| x: 77, |
| y: 0, |
| w: 23, |
| h: 43 |
| } |
| ], |
| }, |
| { |
| name: '合唱練習', |
| panes: [{ |
| type: 'sing', |
| file: 's', |
| x: 46, |
| y: 0, |
| w: 27, |
| h: 50 |
| }, |
| { |
| type: 'musicxml', |
| file: 's', |
| x: 0, |
| y: 0, |
| w: 46, |
| h: 100 |
| }, |
| { |
| type: 'sing', |
| file: 'a', |
| x: 73, |
| y: 0, |
| w: 27, |
| h: 50 |
| }, |
| { |
| type: 'sing', |
| file: 't', |
| x: 46, |
| y: 50, |
| w: 27, |
| h: 50 |
| } |
| ] |
| }, |
| { |
| name: '1パート練習', |
| panes: [{ |
| type: 'sing', |
| file: 's', |
| x: 63, |
| y: 0, |
| w: 36, |
| h: 52 |
| }, |
| { |
| type: 'musicxml', |
| file: 's', |
| x: 0, |
| y: 0, |
| w: 63, |
| h: 100 |
| } |
| ] |
| } |
| ]; |
| |
| |
| let savedPresets = JSON.parse(localStorage.getItem('playerPresets') || '[]'); |
| |
| if (savedPresets.length === 0) { |
| savedPresets = defaultPresets; |
| localStorage.setItem('playerPresets', JSON.stringify(savedPresets)); |
| } |
| |
| async function preloadVideos() { |
| const requiredFiles = ['sv.mp4', 'av.mp4', 'tv.mp4']; |
| const total = requiredFiles.length; |
| let loaded = 0; |
| const progressEl = document.getElementById('loading-progress'); |
| |
| const updateProgress = (message) => { |
| if (progressEl) progressEl.textContent = message; |
| }; |
| updateProgress(`動画読み込み中 0/${total}`); |
| |
| |
| requiredFiles.forEach(file => { |
| if (videoBlobCache[file]) loaded++; |
| }); |
| if (loaded > 0) updateProgress(`動画読み込み中 ${loaded}/${total}`); |
| |
| const promises = requiredFiles.map(async (file) => { |
| |
| if (videoBlobCache[file]) { |
| return videoBlobCache[file]; |
| } |
| |
| if (pendingFetches[file]) { |
| const url = await pendingFetches[file]; |
| loaded++; |
| updateProgress(`動画読み込み中 ${loaded}/${total}`); |
| return url; |
| } |
| |
| |
| const fullPath = BASE_PATH + file; |
| const fetchPromise = fetch(fullPath) |
| .then(res => { |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| return res.blob(); |
| }) |
| .then(blob => { |
| const url = URL.createObjectURL(blob); |
| videoBlobCache[file] = url; |
| delete pendingFetches[file]; |
| loaded++; |
| updateProgress(`動画読み込み中 ${loaded}/${total}`); |
| return url; |
| }) |
| .catch(err => { |
| delete pendingFetches[file]; |
| console.error(`Failed to preload ${file}:`, err); |
| loaded++; |
| updateProgress(`動画読み込み中 ${loaded}/${total} (エラー)`); |
| throw err; |
| }); |
| |
| pendingFetches[file] = fetchPromise; |
| return fetchPromise; |
| }); |
| |
| |
| await Promise.allSettled(promises); |
| updateProgress(`動画読み込み完了 ${loaded}/${total}`); |
| } |
| |
| function setVideoSource(video, fileName) { |
| if (videoBlobCache[fileName]) { |
| const url = videoBlobCache[fileName]; |
| if (video.src !== url) { |
| video.src = url; |
| video.load(); |
| console.log(`📹 キャッシュから設定: ${fileName}`); |
| } |
| return; |
| } |
| |
| |
| if (pendingFetches[fileName]) { |
| console.log(`⏳ フェッチ完了を待機: ${fileName}`); |
| pendingFetches[fileName] |
| .then(url => { |
| if (video.src !== url) { |
| video.src = url; |
| console.log(`📹 フェッチ完了後に設定: ${fileName}`); |
| } |
| }) |
| .catch(err => console.error('動画読み込みエラー:', fileName, err)); |
| return; |
| } |
| |
| |
| console.log(`🔄 新規フェッチ開始: ${fileName}`); |
| const fullPath = BASE_PATH + fileName; |
| const fetchPromise = fetch(fullPath) |
| .then(res => { |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| return res.blob(); |
| }) |
| .then(blob => { |
| const url = URL.createObjectURL(blob); |
| videoBlobCache[fileName] = url; |
| delete pendingFetches[fileName]; |
| if (video.src !== url) { |
| video.src = url; |
| } |
| video.load(); |
| console.log(`✅ フェッチ完了: ${fileName}`); |
| return url; |
| }) |
| .catch(err => { |
| delete pendingFetches[fileName]; |
| console.error('Failed to load video:', fileName, err); |
| throw err; |
| }); |
| |
| pendingFetches[fileName] = fetchPromise; |
| } |
| |
| |
| |
| function updatePaneZIndex() { |
| |
| } |
| |
| function createPane(type, file, x, y, w, h) { |
| try { |
| console.log('🛠 createPane 開始', type, file, x, y, w, h); |
| const id = paneIdCounter++; |
| const pane = document.createElement('div'); |
| pane.className = 'pane'; |
| pane.dataset.paneId = id; |
| pane.dataset.type = type; |
| pane.style.left = x + '%'; |
| pane.style.top = y + '%'; |
| pane.style.width = w + '%'; |
| pane.style.height = h + '%'; |
| |
| |
| const header = document.createElement('div'); |
| header.className = 'pane-header'; |
| |
| |
| const title = document.createElement('span'); |
| title.className = 'pane-title'; |
| const typeNames = { |
| sing: '🎤 歌唱', |
| other: '🎵 その他', |
| midi: '🎹 MIDI', |
| musicxml: '📄 楽譜' |
| }; |
| title.textContent = typeNames[type] || 'ペイン'; |
| header.appendChild(title); |
| |
| |
| const zoomDiv = document.createElement('div'); |
| zoomDiv.className = 'pane-zoom'; |
| |
| const zoomOutBtn = document.createElement('button'); |
| zoomOutBtn.textContent = '−'; |
| zoomOutBtn.title = 'ズームアウト'; |
| |
| const zoomLevelSpan = document.createElement('span'); |
| zoomLevelSpan.className = 'zoom-level'; |
| zoomLevelSpan.textContent = '100%'; |
| |
| const zoomInBtn = document.createElement('button'); |
| zoomInBtn.textContent = '+'; |
| zoomInBtn.title = 'ズームイン'; |
| |
| |
| let zoomLevel = 1.0; |
| |
| const zoomWrapper = document.createElement('div'); |
| zoomWrapper.className = 'zoom-wrapper'; |
| let wrapperStyle = ` |
| position: relative; |
| overflow: visible; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| transform-origin: center center; |
| `; |
| if (type === 'musicxml') { |
| wrapperStyle += `width: 100%; height: 100%; transform: none;`; |
| } else { |
| wrapperStyle += `width: 100%; height: 100%;`; |
| } |
| zoomWrapper.style.cssText = wrapperStyle; |
| |
| function updateZoom() { |
| zoomLevelSpan.textContent = Math.round(zoomLevel * 100) + '%'; |
| const info = panes.find(p => p.id === id); |
| if (!info) return; |
| const wrapper = info.zoomWrapper; |
| if (!wrapper) return; |
| const isMusicXML = (info.type === 'musicxml'); |
| if (isMusicXML) { |
| wrapper.style.transform = 'none'; |
| wrapper.style.width = '100%'; |
| wrapper.style.height = '100%'; |
| if (info.contentInstance && info.contentInstance.setZoom) { |
| info.contentInstance.setZoom(zoomLevel); |
| } |
| } else { |
| |
| wrapper.style.transformOrigin = 'center center'; |
| wrapper.style.transform = `scale(${zoomLevel})`; |
| wrapper.style.width = '100%'; |
| wrapper.style.height = '100%'; |
| if (info.contentInstance && info.contentInstance.setZoom) { |
| info.contentInstance.setZoom(zoomLevel); |
| } |
| } |
| } |
| |
| |
| zoomInBtn.addEventListener('click', () => { |
| zoomLevel = Math.min(3.0, zoomLevel + 0.1); |
| updateZoom(); |
| }); |
| |
| zoomOutBtn.addEventListener('click', () => { |
| zoomLevel = Math.max(0.3, zoomLevel - 0.1); |
| updateZoom(); |
| }); |
| |
| zoomLevelSpan.addEventListener('dblclick', () => { |
| zoomLevel = 1.0; |
| updateZoom(); |
| }); |
| |
| zoomDiv.appendChild(zoomOutBtn); |
| zoomDiv.appendChild(zoomLevelSpan); |
| zoomDiv.appendChild(zoomInBtn); |
| header.appendChild(zoomDiv); |
| |
| |
| const typeSelect = document.createElement('select'); |
| typeSelect.style.cssText = 'background:#112240;color:#e6f1ff;border:1px solid #64ffda;border-radius:3px;font-size:11px;padding:1px 4px;height:22px;'; |
| |
| const typeOptions = [{ |
| value: 'sing', |
| label: '歌唱' |
| }, |
| { |
| value: 'other', |
| label: 'その他' |
| }, |
| { |
| value: 'midi', |
| label: 'MIDI' |
| }, |
| { |
| value: 'musicxml', |
| label: '楽譜' |
| } |
| ]; |
| |
| typeOptions.forEach(opt => { |
| const option = document.createElement('option'); |
| option.value = opt.value; |
| option.textContent = opt.label; |
| if (opt.value === type) option.selected = true; |
| typeSelect.appendChild(option); |
| }); |
| header.appendChild(typeSelect); |
| |
| |
| const fileSelect = document.createElement('select'); |
| fileSelect.style.cssText = 'background:#112240;color:#e6f1ff;border:1px solid #64ffda;border-radius:3px;font-size:11px;padding:1px 4px;height:22px;'; |
| fileSelect.style.display = (type === 'sing' || type === 'other') ? 'inline-block' : 'none'; |
| |
| function updateFileOptions(selectedType) { |
| fileSelect.innerHTML = ''; |
| let files; |
| if (selectedType === 'sing') { |
| files = [{ |
| value: 's', |
| label: 'ソプラノ' |
| }, |
| { |
| value: 'a', |
| label: 'アルト' |
| }, |
| { |
| value: 't', |
| label: 'テノール' |
| } |
| ]; |
| } else if (selectedType === 'other') { |
| files = [{ |
| value: 'p', |
| label: 'ピアノ' |
| }]; |
| } else { |
| files = []; |
| } |
| files.forEach(f => { |
| const opt = document.createElement('option'); |
| opt.value = f.value; |
| opt.textContent = f.label; |
| if (f.value === file) opt.selected = true; |
| fileSelect.appendChild(opt); |
| }); |
| fileSelect.style.display = (selectedType === 'sing' || selectedType === 'other') ? 'inline-block' : 'none'; |
| } |
| updateFileOptions(type); |
| header.appendChild(fileSelect); |
| |
| |
| const closeBtn = document.createElement('button'); |
| closeBtn.textContent = '✕'; |
| closeBtn.style.cssText = 'background:none;border:none;color:#ff6b6b;cursor:pointer;font-size:14px;padding:0 4px;'; |
| closeBtn.title = 'ペインを削除'; |
| closeBtn.addEventListener('click', function(e) { |
| e.stopPropagation(); |
| const info = panes.find(p => p.id === id); |
| if (info) { |
| if (info._cleanup) info._cleanup(); |
| if (type === 'midi' && window.MIDIPane && info.contentInstance) { |
| const content = pane.querySelector('.pane-content'); |
| if (content) window.MIDIPane.dispose(content); |
| } else if (type === 'musicxml' && window.MusicXMLPane && info.contentInstance) { |
| const content = pane.querySelector('.pane-content'); |
| if (content) window.MusicXMLPane.dispose(content); |
| } |
| } |
| pane.remove(); |
| panes = panes.filter(p => p.id !== id); |
| }); |
| header.appendChild(closeBtn); |
| pane.appendChild(header); |
| |
| |
| const content = document.createElement('div'); |
| content.className = 'pane-content'; |
| content.style.top = '32px'; |
| content.style.height = 'calc(100% - 32px)'; |
| content.style.overflow = 'auto'; |
| content.style.background = (type === 'musicxml') ? '#fff' : '#000'; |
| |
| |
| zoomWrapper.style.width = '100%'; |
| zoomWrapper.style.height = '100%'; |
| content.appendChild(zoomWrapper); |
| pane.appendChild(content); |
| |
| |
| const resizeHandle = document.createElement('div'); |
| resizeHandle.className = 'resize-handle'; |
| pane.appendChild(resizeHandle); |
| |
| |
| let isPanning = false; |
| let panStartX = 0; |
| let panStartY = 0; |
| let startScrollLeft = 0; |
| let startScrollTop = 0; |
| |
| content.addEventListener('wheel', function(e) { |
| if (!e.ctrlKey) { |
| return; |
| } |
| e.preventDefault(); |
| |
| |
| const rect = content.getBoundingClientRect(); |
| const mouseX = e.clientX - rect.left; |
| const mouseY = e.clientY - rect.top; |
| |
| |
| const relX = mouseX / rect.width; |
| const relY = mouseY / rect.height; |
| |
| |
| const delta = e.deltaY > 0 ? -0.1 : 0.1; |
| const oldZoom = zoomLevel; |
| zoomLevel = Math.max(0.3, Math.min(3.0, zoomLevel + delta)); |
| |
| |
| const newRect = content.getBoundingClientRect(); |
| const targetX = relX * newRect.width; |
| const targetY = relY * newRect.height; |
| |
| |
| content.scrollLeft += (targetX - mouseX); |
| content.scrollTop += (targetY - mouseY); |
| |
| |
| const info = panes.find(p => p.id === id); |
| if (info) { |
| info.zoomLevel = zoomLevel; |
| if (info.updateZoom) { |
| info.updateZoom(); |
| } |
| } |
| }, { |
| passive: false |
| }); |
| |
| |
| content.addEventListener('mousedown', function(e) { |
| if (e.button === 1 || e.button === 2) { |
| e.preventDefault(); |
| isPanning = true; |
| panStartX = e.clientX; |
| panStartY = e.clientY; |
| startScrollLeft = content.scrollLeft; |
| startScrollTop = content.scrollTop; |
| content.style.cursor = 'grabbing'; |
| } |
| }); |
| |
| |
| content.addEventListener('contextmenu', function(e) { |
| e.preventDefault(); |
| }); |
| |
| |
| const onMouseMove = function(e) { |
| if (!isPanning) return; |
| const dx = e.clientX - panStartX; |
| const dy = e.clientY - panStartY; |
| content.scrollLeft = startScrollLeft - dx; |
| content.scrollTop = startScrollTop - dy; |
| }; |
| |
| const onMouseUp = function() { |
| if (isPanning) { |
| isPanning = false; |
| content.style.cursor = 'default'; |
| } |
| }; |
| |
| document.addEventListener('mousemove', onMouseMove); |
| document.addEventListener('mouseup', onMouseUp); |
| |
| |
| let isResizing = false; |
| let resizeStartX = 0; |
| let resizeStartY = 0; |
| let resizeStartW = 0; |
| let resizeStartH = 0; |
| const containerRect = paneContainer.getBoundingClientRect(); |
| |
| resizeHandle.addEventListener('mousedown', function(e) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| isResizing = true; |
| resizeStartX = e.clientX; |
| resizeStartY = e.clientY; |
| resizeStartW = pane.offsetWidth; |
| resizeStartH = pane.offsetHeight; |
| document.addEventListener('mousemove', onResizeMove); |
| document.addEventListener('mouseup', onResizeUp); |
| }); |
| |
| function onResizeMove(e) { |
| if (!isResizing) return; |
| const dx = e.clientX - resizeStartX; |
| const dy = e.clientY - resizeStartY; |
| let newW = resizeStartW + dx; |
| let newH = resizeStartH + dy; |
| |
| newW = Math.max(120, newW); |
| newH = Math.max(80, newH); |
| |
| const containerW = paneContainer.clientWidth; |
| const containerH = paneContainer.clientHeight; |
| const wPercent = (newW / containerW) * 100; |
| const hPercent = (newH / containerH) * 100; |
| pane.style.width = wPercent + '%'; |
| pane.style.height = hPercent + '%'; |
| } |
| |
| function onResizeUp() { |
| if (isResizing) { |
| isResizing = false; |
| document.removeEventListener('mousemove', onResizeMove); |
| document.removeEventListener('mouseup', onResizeUp); |
| } |
| } |
| |
| typeSelect.addEventListener('change', function() { |
| const newType = this.value; |
| const info = panes.find(p => p.id === id); |
| if (!info) return; |
| |
| |
| const oldType = info.type; |
| |
| |
| updateFileOptions(newType); |
| let newFile = file; |
| if (newType === 'sing') { |
| if (!['s', 'a', 't'].includes(newFile)) newFile = 's'; |
| } else if (newType === 'other') { |
| newFile = 'p'; |
| } else { |
| newFile = ''; |
| } |
| |
| |
| regeneratePaneContent(id, newType, newFile, oldType); |
| |
| |
| info.type = newType; |
| info.file = newFile; |
| |
| |
| const titleSpan = pane.querySelector('.pane-title'); |
| if (titleSpan) titleSpan.textContent = typeNames[newType] || 'ペイン'; |
| |
| if (info.contentInstance && info.contentInstance.setZoom) { |
| info.contentInstance.setZoom(1); |
| } |
| |
| |
| if (info.updateZoom) info.updateZoom(); |
| }); |
| |
| fileSelect.addEventListener('change', function() { |
| const newFile = this.value; |
| const info = panes.find(p => p.id === id); |
| if (!info) return; |
| |
| if (info.type === 'sing' || info.type === 'other') { |
| info.file = newFile; |
| |
| const video = info.contentInstance; |
| if (video && video.tagName === 'VIDEO') { |
| const fileName = info.type === 'sing' ? videoFiles[newFile] || 'sv.mp4' : videoFiles[newFile] || 'av.mp4'; |
| setVideoSource(video, fileName); |
| |
| if (combinedAudioElement) { |
| video.currentTime = combinedAudioElement.currentTime; |
| } |
| } |
| } |
| }); |
| |
| |
| |
| |
| if (paneContainer) { |
| paneContainer.appendChild(pane); |
| } else { |
| console.error('paneContainer が見つかりません'); |
| return null; |
| } |
| |
| |
| if (typeof Draggabilly === 'function') { |
| const draggie = new Draggabilly(pane, { |
| handle: '.pane-header', |
| containment: paneContainer |
| }); |
| |
| draggie.on('dragStart', function() { |
| panes.forEach(p => { |
| p.element.style.zIndex = 1; |
| }); |
| pane.style.zIndex = 10; |
| }); |
| |
| const info = panes.find(p => p.id === id); |
| if (info) { |
| const oldCleanup = info._cleanup || function() {}; |
| info._cleanup = function() { |
| draggie.destroy(); |
| oldCleanup(); |
| }; |
| } |
| } else { |
| console.warn('Draggabilly が読み込まれていません。ペインの移動はできません。'); |
| } |
| |
| |
| |
| |
| let contentInstance = null; |
| let videoElement = null; |
| |
| if (type === 'midi') { |
| pane.classList.add('midi-pane'); |
| if (window.MIDIPane) { |
| contentInstance = window.MIDIPane.create(zoomWrapper, './f/piano.mid', header, midioffset); |
| } else { |
| zoomWrapper.innerHTML = '<div style="color:#ff6b6b;padding:20px;">MIDIPane が読み込まれていません</div>'; |
| } |
| } else if (type === 'musicxml') { |
| pane.classList.add('xml-pane'); |
| if (window.MusicXMLPane) { |
| |
| contentInstance = window.MusicXMLPane.create(zoomWrapper, './mei-ver.mei', { |
| offset: meiOffset |
| }); |
| |
| if (contentInstance && contentInstance.setZoom) { |
| contentInstance.setZoom(1); |
| } |
| const currentTime = combinedAudioElement ? combinedAudioElement.currentTime : 0; |
| if (contentInstance && contentInstance.syncTo) { |
| contentInstance.syncTo(currentTime); |
| } |
| const controls = zoomWrapper.closest('.pane').querySelector('.xml-controls'); |
| if (controls) controls.style.display = 'none'; |
| |
| updateZoom(); |
| } else { |
| zoomWrapper.innerHTML = '<div style="color:#ff6b6b;padding:20px;">MusicXMLPane が読み込まれていません</div>'; |
| } |
| } else { |
| |
| videoElement = document.createElement('video'); |
| videoElement.muted = true; |
| videoElement.playsInline = true; |
| videoElement.preload = 'auto'; |
| videoElement.style.width = '100%'; |
| videoElement.style.height = '100%'; |
| videoElement.style.objectFit = 'contain'; |
| videoElement.style.background = '#000'; |
| videoElement.style.pointerEvents = 'none'; |
| |
| const fileName = type === 'sing' ? videoFiles[file] || 'sv.mp4' : videoFiles[file] || 'av.mp4'; |
| setVideoSource(videoElement, fileName); |
| zoomWrapper.appendChild(videoElement); |
| contentInstance = videoElement; |
| |
| videoElement.addEventListener('loadedmetadata', () => { |
| if (combinedAudioElement) { |
| videoElement.currentTime = combinedAudioElement.currentTime; |
| } |
| }); |
| |
| updateZoom(); |
| } |
| |
| |
| const paneInfo = { |
| id: id, |
| type: type, |
| file: file, |
| element: pane, |
| content: content, |
| contentInstance: contentInstance, |
| zoomWrapper: zoomWrapper, |
| zoomLevel: zoomLevel, |
| updateZoom: updateZoom, |
| _cleanup: function() { |
| document.removeEventListener('mousemove', onMouseMove); |
| document.removeEventListener('mouseup', onMouseUp); |
| document.removeEventListener('mousemove', onResizeMove); |
| document.removeEventListener('mouseup', onResizeUp); |
| } |
| }; |
| |
| |
| const existingIdx = panes.findIndex(p => p.id === id); |
| if (existingIdx >= 0) { |
| panes[existingIdx] = paneInfo; |
| } else { |
| panes.push(paneInfo); |
| } |
| |
| return paneInfo; |
| } catch (err) { |
| console.error('❌ createPane でエラーが発生:', err); |
| |
| return null; |
| } |
| } |
| |
| function regeneratePaneContent(paneId, type, file, oldType) { |
| const info = panes.find(p => p.id === paneId); |
| if (!info) return; |
| |
| const pane = info.element; |
| const content = pane.querySelector('.pane-content'); |
| |
| |
| if (oldType === 'midi' && window.MIDIPane) { |
| content.querySelectorAll('.zoom-wrapper').forEach(w => { |
| window.MIDIPane.dispose(w); |
| }); |
| } else if (oldType === 'musicxml' && window.MusicXMLPane) { |
| content.querySelectorAll('.zoom-wrapper').forEach(w => { |
| window.MusicXMLPane.dispose(w); |
| }); |
| } |
| |
| |
| content.innerHTML = ''; |
| |
| |
| const zoomWrapper = document.createElement('div'); |
| zoomWrapper.className = 'zoom-wrapper'; |
| let wrapperStyle = ` |
| position: relative; |
| overflow: visible; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| width: 100%; |
| height: 100%; |
| `; |
| if (type === 'musicxml') { |
| wrapperStyle += 'transform: none;'; |
| } |
| zoomWrapper.style.cssText = wrapperStyle; |
| content.appendChild(zoomWrapper); |
| info.zoomWrapper = zoomWrapper; |
| zoomWrapper.innerHTML = ''; |
| info.contentInstance = null; |
| |
| |
| if (type === 'midi') { |
| pane.classList.add('midi-pane'); |
| if (window.MIDIPane) { |
| info.contentInstance = window.MIDIPane.create(zoomWrapper, './f/piano.mid', pane.querySelector('.pane-header'), midioffset); |
| } else { |
| zoomWrapper.innerHTML = '<div style="color:#ff6b6b;padding:20px;">MIDIPane が読み込まれていません</div>'; |
| } |
| } else if (type === 'musicxml') { |
| pane.classList.add('xml-pane'); |
| if (window.MusicXMLPane) { |
| info.contentInstance = window.MusicXMLPane.create(zoomWrapper, './mei-ver.mei', { |
| offset: meiOffset |
| }); |
| const currentTime = combinedAudioElement ? combinedAudioElement.currentTime : 0; |
| if (info.contentInstance && info.contentInstance.syncTo) { |
| info.contentInstance.syncTo(currentTime); |
| } |
| const controls = zoomWrapper.closest('.pane').querySelector('.xml-controls'); |
| if (controls) controls.style.display = 'none'; |
| } else { |
| zoomWrapper.innerHTML = '<div style="color:#ff6b6b;padding:20px;">MusicXMLPane が読み込まれていません</div>'; |
| } |
| } else { |
| |
| pane.classList.remove('midi-pane', 'xml-pane'); |
| const videoElement = document.createElement('video'); |
| videoElement.muted = true; |
| videoElement.playsInline = true; |
| videoElement.preload = 'auto'; |
| videoElement.style.width = '100%'; |
| videoElement.style.height = '100%'; |
| videoElement.style.objectFit = 'contain'; |
| videoElement.style.background = '#000'; |
| videoElement.style.pointerEvents = 'none'; |
| |
| const fileName = type === 'sing' ? videoFiles[file] || 'sv.mp4' : videoFiles[file] || 'av.mp4'; |
| setVideoSource(videoElement, fileName); |
| zoomWrapper.appendChild(videoElement); |
| info.contentInstance = videoElement; |
| |
| if (combinedAudioElement) { |
| videoElement.currentTime = combinedAudioElement.currentTime; |
| } |
| videoElement.addEventListener('loadedmetadata', () => { |
| if (combinedAudioElement) { |
| videoElement.currentTime = combinedAudioElement.currentTime; |
| } |
| }); |
| } |
| |
| |
| info.zoomLevel = 1.0; |
| if (info.updateZoom) { |
| info.updateZoom(); |
| } |
| } |
| |
| function syncAllVideos(seekTime) { |
| return new Promise((resolve) => { |
| const videos = panes.map(p => p.element.querySelector('video')).filter(v => v); |
| const audio = combinedAudioElement; |
| const mediaElements = [audio, ...videos].filter(el => el !== null); |
| |
| if (mediaElements.length === 0) { |
| resolve(); |
| return; |
| } |
| |
| |
| const allReady = mediaElements.every(el => { |
| if (!el) return false; |
| if (el.tagName === 'VIDEO') { |
| return el.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA; |
| } |
| return el.duration > 0; |
| }); |
| |
| if (allReady) { |
| const time = seekTime !== undefined ? seekTime : (audio ? audio.currentTime : 0); |
| videos.forEach(v => { |
| v.currentTime = time; |
| }); |
| if (audio) audio.currentTime = time; |
| resolve(); |
| return; |
| } |
| |
| |
| let pending = mediaElements.filter(el => { |
| if (!el) return false; |
| if (el.tagName === 'VIDEO') { |
| return el.readyState < HTMLMediaElement.HAVE_ENOUGH_DATA; |
| } |
| return el.duration === 0; |
| }).length; |
| |
| if (pending === 0) { |
| const time = seekTime !== undefined ? seekTime : (audio ? audio.currentTime : 0); |
| videos.forEach(v => { |
| v.currentTime = time; |
| }); |
| if (audio) audio.currentTime = time; |
| resolve(); |
| return; |
| } |
| |
| const indicator = document.getElementById('buffering-indicator'); |
| if (indicator) indicator.style.display = 'block'; |
| |
| let resolved = false; |
| |
| const onReady = () => { |
| if (resolved) return; |
| pending--; |
| if (pending === 0) { |
| resolved = true; |
| if (indicator) indicator.style.display = 'none'; |
| const time = seekTime !== undefined ? seekTime : (audio ? audio.currentTime : 0); |
| videos.forEach(v => { |
| v.currentTime = time; |
| }); |
| if (audio) audio.currentTime = time; |
| resolve(); |
| } |
| }; |
| |
| mediaElements.forEach(el => { |
| if (!el) return; |
| if (el.tagName === 'VIDEO' && el.readyState < HTMLMediaElement.HAVE_ENOUGH_DATA) { |
| el.addEventListener('canplaythrough', onReady, { |
| once: true |
| }); |
| el.addEventListener('loadeddata', onReady, { |
| once: true |
| }); |
| el.addEventListener('error', onReady, { |
| once: true |
| }); |
| } else if (el.tagName !== 'VIDEO' && el.duration === 0) { |
| el.addEventListener('loadedmetadata', onReady, { |
| once: true |
| }); |
| el.addEventListener('canplaythrough', onReady, { |
| once: true |
| }); |
| el.addEventListener('error', onReady, { |
| once: true |
| }); |
| } |
| }); |
| |
| |
| setTimeout(() => { |
| if (!resolved) { |
| resolved = true; |
| if (indicator) indicator.style.display = 'none'; |
| console.warn('syncAllVideos: タイムアウト、強制実行'); |
| const time = seekTime !== undefined ? seekTime : (audio ? audio.currentTime : 0); |
| videos.forEach(v => { |
| v.currentTime = time; |
| }); |
| if (audio) audio.currentTime = time; |
| resolve(); |
| } |
| }, 10000); |
| }); |
| } |
| |
| function clearPanesOnly() { |
| |
| panes.forEach(p => { |
| if (p.type === 'midi' && window.MIDIPane && p.contentInstance) { |
| const content = p.element.querySelector('.pane-content'); |
| if (content) window.MIDIPane.dispose(content); |
| } else if (p.type === 'musicxml' && window.MusicXMLPane && p.contentInstance) { |
| const content = p.element.querySelector('.pane-content'); |
| if (content) window.MusicXMLPane.dispose(content); |
| } |
| p.element.remove(); |
| }); |
| panes = []; |
| paneIdCounter = 0; |
| } |
| |
| |
| function clearPanes() { |
| |
| if (combinedAudioElement) { |
| combinedAudioElement.pause(); |
| URL.revokeObjectURL(combinedAudioElement.src); |
| combinedAudioElement = null; |
| } |
| isAudioCombined = false; |
| clearPanesOnly(); |
| } |
| |
| function loadPreset(preset) { |
| const wasPlaying = isPlaying; |
| const currentTime = combinedAudioElement ? combinedAudioElement.currentTime : 0; |
| |
| |
| clearPanesOnly(); |
| |
| if (!preset || !preset.panes) { |
| return; |
| } |
| |
| |
| preset.panes.forEach((p, index) => { |
| setTimeout(() => { |
| createPane(p.type, p.file || 's', p.x ?? (50 + index * 30), p.y ?? (50 + index * 20), p.w ?? 300, p.h ?? 200); |
| }, index * 70); |
| }); |
| |
| |
| if (combinedAudioElement) { |
| combinedAudioElement.currentTime = currentTime; |
| if (wasPlaying) { |
| combinedAudioElement.play().catch(() => {}); |
| } |
| } |
| } |
| |
| |
| function applyVolumePreset(vol) { |
| audioSliders.forEach(slider => { |
| const key = slider.dataset.audio; |
| if (vol && vol[key] !== undefined) { |
| slider.value = vol[key]; |
| const val = parseFloat(vol[key]); |
| const display = slider.parentElement.querySelector('.volume-value'); |
| if (display) display.textContent = val.toFixed(2); |
| const percent = val * 100; |
| slider.style.backgroundSize = `${percent}% 100%`; |
| } |
| }); |
| } |
| |
| function getCurrentPaneData() { |
| const containerRect = paneContainer.getBoundingClientRect(); |
| return panes.map(p => { |
| const rect = p.element.getBoundingClientRect(); |
| const left = (rect.left - containerRect.left) / containerRect.width * 100; |
| const top = (rect.top - containerRect.top) / containerRect.height * 100; |
| const w = rect.width / containerRect.width * 100; |
| const h = rect.height / containerRect.height * 100; |
| return { |
| type: p.type, |
| file: p.file, |
| x: left, |
| y: top, |
| w, |
| h |
| }; |
| }); |
| } |
| |
| function getCurrentVolumes() { |
| const vols = {}; |
| audioSliders.forEach(slider => { |
| vols[slider.dataset.audio] = parseFloat(slider.value); |
| }); |
| return vols; |
| } |
| |
| |
| function savePreset() { |
| const name = prompt('プリセット名を入力してください'); |
| if (!name) return; |
| const data = { |
| name: name, |
| panes: getCurrentPaneData(), |
| volumes: getCurrentVolumes() |
| }; |
| |
| const idx = savedPresets.findIndex(p => p.name === name); |
| if (idx >= 0) { |
| savedPresets[idx] = data; |
| } else { |
| savedPresets.push(data); |
| } |
| localStorage.setItem('playerPresets', JSON.stringify(savedPresets)); |
| renderPresets(); |
| } |
| |
| function deletePreset(name) { |
| savedPresets = savedPresets.filter(p => p.name !== name); |
| localStorage.setItem('playerPresets', JSON.stringify(savedPresets)); |
| renderPresets(); |
| } |
| |
| function renderPresets() { |
| presetList.innerHTML = ''; |
| savedPresets.forEach(p => { |
| const el = document.createElement('span'); |
| el.className = 'preset-item'; |
| el.textContent = p.name; |
| el.addEventListener('click', () => loadPreset(p)); |
| const del = document.createElement('button'); |
| del.className = 'preset-delete'; |
| del.textContent = '×'; |
| del.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| deletePreset(p.name); |
| }); |
| el.appendChild(del); |
| presetList.appendChild(el); |
| }); |
| } |
| |
| |
| async function combineAudio() { |
| const ffmpegVolume = parseFloat(ffmpegVolumeSlider.value); |
| combineButton.disabled = true; |
| combineStatus.textContent = "音声を合成中..."; |
| |
| |
| showOverlay('音声を合成中...'); |
| |
| try { |
| const vols = {}; |
| audioSliders.forEach(slider => { |
| vols[slider.dataset.audio] = parseFloat(slider.value); |
| }); |
| |
| |
| const audioBufferPromises = audioFiles.map(async file => { |
| if (vols[file] === 0) return null; |
| const resp = await fetch(BASE_PATH + audioFileMap[file]); |
| const arrayBuffer = await resp.arrayBuffer(); |
| return await audioContext.decodeAudioData(arrayBuffer); |
| }); |
| |
| const buffers = await Promise.all(audioBufferPromises); |
| const validBuffers = buffers.filter(b => b); |
| if (validBuffers.length === 0) { |
| throw new Error('有効な音声ファイルがありません'); |
| } |
| |
| const maxDuration = Math.max(...validBuffers.map(b => b.duration)); |
| const combined = audioContext.createBuffer(2, audioContext.sampleRate * maxDuration, audioContext.sampleRate); |
| |
| audioFiles.forEach((file, idx) => { |
| const buf = buffers[idx]; |
| if (!buf || vols[file] === 0) return; |
| let vol = vols[file] * ffmpegVolume; |
| if (file === 'p') { |
| vol = vol * 2; |
| console.log(`🎹 ピアノ音量を2倍に調整: ${vols[file]} → ${vols[file] * 2}`); |
| } |
| for (let ch = 0; ch < 2; ch++) { |
| const input = buf.getChannelData(ch % buf.numberOfChannels); |
| const output = combined.getChannelData(ch); |
| for (let i = 0; i < input.length; i++) { |
| output[i] += input[i] * vol; |
| } |
| } |
| }); |
| |
| |
| const blob = bufferToWave(combined); |
| const url = URL.createObjectURL(blob); |
| |
| if (combinedAudioElement) { |
| combinedAudioElement.pause(); |
| URL.revokeObjectURL(combinedAudioElement.src); |
| } |
| combinedAudioElement = new Audio(url); |
| combinedAudioElement.preservesPitch = true; |
| combinedAudioElement.mozPreservesPitch = true; |
| combinedAudioElement.webkitPreservesPitch = true; |
| combinedAudioElement.playbackRate = currentPlaybackRate; |
| combinedAudioElement.volume = parseFloat(volumeSlider.value) * (parseFloat(globalVolumeSlider.value) / 10); |
| |
| isAudioCombined = true; |
| videoDuration = maxDuration; |
| endTime = maxDuration; |
| endTimeInput.value = maxDuration.toFixed(2); |
| popupEndTime.value = maxDuration.toFixed(2); |
| |
| |
| updateOverlayMessage('動画を読み込み中...'); |
| |
| combineStatus.textContent = "音声の合成が完了しました。動画を読み込み中です。"; |
| const videoElements = panes.map(p => p.element.querySelector('video')).filter(v => v); |
| |
| if (videoElements.length > 0) { |
| let loadedCount = 0; |
| const totalVideos = videoElements.length; |
| |
| videoElements.forEach(video => { |
| if (video.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) { |
| loadedCount++; |
| checkAllLoaded(); |
| } else { |
| video.addEventListener('canplaythrough', function onCanPlay() { |
| loadedCount++; |
| checkAllLoaded(); |
| video.removeEventListener('canplaythrough', onCanPlay); |
| }); |
| video.addEventListener('error', function onError() { |
| loadedCount++; |
| checkAllLoaded(); |
| video.removeEventListener('error', onError); |
| }); |
| } |
| }); |
| |
| function checkAllLoaded() { |
| if (loadedCount >= totalVideos) { |
| combineStatus.textContent = "音声の合成と動画の読み込みが完了しました"; |
| |
| updateOverlayMessage('完了'); |
| enablePlayerControls(); |
| } else { |
| |
| updateOverlayMessage(`動画読み込み中... ${loadedCount}/${totalVideos}`); |
| } |
| } |
| |
| |
| setTimeout(() => { |
| if (loadedCount < totalVideos) { |
| combineStatus.textContent = "音声の合成が完了しました(一部動画は読み込み中)"; |
| |
| updateOverlayMessage('完了(一部動画読み込み中)'); |
| enablePlayerControls(); |
| } |
| }, 5000); |
| } else { |
| |
| enablePlayerControls(); |
| } |
| |
| combinedAudioElement.addEventListener('ended', function() { |
| if (loopEnabled) { |
| const st = parseFloat(startTimeInput.value) || 0; |
| combinedAudioElement.currentTime = st; |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid) vid.currentTime = st; |
| }); |
| combinedAudioElement.play().catch(e => {}); |
| } else { |
| pauseMedia(); |
| } |
| }); |
| combinedAudioElement.addEventListener('timeupdate', function() { |
| updateProgress(); |
| if (isLooping) return; |
| const current = this.currentTime; |
| const start = parseFloat(startTimeInput.value) || 0; |
| const end = parseFloat(endTimeInput.value) || videoDuration; |
| |
| if (current >= end) { |
| |
| isLooping = true; |
| if (loopEnabled) { |
| this.currentTime = start; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = start; |
| }); |
| } else { |
| this.pause(); |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.pause(); |
| }); |
| isPlaying = false; |
| playPauseBtn.textContent = '▶'; |
| } |
| setTimeout(() => { |
| isLooping = false; |
| }, 50); |
| } else if (current < start) { |
| |
| isLooping = true; |
| this.currentTime = start; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = start; |
| }); |
| setTimeout(() => { |
| isLooping = false; |
| }, 50); |
| } |
| }); |
| |
| |
| syncAllVideos(); |
| |
| } catch (error) { |
| console.error('音声合成エラー:', error); |
| combineStatus.textContent = "音声の合成に失敗しました: " + error.message; |
| } finally { |
| combineButton.disabled = false; |
| } |
| } |
| |
| function bufferToWave(abuffer) { |
| const numOfChan = abuffer.numberOfChannels; |
| const length = abuffer.length * numOfChan * 2 + 44; |
| const buffer = new ArrayBuffer(length); |
| const view = new DataView(buffer); |
| let pos = 0; |
| |
| function setUint16(data) { |
| view.setUint16(pos, data, true); |
| pos += 2; |
| } |
| |
| function setUint32(data) { |
| view.setUint32(pos, data, true); |
| pos += 4; |
| } |
| |
| setUint32(0x46464952); |
| setUint32(length - 8); |
| setUint32(0x45564157); |
| setUint32(0x20746d66); |
| setUint32(16); |
| setUint16(1); |
| setUint16(numOfChan); |
| setUint32(abuffer.sampleRate); |
| setUint32(abuffer.sampleRate * 2 * numOfChan); |
| setUint16(numOfChan * 2); |
| setUint16(16); |
| setUint32(0x61746164); |
| setUint32(length - pos - 4); |
| |
| for (let i = 0; i < abuffer.length; i++) { |
| for (let ch = 0; ch < numOfChan; ch++) { |
| let sample = abuffer.getChannelData(ch)[i] * 0x7fff; |
| sample = Math.max(-32768, Math.min(32767, sample)); |
| view.setInt16(pos, sample, true); |
| pos += 2; |
| } |
| } |
| return new Blob([buffer], { |
| type: 'audio/wav' |
| }); |
| } |
| |
| |
| function togglePlayPause() { |
| if (!isAudioCombined || !combinedAudioElement) return; |
| if (isPlaying) { |
| pauseMedia(); |
| } else { |
| playMedia(); |
| } |
| } |
| |
| function playMedia() { |
| if (!isAudioCombined || !combinedAudioElement) return; |
| const et = parseFloat(endTimeInput.value) || videoDuration; |
| |
| |
| if (combinedAudioElement.currentTime >= et) { |
| const st = parseFloat(startTimeInput.value) || 0; |
| combinedAudioElement.currentTime = st; |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid) vid.currentTime = st; |
| }); |
| } |
| |
| const currentTime = combinedAudioElement.currentTime; |
| |
| combinedAudioElement.play().catch(e => {}); |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid) { |
| vid.currentTime = currentTime; |
| vid.play().catch(e => {}); |
| vid.playbackRate = currentPlaybackRate; |
| } |
| if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.play) { |
| p.contentInstance.play(); |
| } |
| }); |
| |
| |
| if (window.MIDIPane) { |
| window.MIDIPane.seekAll(currentTime); |
| window.MIDIPane.playAll(); |
| } |
| if (window.MusicXMLPane) window.MusicXMLPane.playAll(); |
| |
| isPlaying = true; |
| playPauseBtn.textContent = '⏸'; |
| } |
| |
| function pauseMedia() { |
| if (!isAudioCombined || !combinedAudioElement) return; |
| combinedAudioElement.pause(); |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid) vid.pause(); |
| |
| if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.pause) { |
| p.contentInstance.pause(); |
| } |
| }); |
| |
| if (window.MIDIPane) window.MIDIPane.pauseAll(); |
| |
| if (window.MusicXMLPane) window.MusicXMLPane.pauseAll(); |
| isPlaying = false; |
| playPauseBtn.textContent = '▶'; |
| } |
| |
| function resetToStart() { |
| const st = parseFloat(startTimeInput.value) || 0; |
| if (combinedAudioElement) { |
| combinedAudioElement.currentTime = st; |
| } |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid) vid.currentTime = st; |
| |
| if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.seekTo) { |
| p.contentInstance.seekTo(st); |
| } |
| }); |
| |
| if (window.MIDIPane) window.MIDIPane.seekAll(st); |
| |
| if (window.MusicXMLPane) window.MusicXMLPane.seekAll(st); |
| updateProgress(); |
| if (!isPlaying) { |
| |
| } |
| } |
| |
| |
| function updateProgress() { |
| if (!combinedAudioElement) return; |
| if (isDraggingMarker) return; |
| const current = combinedAudioElement.currentTime; |
| const duration = combinedAudioElement.duration || videoDuration; |
| const percent = duration > 0 ? (current / duration) * 100 : 0; |
| progressBar.style.width = percent + '%'; |
| const mins = Math.floor(current / 60); |
| const secs = Math.floor(current % 60); |
| const ms = Math.floor((current % 1) * 100); |
| const dmins = Math.floor(duration / 60); |
| const dsecs = Math.floor(duration % 60); |
| timeDisplay.textContent = |
| `${String(mins).padStart(2,'0')}:${String(secs).padStart(2,'0')}.${String(ms).padStart(2,'0')} / ${String(dmins).padStart(2,'0')}:${String(dsecs).padStart(2,'0')}`; |
| |
| const st = parseFloat(startTimeInput.value) || 0; |
| const et = parseFloat(endTimeInput.value) || duration; |
| if (duration > 0) { |
| startMarker.style.left = (st / duration * 100) + '%'; |
| endMarker.style.left = (et / duration * 100) + '%'; |
| startMarker.style.display = 'block'; |
| endMarker.style.display = 'block'; |
| } |
| } |
| |
| function seekFromProgress(e) { |
| if (!combinedAudioElement) return; |
| const rect = progressContainer.getBoundingClientRect(); |
| const pos = (e.clientX - rect.left) / rect.width; |
| const duration = combinedAudioElement.duration || videoDuration; |
| let seekTime = pos * duration; |
| const start = parseFloat(startTimeInput.value) || 0; |
| const end = parseFloat(endTimeInput.value) || duration; |
| if (seekTime < start) seekTime = start; |
| if (seekTime > end) seekTime = end; |
| combinedAudioElement.currentTime = seekTime; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = seekTime; |
| |
| if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.seekTo) { |
| p.contentInstance.seekTo(seekTime); |
| } |
| }); |
| |
| if (window.MIDIPane) window.MIDIPane.seekAll(seekTime); |
| |
| if (window.MusicXMLPane) window.MusicXMLPane.seekAll(seekTime); |
| updateProgress(); |
| if (!isPlaying) { |
| playMedia(); |
| } |
| } |
| |
| |
| |
| function applyVolume() { |
| if (!combinedAudioElement) return; |
| const baseVol = parseFloat(volumeSlider.value); |
| const globalVol = parseFloat(globalVolumeSlider.value) / 10; |
| const finalVol = Math.min(1, baseVol * globalVol); |
| combinedAudioElement.volume = finalVol; |
| volumeBtn.textContent = finalVol === 0 ? '🔇' : (finalVol < 0.3 ? '🔈' : '🔊'); |
| } |
| let isUpdating = false; |
| |
| |
| function applyPlaybackRate(speed) { |
| if (isUpdating) return; |
| isUpdating = true; |
| currentPlaybackRate = speed; |
| if (combinedAudioElement) { |
| combinedAudioElement.playbackRate = speed; |
| } |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid) { |
| vid.playbackRate = speed; |
| vid.preservesPitch = true; |
| vid.mozPreservesPitch = true; |
| vid.webkitPreservesPitch = true; |
| } |
| |
| if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.setSpeed) { |
| p.contentInstance.setSpeed(speed); |
| } |
| }); |
| |
| if (window.MIDIPane) window.MIDIPane.setPlaybackRate(speed); |
| |
| if (window.MusicXMLPane) window.MusicXMLPane.setPlaybackRate(speed); |
| |
| playbackSpeedSlider.value = speed; |
| playbackSpeedValue.textContent = speed.toFixed(2) + 'x'; |
| popupSpeed.value = speed; |
| popupSpeedValue.textContent = speed.toFixed(2) + 'x'; |
| const baseTempo = 84; |
| const tempo = speed * baseTempo; |
| tempoInput.value = (speed * 84).toFixed(1); |
| tempoSpeedValue.textContent = speed.toFixed(2) + 'x'; |
| popupTempo.value = (speed * 84).toFixed(1); |
| popupTempoValue.textContent = speed.toFixed(2) + 'x'; |
| isUpdating = false; |
| } |
| let lastMusicXMLSyncTime = 0; |
| if (combinedAudioElement) { |
| combinedAudioElement.addEventListener('timeupdate', function() { |
| const t = this.currentTime; |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid && Math.abs(vid.currentTime - t) > 0.1) { |
| vid.currentTime = t; |
| } |
| }); |
| |
| if (window.MIDIPane && Math.floor(t * 10) % 2 === 0) { |
| window.MIDIPane.syncAll(t); |
| } |
| |
| const now = Date.now(); |
| if (now - lastMusicXMLSyncTime > 100) { |
| if (window.MusicXMLPane) { |
| window.MusicXMLPane.syncAll(t); |
| } |
| lastMusicXMLSyncTime = now; |
| } |
| }); |
| } |
| |
| |
| function applyTempo(tempo) { |
| const baseTempo = 84; |
| const speed = Math.max(0.01, Math.min(5, tempo / baseTempo)); |
| applyPlaybackRate(speed); |
| } |
| |
| |
| |
| playPauseBtn.addEventListener('click', togglePlayPause); |
| |
| |
| resetStartBtn.addEventListener('click', resetToStart); |
| |
| |
| progressContainer.addEventListener('click', seekFromProgress); |
| progressContainer.addEventListener('mousemove', function(e) { |
| if (!combinedAudioElement) return; |
| const rect = progressContainer.getBoundingClientRect(); |
| const pos = (e.clientX - rect.left) / rect.width; |
| const duration = combinedAudioElement.duration || videoDuration; |
| const time = pos * duration; |
| const mins = Math.floor(time / 60); |
| const secs = Math.floor(time % 60); |
| progressTime.textContent = `${String(mins).padStart(2,'0')}:${String(secs).padStart(2,'0')}`; |
| progressTime.style.display = 'block'; |
| progressTime.style.left = (pos * 100) + '%'; |
| }); |
| progressContainer.addEventListener('mouseleave', function() { |
| progressTime.style.display = 'none'; |
| }); |
| |
| |
| volumeSlider.addEventListener('change', function() { |
| applyVolume(); |
| }); |
| volumeBtn.addEventListener('click', function() { |
| const vol = parseFloat(volumeSlider.value); |
| volumeSlider.value = vol > 0 ? 0 : 1; |
| applyVolume(); |
| }); |
| |
| function updateFullscreenScale() {} |
| |
| |
| document.addEventListener('fullscreenchange', updateFullscreenScale); |
| window.addEventListener('resize', updateFullscreenScale); |
| |
| |
| fullscreenBtn.addEventListener('click', function() { |
| const container = document.querySelector('.player-wrapper'); |
| if (!document.fullscreenElement) { |
| container.requestFullscreen().catch(e => {}); |
| } else { |
| document.exitFullscreen().catch(e => {}); |
| } |
| }); |
| let controlsTimeout = null; |
| |
| function showControls() { |
| const controls = document.querySelector('.player-wrapper:fullscreen .video-controls'); |
| if (controls) { |
| controls.classList.remove('hidden'); |
| clearTimeout(controlsTimeout); |
| controlsTimeout = setTimeout(() => { |
| controls.classList.add('hidden'); |
| }, 3000); |
| } |
| } |
| |
| |
| document.addEventListener('fullscreenchange', () => { |
| const wrapper = document.querySelector('.player-wrapper'); |
| if (document.fullscreenElement === wrapper) { |
| wrapper.addEventListener('mousemove', showControls); |
| wrapper.addEventListener('touchstart', showControls); |
| wrapper.addEventListener('click', showControls); |
| showControls(); |
| } else { |
| wrapper.removeEventListener('mousemove', showControls); |
| wrapper.removeEventListener('touchstart', showControls); |
| wrapper.removeEventListener('click', showControls); |
| const controls = document.querySelector('.video-controls'); |
| if (controls) controls.classList.remove('hidden'); |
| } |
| }); |
| |
| |
| settingsToggleBtn.addEventListener('click', function(e) { |
| e.stopPropagation(); |
| settingsPopup.classList.toggle('visible'); |
| }); |
| document.addEventListener('click', function(e) { |
| if (!settingsPopup.contains(e.target) && e.target !== settingsToggleBtn) { |
| settingsPopup.classList.remove('visible'); |
| } |
| }); |
| |
| |
| popupSpeed.addEventListener('change', function() { |
| const speed = parseFloat(this.value); |
| applyPlaybackRate(speed); |
| }); |
| popupTempo.addEventListener('change', function() { |
| const tempo = parseFloat(this.value); |
| if (!isNaN(tempo) && tempo > 0) { |
| const speed = tempo / 84; |
| if (speed >= 0.25 && speed <= 4.0) { |
| applyPlaybackRate(speed); |
| } else { |
| const clampedSpeed = Math.max(0.25, Math.min(4.0, speed)); |
| applyPlaybackRate(clampedSpeed); |
| } |
| } |
| }); |
| |
| |
| popupApplyTime.addEventListener('click', function() { |
| const st = parseFloat(popupStartTime.value) || 0; |
| const et = parseFloat(popupEndTime.value) || videoDuration; |
| startTimeInput.value = st; |
| endTimeInput.value = et; |
| loopCheckbox.checked = popupLoop.checked; |
| loopEnabled = popupLoop.checked; |
| |
| if (combinedAudioElement) { |
| const current = combinedAudioElement.currentTime; |
| if (current < st) { |
| combinedAudioElement.currentTime = st; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = |
| st; |
| }); |
| } else if (current > et) { |
| combinedAudioElement.currentTime = st; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = |
| st; |
| }); |
| } |
| } |
| updateProgress(); |
| settingsPopup.classList.remove('visible'); |
| }); |
| |
| addPaneBtn.addEventListener('click', function() { |
| console.log('追加ボタンがクリックされました'); |
| const containerRect = paneContainer.getBoundingClientRect(); |
| const maxX = Math.max(100, containerRect.width - 320); |
| const maxY = Math.max(100, containerRect.height - 220); |
| const x = 20 + Math.random() * maxX; |
| const y = 20 + Math.random() * maxY; |
| const w = 280 + Math.random() * 200; |
| const h = 180 + Math.random() * 150; |
| |
| |
| const xPercent = (x / containerRect.width) * 100; |
| const yPercent = (y / containerRect.height) * 100; |
| const wPercent = (w / containerRect.width) * 100; |
| const hPercent = (h / containerRect.height) * 100; |
| |
| const types = ['sing', 'sing', 'sing', 'other', 'midi', 'musicxml']; |
| const type = types[Math.floor(Math.random() * types.length)]; |
| const file = type === 'sing' ? ['s', 'a', 't'][Math.floor(Math.random() * 3)] : 'p'; |
| createPane(type, file, xPercent, yPercent, wPercent, hPercent); |
| }); |
| |
| paneContainer.addEventListener('contextmenu', function(e) { |
| e.preventDefault(); |
| const rect = paneContainer.getBoundingClientRect(); |
| const x = e.clientX - rect.left - 50; |
| const y = e.clientY - rect.top - 50; |
| const type = Math.random() > 0.5 ? 'sing' : 'other'; |
| const file = type === 'sing' ? ['s', 'a', 't'][Math.floor(Math.random() * 3)] : 'p'; |
| createPane(type, file, Math.max(0, x), Math.max(0, y), 300, 200); |
| }); |
| |
| |
| setStartTimeBtn.addEventListener('click', function() { |
| if (combinedAudioElement) { |
| startTimeInput.value = combinedAudioElement.currentTime.toFixed(2); |
| popupStartTime.value = combinedAudioElement.currentTime.toFixed(2); |
| updateProgress(); |
| } |
| }); |
| setEndTimeBtn.addEventListener('click', function() { |
| if (combinedAudioElement) { |
| endTimeInput.value = combinedAudioElement.currentTime.toFixed(2); |
| popupEndTime.value = combinedAudioElement.currentTime.toFixed(2); |
| updateProgress(); |
| } |
| }); |
| resetEndTimeBtn.addEventListener('click', function() { |
| endTimeInput.value = videoDuration.toFixed(2); |
| popupEndTime.value = videoDuration.toFixed(2); |
| updateProgress(); |
| }); |
| applyTimeBtn.addEventListener('click', function() { |
| let st = parseFloat(startTimeInput.value) || 0; |
| let et = parseFloat(endTimeInput.value) || videoDuration; |
| if (st > et) { |
| [st, et] = [et, st]; |
| startTimeInput.value = st; |
| endTimeInput.value = et; |
| } |
| loopEnabled = loopCheckbox.checked; |
| popupLoop.checked = loopEnabled; |
| popupStartTime.value = st; |
| popupEndTime.value = et; |
| if (combinedAudioElement) { |
| const current = combinedAudioElement.currentTime; |
| if (current < st) { |
| combinedAudioElement.currentTime = st; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = |
| st; |
| }); |
| } else if (current > et) { |
| combinedAudioElement.currentTime = st; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = |
| st; |
| }); |
| } |
| } |
| |
| updateProgress(); |
| }); |
| loopCheckbox.addEventListener('change', function() { |
| loopEnabled = this.checked; |
| popupLoop.checked = this.checked; |
| }); |
| |
| |
| globalVolumeSlider.addEventListener('change', function() { |
| const val = parseFloat(this.value); |
| globalVolumeValue.textContent = (val / 10).toFixed(1); |
| applyVolume(); |
| }); |
| |
| |
| playbackSpeedSlider.addEventListener('change', function() { |
| const speed = parseFloat(this.value); |
| applyPlaybackRate(speed); |
| }); |
| |
| tempoInput.addEventListener('change', function() { |
| if (isUpdating) return; |
| const tempo = parseFloat(this.value); |
| if (!isNaN(tempo) && tempo > 0) { |
| const speed = tempo / 84; |
| |
| if (speed >= 0.25 && speed <= 4.0) { |
| applyPlaybackRate(speed); |
| } else { |
| |
| console.warn(`速度 ${speed.toFixed(4)} はサポート範囲外です (0.25~4.0)`); |
| |
| const clampedSpeed = Math.max(0.25, Math.min(4.0, speed)); |
| applyPlaybackRate(clampedSpeed); |
| } |
| } |
| }); |
| |
| |
| combineButton.addEventListener('click', combineAudio); |
| |
| |
| presetSelect.addEventListener('change', function() { |
| const val = this.value; |
| if (val === 'custom') return; |
| const vols = PRESET_VOLUMES[val]; |
| if (vols) { |
| applyVolumePreset(vols); |
| } |
| }); |
| |
| |
| savePresetBtn.addEventListener('click', savePreset); |
| renderPresets(); |
| |
| |
| |
| lockControlsBtn.addEventListener('click', function() { |
| |
| }); |
| |
| |
| paneContainer.addEventListener('click', function(e) { |
| const pane = e.target.closest('.pane'); |
| if (pane) { |
| panes.forEach(p => { |
| p.element.style.zIndex = 1; |
| }); |
| pane.style.zIndex = 10; |
| } |
| }); |
| |
| |
| loadPreset(savedPresets[0] || defaultPresets[0]); |
| |
| const markerContainer = document.getElementById('time-markers-container'); |
| timeMarkers.forEach(m => { |
| const el = document.createElement('div'); |
| el.className = 'time-marker'; |
| el.innerHTML = `<b>${m.label}</b>(${m.time}秒)`; |
| el.dataset.time = m.time; |
| el.draggable = true; |
| el.addEventListener('dragstart', function(e) { |
| e.dataTransfer.setData('text/plain', m.time); |
| }); |
| el.addEventListener('click', function() { |
| startTimeInput.value = m.time; |
| popupStartTime.value = m.time; |
| updateProgress(); |
| }); |
| markerContainer.appendChild(el); |
| }); |
| |
| |
| [startTimeInput, endTimeInput, popupStartTime, popupEndTime].forEach(input => { |
| input.addEventListener('dragover', e => e.preventDefault()); |
| input.addEventListener('drop', function(e) { |
| e.preventDefault(); |
| const time = parseFloat(e.dataTransfer.getData('text/plain')); |
| if (!isNaN(time)) { |
| this.value = time; |
| updateProgress(); |
| } |
| }); |
| }); |
| |
| |
| audioSliders.forEach(slider => { |
| slider.addEventListener('change', function() { |
| const val = parseFloat(this.value); |
| const display = this.parentElement.querySelector('.volume-value'); |
| if (display) display.textContent = val.toFixed(2); |
| const percent = val * 100; |
| this.style.backgroundSize = `${percent}% 100%`; |
| }); |
| }); |
| ffmpegVolumeSlider.addEventListener('change', function() { |
| const val = parseFloat(this.value); |
| ffmpegVolumeValue.textContent = val.toFixed(2); |
| const percent = (val / 10) * 100; |
| this.style.backgroundSize = `${percent}% 100%`; |
| }); |
| |
| |
| disablePlayerControls(); |
| |
| |
| document.addEventListener('fullscreenchange', function() { |
| |
| }); |
| |
| |
| setInterval(() => { |
| if (combinedAudioElement && !isDragging) { |
| updateProgress(); |
| } |
| }, 100); |
| |
| if (combinedAudioElement) { |
| combinedAudioElement.addEventListener('timeupdate', function() { |
| const t = this.currentTime; |
| panes.forEach(p => { |
| const vid = p.element.querySelector('video'); |
| if (vid && Math.abs(vid.currentTime - t) > 0.1) { |
| vid.currentTime = t; |
| } |
| }); |
| |
| if (window.MIDIPane && Math.floor(t * 10) % 2 === 0) { |
| window.MIDIPane.syncAll(t); |
| } |
| }); |
| } |
| |
| function initMarkerDrag() { |
| const startMarker = document.getElementById('start-marker'); |
| const endMarker = document.getElementById('end-marker'); |
| |
| function onMarkerMouseDown(e, type) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| isDraggingMarker = true; |
| draggingMarker = type; |
| const onMove = (ev) => { |
| const rect = progressContainer.getBoundingClientRect(); |
| let percent = (ev.clientX - rect.left) / rect.width; |
| percent = Math.max(0, Math.min(1, percent)); |
| const duration = combinedAudioElement ? combinedAudioElement.duration : videoDuration; |
| if (!duration) return; |
| const time = percent * duration; |
| |
| const marker = type === 'start' ? startMarker : endMarker; |
| marker.style.left = (percent * 100) + '%'; |
| |
| }; |
| const onUp = () => { |
| document.removeEventListener('mousemove', onMove); |
| document.removeEventListener('mouseup', onUp); |
| const marker = type === 'start' ? startMarker : endMarker; |
| const percent = parseFloat(marker.style.left) / 100; |
| const duration = combinedAudioElement ? combinedAudioElement.duration : videoDuration; |
| let time = percent * duration; |
| |
| |
| let start = parseFloat(startTimeInput.value) || 0; |
| let end = parseFloat(endTimeInput.value) || videoDuration; |
| |
| if (type === 'start') { |
| |
| if (time > end) time = end; |
| startTimeInput.value = time.toFixed(2); |
| popupStartTime.value = time.toFixed(2); |
| } else { |
| |
| if (time < start) time = start; |
| endTimeInput.value = time.toFixed(2); |
| popupEndTime.value = time.toFixed(2); |
| } |
| |
| |
| if (combinedAudioElement) { |
| let cur = combinedAudioElement.currentTime; |
| if (cur < parseFloat(startTimeInput.value)) { |
| cur = parseFloat(startTimeInput.value); |
| } else if (cur > parseFloat(endTimeInput.value)) { |
| cur = parseFloat(endTimeInput.value); |
| } |
| combinedAudioElement.currentTime = cur; |
| panes.forEach(p => { |
| const v = p.element.querySelector('video'); |
| if (v) v.currentTime = cur; |
| }); |
| } |
| |
| draggingMarker = null; |
| isDraggingMarker = false; |
| updateProgress(); |
| }; |
| document.addEventListener('mousemove', onMove); |
| document.addEventListener('mouseup', onUp); |
| } |
| |
| startMarker.addEventListener('mousedown', (e) => onMarkerMouseDown(e, 'start')); |
| endMarker.addEventListener('mousedown', (e) => onMarkerMouseDown(e, 'end')); |
| } |
| |
| function waitForScripts() { |
| return new Promise((resolve) => { |
| |
| const requiredScripts = [ |
| 'Draggabilly', |
| 'MIDIPane', |
| 'MusicXMLPane' |
| ]; |
| |
| let allLoaded = false; |
| |
| function checkScripts() { |
| const loaded = requiredScripts.every(name => { |
| if (name === 'Draggabilly') { |
| return typeof Draggabilly === 'function'; |
| } else if (name === 'MIDIPane') { |
| return window.MIDIPane && typeof window.MIDIPane.create === 'function'; |
| } else if (name === 'MusicXMLPane') { |
| return window.MusicXMLPane && typeof window.MusicXMLPane.create === 'function'; |
| } |
| return false; |
| }); |
| |
| if (loaded) { |
| allLoaded = true; |
| resolve(); |
| } else { |
| |
| setTimeout(checkScripts, 100); |
| } |
| } |
| |
| |
| checkScripts(); |
| |
| |
| setTimeout(() => { |
| if (!allLoaded) { |
| console.warn('⚠️ スクリプト読み込みタイムアウト、強制続行'); |
| resolve(); |
| } |
| }, 10000); |
| }); |
| } |
| |
| async function initializePlayer() { |
| |
| if (isInitialized) { |
| console.log('⚠️ 既に初期化済みです'); |
| return; |
| } |
| |
| console.log('🚀 プレイヤー初期化開始...'); |
| createOverlay(); |
| showOverlay('音声を合成してください'); |
| |
| |
| const loadingOverlay = document.getElementById('loadingOverlay'); |
| const loadingProgress = document.getElementById('loading-progress'); |
| |
| if (loadingOverlay) { |
| loadingOverlay.style.display = 'flex'; |
| loadingOverlay.style.opacity = '1'; |
| } |
| |
| |
| function updateLoadingStatus(message) { |
| if (loadingProgress) { |
| loadingProgress.textContent = message; |
| } |
| console.log(`📊 ${message}`); |
| } |
| |
| try { |
| |
| updateLoadingStatus('スクリプトを読み込み中...'); |
| await waitForScripts(); |
| updateLoadingStatus('スクリプト読み込み完了 ✅'); |
| |
| |
| updateLoadingStatus('動画ファイルをプリロード中...'); |
| await preloadVideos(); |
| updateLoadingStatus(`動画プリロード完了 ✅ (${Object.keys(videoBlobCache).length}ファイル)`); |
| |
| } catch (e) { |
| console.error('プリロード中にエラー:', e); |
| updateLoadingStatus('⚠️ 読み込みエラーが発生しました'); |
| } |
| |
| try { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| isAudioCombined = false; |
| combinedAudioElement = null; |
| disablePlayerControls(); |
| |
| updateLoadingStatus('プリセット読み込み完了 ✅'); |
| console.log('✅ プリセット読み込み完了'); |
| } catch (e) { |
| console.error('プリセット読み込み中にエラー:', e); |
| updateLoadingStatus('⚠️ プリセット読み込みエラー'); |
| |
| if (defaultPresets[0] && defaultPresets[0].panes) { |
| defaultPresets[0].panes.forEach((p, index) => { |
| createPane(p.type, p.file || 's', p.x ?? (50 + index * 30), p.y ?? (50 + index * 20), p.w ?? 300, p.h ?? 200); |
| }); |
| } |
| } |
| |
| |
| updateLoadingStatus('準備完了!'); |
| |
| if (loadingOverlay) { |
| |
| await new Promise(resolve => setTimeout(resolve, 500)); |
| |
| loadingOverlay.style.transition = 'opacity 0.8s ease-out'; |
| loadingOverlay.style.opacity = '0'; |
| setTimeout(() => { |
| loadingOverlay.style.display = 'none'; |
| }, 800); |
| } |
| |
| |
| isInitialized = true; |
| console.log('✅ プレイヤー初期化完了'); |
| } |
| |
| setTimeout(() => { |
| const loadingOverlay = document.getElementById('loadingOverlay'); |
| if (loadingOverlay && loadingOverlay.style.display !== 'none') { |
| console.warn('⚠️ 強制タイムアウト: ローディング画面を強制終了'); |
| loadingOverlay.style.transition = 'opacity 0.5s'; |
| loadingOverlay.style.opacity = '0'; |
| setTimeout(() => { |
| loadingOverlay.style.display = 'none'; |
| }, 500); |
| } |
| }, 15000); |
| |
| initializePlayer().catch(console.error); |
| |
| initMarkerDrag(); |
| |
| |
| |
| |
| async function seekToWithLoading(time, showNotification = true) { |
| if (!combinedAudioElement) return; |
| |
| const duration = combinedAudioElement.duration || videoDuration; |
| const start = parseFloat(startTimeInput.value) || 0; |
| const end = parseFloat(endTimeInput.value) || duration; |
| |
| |
| const clampedTime = Math.max(start, Math.min(end, time)); |
| |
| |
| if (Math.abs(combinedAudioElement.currentTime - clampedTime) < 0.01) return; |
| |
| |
| combinedAudioElement.currentTime = clampedTime; |
| |
| |
| const syncPromises = []; |
| const desyncedPanes = []; |
| |
| |
| panes.forEach(p => { |
| if (p.type === 'sing' || p.type === 'other') { |
| const video = p.element.querySelector('video'); |
| if (video) { |
| const diff = Math.abs(video.currentTime - clampedTime); |
| if (diff > 0.1) { |
| desyncedPanes.push({ |
| pane: p, |
| element: video, |
| type: 'video', |
| diff: diff |
| }); |
| } |
| } |
| } |
| }); |
| |
| |
| panes.forEach(p => { |
| if (p.type === 'musicxml' && p.contentInstance) { |
| const current = p.contentInstance.currentTime; |
| if (current !== undefined && Math.abs(current - clampedTime) > 0.1) { |
| desyncedPanes.push({ |
| pane: p, |
| element: p.contentInstance, |
| type: 'musicxml', |
| diff: Math.abs(current - clampedTime) |
| }); |
| } |
| } |
| }); |
| |
| |
| panes.forEach(p => { |
| if (p.type === 'midi' && p.contentInstance) { |
| let current = null; |
| if (typeof p.contentInstance.getCurrentTime === 'function') { |
| current = p.contentInstance.getCurrentTime(); |
| } |
| if (current !== null && Math.abs(current - clampedTime) > 0.1) { |
| desyncedPanes.push({ |
| pane: p, |
| element: p.contentInstance, |
| type: 'midi', |
| diff: Math.abs(current - clampedTime) |
| }); |
| } |
| } |
| }); |
| |
| |
| if (desyncedPanes.length > 0) { |
| console.log(`🎯 シーク: ${desyncedPanes.length}個のペインがずれています (しきい値: 0.1秒)`); |
| |
| desyncedPanes.forEach(({ |
| pane, |
| element, |
| type, |
| diff |
| }) => { |
| console.log(` → ペイン${pane.id}(${type}): ずれ ${diff.toFixed(3)}秒を修正`); |
| |
| if (type === 'video') { |
| |
| element.currentTime = clampedTime; |
| |
| if (element.readyState < HTMLMediaElement.HAVE_ENOUGH_DATA) { |
| const promise = new Promise((resolve) => { |
| let resolved = false; |
| const onReady = () => { |
| if (!resolved) { |
| resolved = true; |
| resolve(); |
| } |
| }; |
| element.addEventListener('canplaythrough', onReady, { |
| once: true |
| }); |
| element.addEventListener('loadeddata', onReady, { |
| once: true |
| }); |
| element.addEventListener('error', () => { |
| if (!resolved) { |
| resolved = true; |
| resolve(); |
| } |
| }, { |
| once: true |
| }); |
| setTimeout(() => { |
| if (!resolved) { |
| resolved = true; |
| resolve(); |
| } |
| }, 300); |
| }); |
| syncPromises.push(promise); |
| } |
| } else if (type === 'musicxml' && typeof element.seekTo === 'function') { |
| |
| element.seekTo(clampedTime); |
| } else if (type === 'midi' && typeof element.seekTo === 'function') { |
| |
| element.seekTo(clampedTime); |
| } |
| }); |
| |
| |
| if (syncPromises.length > 0) { |
| await Promise.all(syncPromises); |
| } |
| |
| console.log(`✅ ${desyncedPanes.length}個のペインを個別シーク完了`); |
| } else { |
| |
| console.log('✅ ずれているペインはありません(一括シークはスキップ)'); |
| } |
| |
| |
| |
| |
| if (window.MIDIPane && typeof window.MIDIPane.seekAll === 'function') { |
| |
| const midiPanes = panes.filter(p => p.type === 'midi'); |
| let needGlobalSeek = false; |
| midiPanes.forEach(p => { |
| if (p.contentInstance) { |
| const current = typeof p.contentInstance.getCurrentTime === 'function' ? |
| p.contentInstance.getCurrentTime() : |
| null; |
| if (current !== null && Math.abs(current - clampedTime) > 0.1) { |
| needGlobalSeek = true; |
| } |
| } |
| }); |
| if (needGlobalSeek) { |
| window.MIDIPane.seekAll(clampedTime); |
| } |
| } |
| |
| if (window.MusicXMLPane && typeof window.MusicXMLPane.seekAll === 'function') { |
| const xmlPanes = panes.filter(p => p.type === 'musicxml'); |
| let needGlobalSeek = false; |
| xmlPanes.forEach(p => { |
| if (p.contentInstance) { |
| const current = p.contentInstance.currentTime; |
| if (current !== undefined && Math.abs(current - clampedTime) > 0.1) { |
| needGlobalSeek = true; |
| } |
| } |
| }); |
| if (needGlobalSeek) { |
| window.MusicXMLPane.seekAll(clampedTime); |
| } |
| } |
| |
| |
| panes.forEach(p => { |
| if (p.type === 'musicxml' && p.contentInstance && typeof p.contentInstance.seekTo === 'function') { |
| const current = p.contentInstance.currentTime; |
| if (current !== undefined && Math.abs(current - clampedTime) > 0.1) { |
| p.contentInstance.seekTo(clampedTime); |
| } |
| } |
| }); |
| |
| |
| if (showNotification) { |
| showSeekNotification(null, clampedTime); |
| } |
| |
| updateProgress(); |
| } |
| |
| |
| async function seekRelative(amount) { |
| if (!combinedAudioElement) return; |
| const duration = combinedAudioElement.duration || videoDuration; |
| const start = parseFloat(startTimeInput.value) || 0; |
| const end = parseFloat(endTimeInput.value) || duration; |
| let newTime = combinedAudioElement.currentTime + amount; |
| newTime = Math.max(start, Math.min(end, newTime)); |
| await seekToWithLoading(newTime, true); |
| |
| showSeekNotification(amount, newTime); |
| } |
| |
| |
| document.addEventListener('keydown', function(event) { |
| |
| const target = event.target; |
| if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { |
| return; |
| } |
| if (target.closest('details')) { |
| return; |
| } |
| |
| const key = event.key; |
| const ctrl = event.ctrlKey || event.metaKey; |
| const shift = event.shiftKey; |
| |
| |
| if (window._isSeeking) { |
| event.preventDefault(); |
| return; |
| } |
| |
| |
| if (key === 'k' || key === ' ') { |
| event.preventDefault(); |
| togglePlayPause(); |
| return; |
| } |
| |
| |
| const SEEK_STEP = 5; |
| let seekAmount = 0; |
| let seekDirection = null; |
| |
| if (key === 'ArrowRight' || key === 'l' || key === 'L') { |
| seekDirection = 'forward'; |
| } else if (key === 'ArrowLeft' || key === 'j' || key === 'J') { |
| seekDirection = 'backward'; |
| } |
| |
| if (seekDirection) { |
| event.preventDefault(); |
| if (!combinedAudioElement) return; |
| |
| |
| if (!event.repeat) { |
| seekAmount = SEEK_STEP; |
| } else { |
| seekAmount = SEEK_STEP * 2; |
| } |
| |
| if (seekDirection === 'backward') { |
| seekAmount = -seekAmount; |
| } |
| |
| window._isSeeking = true; |
| seekRelative(seekAmount).finally(() => { |
| window._isSeeking = false; |
| }); |
| return; |
| } |
| |
| |
| if (key >= '0' && key <= '9') { |
| event.preventDefault(); |
| if (!combinedAudioElement) return; |
| |
| const duration = combinedAudioElement.duration || videoDuration; |
| const start = parseFloat(startTimeInput.value) || 0; |
| const end = parseFloat(endTimeInput.value) || duration; |
| const range = end - start; |
| |
| const num = parseInt(key); |
| let newTime; |
| if (num === 0) { |
| newTime = start; |
| } else if (num === 9) { |
| newTime = end; |
| } else { |
| newTime = start + (range * (num / 10)); |
| } |
| |
| window._isSeeking = true; |
| seekToWithLoading(newTime, true).finally(() => { |
| window._isSeeking = false; |
| }); |
| return; |
| } |
| |
| |
| if (key === 'f' || key === 'F') { |
| event.preventDefault(); |
| const container = document.querySelector('.player-wrapper'); |
| if (!document.fullscreenElement) { |
| container.requestFullscreen().catch(e => {}); |
| } else { |
| document.exitFullscreen().catch(e => {}); |
| } |
| return; |
| } |
| |
| |
| if (key === 'ArrowUp') { |
| event.preventDefault(); |
| let vol = parseFloat(volumeSlider.value) + 0.1; |
| vol = Math.min(1, Math.max(0, vol)); |
| volumeSlider.value = vol; |
| applyVolume(); |
| showVolumeNotification(vol, vol === 0); |
| return; |
| } |
| if (key === 'ArrowDown') { |
| event.preventDefault(); |
| let vol = parseFloat(volumeSlider.value) - 0.1; |
| vol = Math.min(1, Math.max(0, vol)); |
| volumeSlider.value = vol; |
| applyVolume(); |
| showVolumeNotification(vol, vol === 0); |
| return; |
| } |
| |
| |
| if (key === 'm' || key === 'M') { |
| event.preventDefault(); |
| const vol = parseFloat(volumeSlider.value); |
| if (vol > 0) { |
| volumeSlider.dataset.previousVol = vol; |
| volumeSlider.value = 0; |
| applyVolume(); |
| showVolumeNotification(0, true); |
| } else { |
| const prev = parseFloat(volumeSlider.dataset.previousVol) || 0.5; |
| volumeSlider.value = Math.min(1, prev); |
| applyVolume(); |
| showVolumeNotification(parseFloat(volumeSlider.value), false); |
| } |
| return; |
| } |
| |
| |
| if (key === ',' || key === '<') { |
| event.preventDefault(); |
| let speed = parseFloat(playbackSpeedSlider.value) - 0.1; |
| speed = Math.max(0.01, Math.min(5, speed)); |
| applyPlaybackRate(speed); |
| showSpeedNotification(speed); |
| return; |
| } |
| if (key === '.' || key === '>') { |
| event.preventDefault(); |
| let speed = parseFloat(playbackSpeedSlider.value) + 0.1; |
| speed = Math.max(0.01, Math.min(5, speed)); |
| applyPlaybackRate(speed); |
| showSpeedNotification(speed); |
| return; |
| } |
| }); |
| |
| |
| function showSeekNotification(amount, time) { |
| const oldNotify = document.querySelector('.seek-notification'); |
| if (oldNotify) oldNotify.remove(); |
| |
| const notif = document.createElement('div'); |
| notif.className = 'seek-notification'; |
| const mins = Math.floor(time / 60); |
| const secs = Math.floor(time % 60); |
| const ms = Math.floor((time % 1) * 100); |
| let text = `${String(mins).padStart(2,'0')}:${String(secs).padStart(2,'0')}.${String(ms).padStart(2,'0')}`; |
| if (amount !== null) { |
| const sign = amount > 0 ? '+' : ''; |
| text = `${sign}${amount.toFixed(1)}s → ${text}`; |
| } |
| notif.textContent = text; |
| notif.style.cssText = ` |
| position: fixed; |
| bottom: 80px; |
| left: 50%; |
| transform: translateX(-50%); |
| background: rgba(0,0,0,0.85); |
| color: #64ffda; |
| padding: 8px 20px; |
| border-radius: 8px; |
| font-size: 20px; |
| font-family: monospace; |
| z-index: 9999; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 0.15s ease; |
| border: 1px solid rgba(100,255,218,0.3); |
| user-select: none; |
| `; |
| document.body.appendChild(notif); |
| |
| requestAnimationFrame(() => { |
| notif.style.opacity = '1'; |
| }); |
| |
| setTimeout(() => { |
| notif.style.opacity = '0'; |
| setTimeout(() => notif.remove(), 300); |
| }, 1200); |
| } |
| |
| function showVolumeNotification(vol, isMuted) { |
| const oldNotify = document.querySelector('.volume-notification'); |
| if (oldNotify) oldNotify.remove(); |
| |
| const notif = document.createElement('div'); |
| notif.className = 'volume-notification'; |
| const icon = isMuted ? '🔇' : (vol === 0 ? '🔇' : (vol < 0.3 ? '🔈' : (vol < 0.7 ? '🔉' : '🔊'))); |
| notif.textContent = `${icon} ${Math.round(vol * 100)}%`; |
| notif.style.cssText = ` |
| position: fixed; |
| bottom: 130px; |
| left: 50%; |
| transform: translateX(-50%); |
| background: rgba(0,0,0,0.85); |
| color: #e6f1ff; |
| padding: 6px 16px; |
| border-radius: 8px; |
| font-size: 18px; |
| font-family: sans-serif; |
| z-index: 9999; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 0.15s ease; |
| border: 1px solid rgba(100,255,218,0.2); |
| user-select: none; |
| `; |
| document.body.appendChild(notif); |
| |
| requestAnimationFrame(() => { |
| notif.style.opacity = '1'; |
| }); |
| |
| setTimeout(() => { |
| notif.style.opacity = '0'; |
| setTimeout(() => notif.remove(), 300); |
| }, 1000); |
| } |
| |
| function showSpeedNotification(speed) { |
| const oldNotify = document.querySelector('.speed-notification'); |
| if (oldNotify) oldNotify.remove(); |
| |
| const notif = document.createElement('div'); |
| notif.className = 'speed-notification'; |
| notif.textContent = `⏱ ${speed.toFixed(2)}x`; |
| notif.style.cssText = ` |
| position: fixed; |
| bottom: 180px; |
| left: 50%; |
| transform: translateX(-50%); |
| background: rgba(0,0,0,0.85); |
| color: #97c2f0; |
| padding: 6px 16px; |
| border-radius: 8px; |
| font-size: 18px; |
| font-family: sans-serif; |
| z-index: 9999; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 0.15s ease; |
| border: 1px solid rgba(100,255,218,0.2); |
| user-select: none; |
| `; |
| document.body.appendChild(notif); |
| |
| requestAnimationFrame(() => { |
| notif.style.opacity = '1'; |
| }); |
| |
| setTimeout(() => { |
| notif.style.opacity = '0'; |
| setTimeout(() => notif.remove(), 300); |
| }, 1000); |
| } |
| |
| function syncSinglePane(paneInfo, seekTime) { |
| return new Promise((resolve) => { |
| if (!paneInfo || seekTime === undefined) { |
| resolve(); |
| return; |
| } |
| |
| let element = null; |
| let needsWait = false; |
| |
| |
| if (paneInfo.type === 'sing' || paneInfo.type === 'other') { |
| element = paneInfo.element.querySelector('video'); |
| if (element) { |
| element.currentTime = seekTime; |
| if (element.readyState < HTMLMediaElement.HAVE_ENOUGH_DATA) { |
| needsWait = true; |
| const onReady = () => { |
| element.removeEventListener('canplaythrough', onReady); |
| element.removeEventListener('loadeddata', onReady); |
| resolve(); |
| }; |
| element.addEventListener('canplaythrough', onReady, { |
| once: true |
| }); |
| element.addEventListener('loadeddata', onReady, { |
| once: true |
| }); |
| setTimeout(resolve, 500); |
| } |
| } |
| } else if (paneInfo.type === 'musicxml' && paneInfo.contentInstance) { |
| if (typeof paneInfo.contentInstance.seekTo === 'function') { |
| paneInfo.contentInstance.seekTo(seekTime); |
| } |
| } else if (paneInfo.type === 'midi' && paneInfo.contentInstance) { |
| if (typeof paneInfo.contentInstance.seekTo === 'function') { |
| paneInfo.contentInstance.seekTo(seekTime); |
| } |
| } |
| |
| if (!needsWait) { |
| resolve(); |
| } |
| }); |
| } |
| |
| |
| async function syncOnlyDesyncedPanes(masterTime, threshold = 0.12) { |
| const desyncedPanes = []; |
| const syncPromises = []; |
| |
| |
| panes.forEach(p => { |
| let currentTime = null; |
| |
| if (p.type === 'sing' || p.type === 'other') { |
| const video = p.element.querySelector('video'); |
| if (video) currentTime = video.currentTime; |
| } else if (p.type === 'musicxml' && p.contentInstance) { |
| currentTime = p.contentInstance.currentTime; |
| } else if (p.type === 'midi' && p.contentInstance) { |
| if (typeof p.contentInstance.getCurrentTime === 'function') { |
| currentTime = p.contentInstance.getCurrentTime(); |
| } |
| } |
| |
| if (currentTime !== null && !isNaN(currentTime)) { |
| const diff = Math.abs(currentTime - masterTime); |
| if (diff > threshold) { |
| desyncedPanes.push({ |
| pane: p, |
| currentTime: currentTime, |
| diff: diff |
| }); |
| } |
| } |
| }); |
| |
| |
| if (desyncedPanes.length > 0) { |
| console.log(`🔄 ${desyncedPanes.length}個のペインがずれています。個別にシークします。`); |
| |
| desyncedPanes.forEach(({ |
| pane, |
| currentTime, |
| diff |
| }) => { |
| console.log(` ペイン${pane.id}(${pane.type}): ${currentTime.toFixed(3)} (ずれ ${diff.toFixed(3)}) → シーク`); |
| syncPromises.push(syncSinglePane(pane, masterTime)); |
| }); |
| |
| |
| await Promise.all(syncPromises); |
| console.log(`✅ ${desyncedPanes.length}個のペインをシーク完了`); |
| } else { |
| console.log('✅ ずれているペインはありません'); |
| } |
| |
| return desyncedPanes.length; |
| } |
| |
| let isSyncing = false; |
| setInterval(async () => { |
| if (!combinedAudioElement || !isPlaying || isSyncing) return; |
| const masterTime = combinedAudioElement.currentTime; |
| if (masterTime === 0) return; |
| |
| |
| let maxDiff = 0; |
| const diffs = []; |
| |
| panes.forEach(p => { |
| let currentTime = null; |
| if (p.type === 'sing' || p.type === 'other') { |
| const video = p.element.querySelector('video'); |
| if (video) currentTime = video.currentTime; |
| } else if (p.type === 'musicxml' && p.contentInstance) { |
| currentTime = p.contentInstance.currentTime; |
| } else if (p.type === 'midi' && p.contentInstance && typeof p.contentInstance.getCurrentTime === 'function') { |
| currentTime = p.contentInstance.getCurrentTime(); |
| } |
| if (currentTime !== null && !isNaN(currentTime)) { |
| const diff = Math.abs(currentTime - masterTime); |
| diffs.push({ |
| id: p.id, |
| type: p.type, |
| diff, |
| currentTime |
| }); |
| if (diff > maxDiff) maxDiff = diff; |
| } |
| }); |
| |
| |
| if (maxDiff > 0.12 && !isSyncing) { |
| isSyncing = true; |
| console.log('⚠️ ずれが大きいため再シークします'); |
| |
| |
| await syncOnlyDesyncedPanes(masterTime, 0.12); |
| |
| |
| if (isPlaying) { |
| |
| panes.forEach(p => { |
| const video = p.element.querySelector('video'); |
| if (video && video.paused) { |
| video.play().catch(() => {}); |
| } |
| if (p.type === 'musicxml' && p.contentInstance && typeof p.contentInstance.play === 'function') { |
| p.contentInstance.play(); |
| } |
| }); |
| if (window.MIDIPane && typeof window.MIDIPane.playAll === 'function') { |
| window.MIDIPane.playAll(); |
| } |
| if (window.MusicXMLPane && typeof window.MusicXMLPane.playAll === 'function') { |
| window.MusicXMLPane.playAll(); |
| } |
| } |
| |
| isSyncing = false; |
| } |
| }, 4000); |
| |
| |
| function createOverlay() { |
| |
| if (overlayElement) { |
| overlayElement.remove(); |
| overlayElement = null; |
| } |
| |
| |
| const wrapper = document.querySelector('.player-wrapper'); |
| if (!wrapper) return; |
| |
| |
| const existingOverlay = wrapper.querySelector('.player-overlay'); |
| if (existingOverlay) { |
| overlayElement = existingOverlay; |
| return; |
| } |
| |
| |
| overlayElement = document.createElement('div'); |
| overlayElement.className = 'player-overlay active'; |
| overlayElement.id = 'player-overlay'; |
| overlayElement.innerHTML = ` |
| <div class="overlay-message"> |
| <h2>音声を合成してください</h2> |
| <p>「音声を合成」ボタンを押してください</p> |
| <div class="spinner-text"> |
| <div class="loader-small"></div> |
| <span style="color:#97c2f0;font-size:14px;">...</span> |
| </div> |
| </div> |
| `; |
| |
| |
| const videoControls = wrapper.querySelector('.video-controls'); |
| if (videoControls) { |
| wrapper.insertBefore(overlayElement, videoControls); |
| } else { |
| wrapper.appendChild(overlayElement); |
| } |
| } |
| |
| function updateOverlayMessage(status, isError = false) { |
| if (!overlayElement) { |
| createOverlay(); |
| } |
| if (!overlayElement) return; |
| |
| const messageDiv = overlayElement.querySelector('.overlay-message'); |
| if (!messageDiv) return; |
| |
| let html = ''; |
| if (isError) { |
| html = ` |
| <h2 style="color:#ff6b6b;">⚠️ エラーが発生しました</h2> |
| <p>${status}</p> |
| <p style="font-size:14px;margin-top:10px;color:#97c2f0;">もう一度「音声を合成」を押してください</p> |
| `; |
| } else if (status.includes('完了') || status.includes('完了(一部動画読み込み中)')) { |
| |
| hideOverlay(); |
| return; |
| } else { |
| html = ` |
| <h2>${status}</h2> |
| <div class="spinner-text"> |
| <div class="loader-small"></div> |
| </div> |
| `; |
| } |
| |
| messageDiv.innerHTML = html; |
| overlayElement.classList.add('active'); |
| overlayElement.style.display = 'flex'; |
| overlayElement.style.opacity = '1'; |
| } |
| |
| function hideOverlay() { |
| if (overlayElement) { |
| overlayElement.classList.remove('active'); |
| overlayElement.style.opacity = '0'; |
| setTimeout(() => { |
| if (overlayElement) { |
| overlayElement.style.display = 'none'; |
| } |
| }, 500); |
| } |
| } |
| |
| function showOverlay(message) { |
| if (!overlayElement) { |
| createOverlay(); |
| } |
| if (!overlayElement) return; |
| |
| overlayElement.style.display = 'flex'; |
| overlayElement.style.opacity = '1'; |
| overlayElement.classList.add('active'); |
| |
| const messageDiv = overlayElement.querySelector('.overlay-message'); |
| if (messageDiv) { |
| messageDiv.innerHTML = ` |
| <h2>${message}</h2> |
| <div class="spinner-text"> |
| <div class="loader-small"></div> |
| </div> |
| `; |
| } |
| } |
| |
| function enablePlayerControls() { |
| playPauseBtn.disabled = false; |
| resetStartBtn.disabled = false; |
| volumeBtn.disabled = false; |
| volumeSlider.disabled = false; |
| fullscreenBtn.disabled = false; |
| settingsToggleBtn.disabled = false; |
| addPaneBtn.disabled = false; |
| startTimeInput.disabled = false; |
| endTimeInput.disabled = false; |
| resetEndTimeBtn.disabled = false; |
| loopCheckbox.disabled = false; |
| globalVolumeSlider.disabled = false; |
| setStartTimeBtn.disabled = false; |
| setEndTimeBtn.disabled = false; |
| playbackSpeedSlider.disabled = false; |
| applyTimeBtn.disabled = false; |
| |
| |
| hideOverlay(); |
| } |
| |
| function disablePlayerControls() { |
| playPauseBtn.disabled = true; |
| resetStartBtn.disabled = true; |
| volumeBtn.disabled = true; |
| volumeSlider.disabled = true; |
| fullscreenBtn.disabled = true; |
| settingsToggleBtn.disabled = true; |
| addPaneBtn.disabled = true; |
| startTimeInput.disabled = true; |
| endTimeInput.disabled = true; |
| resetEndTimeBtn.disabled = true; |
| loopCheckbox.disabled = true; |
| globalVolumeSlider.disabled = true; |
| setStartTimeBtn.disabled = true; |
| setEndTimeBtn.disabled = true; |
| playbackSpeedSlider.disabled = true; |
| applyTimeBtn.disabled = true; |
| |
| |
| showOverlay('音声を合成してください'); |
| } |
| }); |
| </script> |
|
|
| </body> |
|
|
| </html> |