player2 / index.html
izuemon's picture
Update index.html
397811d verified
Raw
History Blame Contribute Delete
171 kB
<!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;
/* ← 追加: 拡大時にコンテンツがはみ出るように */
}
/* MIDI ペイン用 */
.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;
}
/* MusicXML ペイン用 */
.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;
/* 必要に応じて position: absolute; など */
}
.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%遅くする:,または&lt;キー<br>
・再生速度を5%速くする:.または&gt;キー<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">
<!-- ペインはJSで動的に追加 -->
</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 = {}; // fileName -> blob URL
const pendingFetches = {}; // fileName -> Promise (fetch中)
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; // 'start' or 'end'
let isDraggingMarker = false;
// DOM参照
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];
}
// 既にフェッチ中ならそのPromiseを待つ
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 {
// ★ transform-origin を center に設定
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';
// ズームラッパー(100%固定)
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;
// ズーム前の相対位置(0〜1)
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 = '';
}
// ★ 古いタイプを渡してコンテンツを再生成(この時点では info.type はまだ古い)
regeneratePaneContent(id, newType, newFile, oldType);
// ★ コンテンツ再生成後に info を更新
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);
}
// updateZoom は regeneratePaneContent 内で呼ばれるので、ここでは不要な場合がある
// ただし、確実に呼ぶ
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;
}
}
}
});
// ============================================
// ★★★ ペインをDOMに追加 ★★★
// ============================================
if (paneContainer) {
paneContainer.appendChild(pane);
} else {
console.error('paneContainer が見つかりません');
return null;
}
// ---- Draggabilly でドラッグ移動(ヘッダーをハンドルに) ----
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) {
// ★ MusicXML は内部スケールを1倍固定(親要素のサイズ変更で拡大させる)
contentInstance = window.MusicXMLPane.create(zoomWrapper, './mei-ver.mei', {
offset: meiOffset
});
// 内部スケールは1倍固定
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';
// 初期ズームを適用(サイズ100% = 実寸)
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);
}
};
// 既存の panes に追加(既に存在する場合は上書き)
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);
// エラーが起きても DOM に何も追加されないので、必要に応じてエラー表示
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');
// --- 1. 古いコンテンツのインスタンスを破棄 ---
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);
});
}
// --- 2. content を完全にクリア(古い zoom-wrapper も含めてすべて削除) ---
content.innerHTML = '';
// --- 2. 新しい zoom-wrapper を作成 ---
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;
// --- 3. 新しいコンテンツを生成(以下、元のコードと同じ) ---
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;
}
});
}
// --- 4. ズーム状態をリセットして適用 ---
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
});
}
});
// タイムアウト (10秒)
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;
}
// 元の clearPanes 関数は保持(必要に応じて使用)
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()
};
// 既存を置き換え or 追加
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;
}
}
});
// WAV変換
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}`);
}
}
// タイムアウト: 5秒待っても読み込めない場合は強制的に有効化
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) {
// end 超過時の処理(ループ or 停止)
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) {
// start 未満になったら強制的に 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();
}
});
// MIDI も現在時間にシークしてから再生
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();
// ★ MusicXML ペインの一時停止
if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.pause) {
p.contentInstance.pause();
}
});
// MIDI一時停止
if (window.MIDIPane) window.MIDIPane.pauseAll();
// ★ MusicXML 全体一時停止
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;
// ★ MusicXML ペインのシーク
if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.seekTo) {
p.contentInstance.seekTo(st);
}
});
// MIDIシーク
if (window.MIDIPane) window.MIDIPane.seekAll(st);
// ★ MusicXML 全体シーク
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;
// ★ MusicXML ペインのシーク
if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.seekTo) {
p.contentInstance.seekTo(seekTime);
}
});
// MIDIシーク
if (window.MIDIPane) window.MIDIPane.seekAll(seekTime);
// ★ MusicXML 全体シーク
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;
}
// ★ MusicXML ペインの速度設定
if (p.type === 'musicxml' && p.contentInstance && p.contentInstance.setSpeed) {
p.contentInstance.setSpeed(speed);
}
});
// MIDI速度同期
if (window.MIDIPane) window.MIDIPane.setPlaybackRate(speed);
// ★ MusicXML 全体速度
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;
}
});
// MIDI同期(適度な間隔)
if (window.MIDIPane && Math.floor(t * 10) % 2 === 0) {
window.MIDIPane.syncAll(t);
}
// ★ MusicXML 同期(10fps に制限)
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() {
//
});
// timeupdate 更新ループ (UI用)
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;
}
});
// MIDI同期(ただし頻繁すぎるので適度に)
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') {
// start を end より大きくならないように制限
if (time > end) time = end;
startTimeInput.value = time.toFixed(2);
popupStartTime.value = time.toFixed(2);
} else {
// end を start より小さくならないように制限
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', // MIDI表示
'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();
// タイムアウト(10秒で強制終了)
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 {
// 1. 必要なJSスクリプトが読み込まれるのを待機
updateLoadingStatus('スクリプトを読み込み中...');
await waitForScripts();
updateLoadingStatus('スクリプト読み込み完了 ✅');
// 2. 動画をプリロード
updateLoadingStatus('動画ファイルをプリロード中...');
await preloadVideos();
updateLoadingStatus(`動画プリロード完了 ✅ (${Object.keys(videoBlobCache).length}ファイル)`);
} catch (e) {
console.error('プリロード中にエラー:', e);
updateLoadingStatus('⚠️ 読み込みエラーが発生しました');
}
try {
/* // 3. 保存されたプリセットを読み込み
updateLoadingStatus('プリセットを読み込み中...');
console.log('📂 プリセットをロード...');
const saved = JSON.parse(localStorage.getItem('playerPresets') || '[]');
let presetToLoad;
if (saved.length > 0) {
presetToLoad = saved[0];
} else {
presetToLoad = defaultPresets[0];
}
// プリセット読み込み(同期的に実行)
if (presetToLoad && presetToLoad.panes) {
presetToLoad.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);
});
// 音量プリセット適用
if (presetToLoad.volumes) {
applyVolumePreset(presetToLoad.volumes);
}
}*/
// 音声合成状態をリセット
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);
});
}
}
// 4. すべての準備が完了したらローディングオーバーレイを非表示
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);
}
// 5. 初期化完了フラグを設定
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);
// DOMContentLoaded の最後に追加
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 = [];
// 1. 動画ペインのチェック
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
});
}
}
}
});
// 2. MusicXMLペインのチェック
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)
});
}
}
});
// 3. MIDIペインのチェック
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') {
// MusicXMLのシーク
element.seekTo(clampedTime);
} else if (type === 'midi' && typeof element.seekTo === 'function') {
// MIDIのシーク
element.seekTo(clampedTime);
}
});
// すべてのシーク完了を待機(動画の読み込み待ち)
if (syncPromises.length > 0) {
await Promise.all(syncPromises);
}
console.log(`✅ ${desyncedPanes.length}個のペインを個別シーク完了`);
} else {
// ずれているペインがない場合は、一括シーク(従来通り)
console.log('✅ ずれているペインはありません(一括シークはスキップ)');
}
// ---- MIDI/MusicXML全体シーク(念のため) ----
// 全体シークは必要な場合のみ実行(ずれが大きい場合のフォールバック)
// 通常は個別シークで対応済みなので、ここでは軽く調整
if (window.MIDIPane && typeof window.MIDIPane.seekAll === 'function') {
// MIDI全体のシークは、個別シークで対応しているので、ずれが大きい場合のみ
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);
}
}
// ペインごとのMusicXMLも念のため調整(個別シークで対応済みだが、安全のため)
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;
}
// ----- 再生・停止の切り替え (k, Space) -----
if (key === 'k' || key === ' ') {
event.preventDefault();
togglePlayPause();
return;
}
// ----- 左右キー / j / l: 5秒移動 (長押し対応) -----
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;
}
// ----- 0~9キー: 10分割移動 -----
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;
}
// ----- fキー: フルスクリーン -----
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;
}
// ----- mキー: ミュート -----
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>
`;
// ラッパーに追加(video-controlsの前に配置)
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>