soiz1's picture
Update index.html
8bc486a verified
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="https://soiz1-eruda3.hf.space/eruda.js"></script>
<script>
eruda.init();
</script>
<script>
function isAdmin() {
return document.cookie.includes("admin_access=true");
}
if (!isAdmin()) {
// Google Tag Manager
(function(w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js'
});
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-NDL2LKLQ');
// Google Analytics (gtag.js)
var gtagScript = document.createElement('script');
gtagScript.async = true;
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=G-2M35HBEEVH";
document.head.appendChild(gtagScript);
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-2M35HBEEVH');
} else {
console.log("管理者のため、Googleタグはスキップされました。");
}
</script>
<script src="https://unpkg.com/draggabilly@2/dist/draggabilly.pkgd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
(function() {
const UPDATE_URL = '/update.txt';
const CHECK_INTERVAL_MS = 30000;
let initialVersion = null;
// CSSを挿入
const style = document.createElement('style');
style.textContent = `
#update-popup {
position: fixed;
top: 20px;
right: 20px;
background: #fff3cd;
color: #856304;
border: 1px solid #ffeeba;
border-radius: 8px;
padding: 20px 16px 16px 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
display: none;
z-index: 1000;
font-family: sans-serif;
width: 240px;
}
#update-popup-close {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
font-size: 16px;
font-weight: bold;
color: #856404;
cursor: pointer;
}
#update-popup-close:hover {
color: #000;
}
#update-popup button.reload {
margin-top: 10px;
padding: 6px 12px;
border: none;
background-color: #ffc107;
color: #000;
cursor: pointer;
border-radius: 4px;
width: 100%;
}
#update-popup button.reload:hover {
background-color: #e0a800;
}
`;
document.head.appendChild(style);
// ポップアップ要素を作成
const popup = document.createElement('div');
popup.id = 'update-popup';
popup.innerHTML = `
<button id="update-popup-close" aria-label="閉じる">×</button>
<p>ページが新しくなりました。<br>タブの再読み込みが必要です。</p>
<button class="reload">再読み込み</button>
`;
document.body.appendChild(popup);
// 閉じるボタン機能
document.getElementById('update-popup-close').addEventListener('click', () => {
popup.style.display = 'none';
});
// 再読み込みボタン機能
popup.querySelector('.reload').addEventListener('click', () => {
location.reload();
});
// バージョン取得関数
async function fetchVersion() {
try {
const res = await fetch(UPDATE_URL + '?t=' + Date.now());
if (res.ok) return await res.text();
} catch (err) {
console.warn('update.txt の取得に失敗:', err);
}
return null;
}
// 更新チェック関数
async function checkForUpdate() {
const latest = await fetchVersion();
if (initialVersion && latest && latest !== initialVersion) {
popup.style.display = 'block';
}
}
// 初期バージョン取得 → チェック開始
(async () => {
initialVersion = await fetchVersion();
if (initialVersion) {
setInterval(checkForUpdate, CHECK_INTERVAL_MS);
}
})();
})();
})
</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=' />
<meta charset="UTF-8">
<script>
//青いスクロールバーを追加
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("input[type='range']").forEach((slider) => {
const bubble = document.createElement("div");
bubble.style.position = "absolute";
bubble.style.background = "#333a";
bubble.style.color = "#fff";
bubble.style.padding = "4px 8px";
bubble.style.borderRadius = "4px";
bubble.style.fontSize = "12px";
bubble.style.pointerEvents = "none";
bubble.style.whiteSpace = "nowrap";
bubble.style.transform = "translate(-50%, -120%)";
bubble.style.transition = "opacity 0.1s";
bubble.style.opacity = "0";
bubble.style.zIndex = "1000";
document.body.appendChild(bubble);
let rect = null;
slider.addEventListener("mouseenter", () => {
rect = slider.getBoundingClientRect();
bubble.style.opacity = "1";
});
slider.addEventListener("mouseleave", () => {
bubble.style.opacity = "0";
});
slider.addEventListener("mousemove", (e) => {
if (!rect) rect = slider.getBoundingClientRect();
const min = parseFloat(slider.min || 0);
const max = parseFloat(slider.max || 100);
const step = parseFloat(slider.step || 1);
const relativeX = e.clientX - rect.left;
const percent = Math.min(Math.max(relativeX / rect.width, 0), 1);
let value = min + (max - min) * percent;
value = Math.round(value / step) * step;
value = Number(value.toFixed(4)); //小数点以下を丸める(0.140000000000003などの誤差)
// スライダーの値として表示
bubble.textContent = value;
// ツールチップの位置調整
const pageX = e.pageX;
const pageY = window.scrollY + rect.top;
bubble.style.left = `${pageX}px`;
bubble.style.top = `${pageY}px`;
});
window.addEventListener("scroll", () => {
rect = slider.getBoundingClientRect();
});
window.addEventListener("resize", () => {
rect = slider.getBoundingClientRect();
});
});
});
</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=">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c&display=swap" rel="stylesheet">
<title>文化発表会動画プレイヤー</title>
<style>
/* テクノロジー風背景スタイル */
.tech-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -2;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
overflow: hidden;
}
.circuit-line {
position: absolute;
background: rgba(0, 255, 255, 0.1);
box-shadow: 0 0 10px rgba(0, 255, 255, 0.3);
}
.grid-dot {
position: absolute;
width: 2px;
height: 2px;
background: rgba(0, 255, 255, 0.3);
border-radius: 50%;
}
.hexagon {
position: absolute;
width: 40px;
height: 23px;
background: rgba(0, 255, 255, 0.05);
border: 1px solid rgba(0, 255, 255, 0.1);
box-shadow: 0 0 5px rgba(0, 255, 255, 0.2);
}
.hexagon:before,
.hexagon:after {
content: "";
position: absolute;
width: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
}
.hexagon:before {
bottom: 100%;
border-bottom: 11.5px solid rgba(0, 255, 255, 0.05);
}
.hexagon:after {
top: 100%;
width: 0;
border-top: 11.5px solid rgba(0, 255, 255, 0.05);
}
.pulse {
position: absolute;
width: 10px;
height: 10px;
background: rgba(0, 255, 255, 0.7);
border-radius: 50%;
box-shadow: 0 0 10px 5px rgba(0, 255, 255, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.8);
opacity: 0.7;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(0.8);
opacity: 0.7;
}
}
@keyframes float {
0% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(5deg);
}
100% {
transform: translateY(0) rotate(0deg);
}
}
/* メインスタイル */
body {
font-family: "M PLUS Rounded 1c", 'Arial', sans-serif;
background-color: rgba(10, 25, 47, 0.8);
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(5px);
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: 10px 20px;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
margin-top: 20px;
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;
}
#hide-video:checked~.video-container video {
background-color: black;
opacity: 0;
}
</style>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-NDL2LKLQ" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<script>
// 動画パスを再設定
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const isTMode = urlParams.has('mode') && urlParams.get('mode') === 't';
const videoSource = document.querySelector('#video source');
if (isTMode && videoSource) {
videoSource.src = '/t/v.mp4';
const video = document.getElementById('video');
video.load();
}
});
</script>
<!-- テクノロジー風背景 -->
<div class="tech-background" id="techBg">
</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>
<h1 id="title-name">文化発表会音声動画プレイヤー</h1>
<div class="settings">
<h2>サービスワーカー設定</h2>
<div hidden>
<!--邪魔だから一時的に非表示-->
<div class="setting-item">
<label>
<input type="checkbox" id="sw-video" checked>動画ファイル (/v.mp4)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-t-video" checked>動画ファイル (/t/v.mp4)</label>
</div>
<br>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-piano" checked>ピアノ音声 (/p.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-soprano" checked>ソプラノ音声 (/s.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-alto" checked>アルト音声 (/a.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-tenor" checked>テノール音声 (/t.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-combined" checked>全体音声 (/k.mp3)</label>
</div>
<br>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-t-piano" checked>ピアノ音声 (/t/p.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-t-soprano" checked>ソプラノ音声 (/t/s.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-t-alto" checked>アルト音声 (/t/a.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-t-tenor" checked>テノール音声 (/t/t.mp3)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-t-combined" checked>全体音声 (/t/k.mp3)</label>
</div>
<br>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-index" checked>インデックスファイル (/index.html)</label>
</div>
<div class="setting-item">
<label>
<input type="checkbox" id="sw-root" checked>ルートファイル (/)</label>
</div>
</div>
<a>下記のボタンを押すと、サービスワーカーを使用してページをオフラインで使用できるようにします。<br>
<b>これはテスト的な機能で、ページの更新などができなくなるバグが発生する可能性があります。<br>(更新機能は実装していますが安定して動作するとは限りません。)<br>
ページが更新されない場合、動画の更新ができなかったり既存のバグが残り続けたりする可能性があります。</b></a>
<br>
<button class="combine-button" id="sw-register-btn" disabled>登録を開始</button>
<div class="combine-status" id="sw-status">
</div>
<script>
window.addEventListener('load', async () => {
const statusElem = document.getElementById('sw-status');
const registerBtn = document.getElementById('sw-register-btn');
function updateStatus(message) {
console.log(message);
statusElem.textContent = message;
}
function updateError(message, error) {
const errorMsg = `${message}\nエラー詳細: ${error?.message || error}`;
console.error(errorMsg, error);
statusElem.textContent = errorMsg;
}
if (!('serviceWorker' in navigator)) {
updateError('このブラウザはService Workerに対応していません。');
return;
}
// 常にボタン有効にする
registerBtn.disabled = false;
try {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
updateStatus('Service Workerは既に登録されています。更新を確認中...');
registerBtn.innerText = "更新を開始";
await reg.update();
reg.onupdatefound = () => {
const newSW = reg.installing;
if (newSW) {
newSW.onstatechange = () => {
if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
updateStatus('更新があります。新しいService Workerがインストールされました。');
// ボタンは常に押せるので変更不要
}
};
}
};
} else {
updateStatus('Service Workerは未登録です。');
// ボタンは常に押せるので変更不要
}
} catch (err) {
updateError('Service Workerの確認中にエラーが発生しました。', err);
}
registerBtn.addEventListener('click', async () => {
registerBtn.disabled = true;
try {
updateStatus('既存のService Workerをアンレジスター中...');
const regs = await navigator.serviceWorker.getRegistrations();
for (const reg of regs) {
await reg.unregister();
}
updateStatus('新しいService Workerを登録中...');
await navigator.serviceWorker.register('/sw.js');
updateStatus('登録完了。ページをリロードしてキャッシュを有効化します。');
registerBtn.textContent = 'ページをリロード';
registerBtn.disabled = false;
registerBtn.onclick = () => location.reload();
} catch (err) {
updateError('Service Workerの登録に失敗しました。', err);
}
});
});
</script>
<style>
#app {
position: fixed;
top: 50%;
left: 50%;
width: 200px;
height: 265px;
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: 10px;
overflow: hidden;
z-index: 999999;
}
#header {
position: absolute;
top: 5px;
right: 5px;
cursor: pointer;
user-select: none;
font-size: 24px;
color: #fff;
z-index: 10;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
svg {
overflow: visible;
}
#toggle {
cursor: pointer;
}
#bpmInput {
margin-top: 10px;
background: #222;
border: none;
color: #fff;
font-size: 16px;
text-align: center;
width: 80px;
padding: 4px;
border-radius: 4px;
}
#content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
user-select: none;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/CustomEase.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
// メトロノーム関連のグローバル変数
let metronomeState = {
isActive: false,
bpm: urlParams.get('mode') === 't' ? 66 : 92
};
// メトロノームのタイムライン
const tl = gsap.timeline({
repeat: -1,
paused: true
})
.to('#arm', {
duration: 1,
rotate: 90,
ease: 'power2.inOut',
yoyo: true,
repeat: 1,
transformOrigin: '100% 100%'
}, 0)
.add(() => {
swing(-1);
}, 0.5)
.add(() => {
swing(1);
}, 1.5);
// メトロノームの状態を設定する関数
function setMetronomeState(active, bpm) {
metronomeState.isActive = active;
metronomeState.bpm = bpm;
if (active) {
if (!tl.isActive()) {
gsap.to('#toggle path', {
attr: {
stroke: '#fff'
}
});
gsap.to('#toggle circle', {
duration: 0.3,
x: 15,
ease: 'power3.inOut'
});
tl.play();
}
} else {
if (tl.isActive()) {
gsap.to('#toggle path', {
attr: {
stroke: '#333'
}
});
gsap.to('#toggle circle', {
duration: 0.3,
x: 0,
ease: 'power3.inOut'
});
tl.pause();
}
}
}
// メトロノームのスイングアニメーション
function swing(dir) {
gsap.timeline()
.to('#tip', {
duration: 0.25,
rotate: 40 * dir,
transformOrigin: '50% 19%',
ease: 'power3'
})
.to('#tip', {
duration: 0.35,
rotate: 0,
ease: CustomEase.create('ez', 'M0,0 C0.06,0.002 0.148,0.026 0.186,0.17 0.211,0.274 0.27,1.074 0.552,1.086 0.635,1.089 0.736,0.991 0.81,0.976 0.894,0.957 0.938,1.012 1,1')
}, '-=0.1');
const snd = document.getElementById('snd');
const sound = snd.cloneNode();
sound.volume = 0.33;
sound.play().catch(e => console.error('メトロノーム音再生エラー:', e));
}
// メトロノームのトグル処理
document.querySelector('#toggle').onclick = () => {
setMetronomeState(!tl.isActive(), metronomeState.bpm);
};
// BPM変更処理
function setTempoFromBPM() {
const bpmInput = document.getElementById('bpmInput');
let bpm = parseInt(bpmInput.value);
if (isNaN(bpm)) bpm = 60;
bpm = Math.max(0, Math.min(bpm, 10000));
bpmInput.value = bpm;
metronomeState.bpm = bpm;
const tempoLevel = Math.round(gsap.utils.mapRange(0, 10000, 1, 10, bpm));
gsap.set('#lvl', {
attr: {
'stroke-dasharray': tempoLevel * 10 + ' ' + (100 - tempoLevel * 10)
}
});
const secondsPerBeat = 60 / bpm;
const durationPerHalf = secondsPerBeat;
const wasActive = tl.isActive();
tl.pause(0).clear(); // アニメーションを停止し、タイムラインを初期化
tl.to('#arm', {
duration: durationPerHalf,
rotate: 90,
ease: 'power2.inOut',
yoyo: true,
repeat: 1,
transformOrigin: '100% 100%'
}, 0)
.add(() => {
swing(-1);
}, durationPerHalf / 2)
.add(() => {
swing(1);
}, durationPerHalf * 1.5);
if (wasActive) {
tl.play(0); // 再スタートする場合、タイムラインを最初から再生
}
}
// BPM入力のイベントリスナー
document.getElementById('bpmInput').addEventListener('input', setTempoFromBPM);
// 初期化時にメトロノームを設定
setTempoFromBPM();
// 音声合成関数(完全版)
async function combineAudio() {
// 合成前にメトロノームの状態を保存
const wasMetronomeActive = tl.isActive();
if (wasMetronomeActive) {
setMetronomeState(false, metronomeState.bpm);
}
const combineButton = document.getElementById('combine-button');
const combineStatus = document.getElementById('combine-status');
const audioFiles = ['p', 'a', 't', 's', 'k'];
const basePath = new URLSearchParams(window.location.search).has('mode') ? '/t/' : '/';
// 現在の音量設定を保存
const currentVolumes = {};
audioFiles.forEach(file => {
const slider = document.querySelector(`.audio-slider[data-audio="${file}"]`);
currentVolumes[file] = parseFloat(slider.value);
});
combineButton.disabled = true;
combineStatus.textContent = "音声を合成中...";
try {
// 各音声ファイルをデコード
const audioBufferPromises = audioFiles.map(async file => {
if (currentVolumes[file] === 0) return null;
try {
const response = await fetch(`${basePath}${file}.mp3`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer);
} catch (error) {
console.error(`音声ファイル読み込みエラー (${file}.mp3):`, error);
return null;
}
});
// すべての音声バッファを取得
const buffers = await Promise.all(audioBufferPromises);
const validBuffers = buffers.filter(Boolean);
if (validBuffers.length === 0) {
throw new Error("有効な音声ファイルが見つかりませんでした");
}
// 最長の音声バッファの長さを取得
const maxDuration = Math.max(...validBuffers.map(b => b.duration));
// 新しい音声バッファを作成
const combinedAudioBuffer = audioContext.createBuffer(
2, // ステレオ
audioContext.sampleRate * maxDuration,
audioContext.sampleRate
);
// 各音声バッファを結合
audioFiles.forEach((file, index) => {
if (!buffers[index] || currentVolumes[file] === 0) return;
const buffer = buffers[index];
const volume = currentVolumes[file];
// 各チャンネルに音声を加算
for (let channel = 0; channel < 2; channel++) {
const inputData = buffer.getChannelData(channel % buffer.numberOfChannels);
const outputData = combinedAudioBuffer.getChannelData(channel);
for (let i = 0; i < inputData.length; i++) {
outputData[i] += inputData[i] * volume;
}
}
});
// 音量を正規化 (クリッピング防止)
for (let channel = 0; channel < 2; channel++) {
const outputData = combinedAudioBuffer.getChannelData(channel);
let max = 0;
for (let i = 0; i < outputData.length; i++) {
if (Math.abs(outputData[i]) > max) {
max = Math.abs(outputData[i]);
}
}
if (max > 1) {
for (let i = 0; i < outputData.length; i++) {
outputData[i] /= max;
}
}
}
// AudioBufferをBlobに変換
const blob = bufferToWave(combinedAudioBuffer, audioContext);
const url = URL.createObjectURL(blob);
// 既存のaudio要素をクリーンアップ
if (window.combinedAudioElement) {
window.combinedAudioElement.pause();
URL.revokeObjectURL(window.combinedAudioElement.src);
}
// 新しいaudio要素を作成
window.combinedAudioElement = new Audio(url);
window.combinedAudioElement.preservesPitch = true;
window.combinedAudioElement.mozPreservesPitch = true;
window.combinedAudioElement.webkitPreservesPitch = true;
window.combinedAudioElement.playbackRate = currentPlaybackRate;
// 合成完了メッセージ
combineStatus.textContent = "音声の合成が完了しました";
isAudioCombined = true;
enablePlayerControls();
// メトロノームの状態を復元
if (wasMetronomeActive) {
setTimeout(() => {
setMetronomeState(true, metronomeState.bpm);
}, 500);
}
} catch (error) {
console.error('音声合成エラー:', error);
combineStatus.textContent = `音声の合成に失敗しました: ${error.message}`;
// エラー時もメトロノームの状態を復元
if (wasMetronomeActive) {
setMetronomeState(true, metronomeState.bpm);
}
} finally {
combineButton.disabled = false;
}
}
// AudioBufferをWAV Blobに変換
function bufferToWave(abuffer, audioContext) {
const numOfChan = abuffer.numberOfChannels;
const length = abuffer.length * numOfChan * 2 + 44;
const buffer = new ArrayBuffer(length);
const view = new DataView(buffer);
let pos = 0;
// WAVヘッダー書き込み
setUint32(0x46464952); // "RIFF"
setUint32(length - 8); // ファイル長 - 8
setUint32(0x45564157); // "WAVE"
setUint32(0x20746d66); // "fmt "チャンク
setUint32(16); // 長さ = 16
setUint16(1); // PCM (非圧縮)
setUint16(numOfChan);
setUint32(audioContext.sampleRate);
setUint32(audioContext.sampleRate * 2 * numOfChan);
setUint16(numOfChan * 2);
setUint16(16);
setUint32(0x61746164); // "data"チャンク
setUint32(length - pos - 4);
// インターリーブデータ書き込み
for (let i = 0; i < abuffer.length; i++) {
for (let channel = 0; channel < numOfChan; channel++) {
let sample = abuffer.getChannelData(channel)[i] * 0x7fff;
sample = Math.max(-32768, Math.min(32767, sample));
view.setInt16(pos, sample, true);
pos += 2;
}
}
function setUint16(data) {
view.setUint16(pos, data, true);
pos += 2;
}
function setUint32(data) {
view.setUint32(pos, data, true);
pos += 4;
}
return new Blob([buffer], {
type: 'audio/wav'
});
}
// ドラッグ可能化
const app = document.getElementById('app');
new Draggabilly(app, {
containment: 'body'
});
// 開閉ボタン
const header = document.getElementById('header');
const content = document.getElementById('content');
let isOpen = false;
// 初期コンパクト状態へアニメーション
gsap.set(app, {
width: 60,
height: 40,
padding: 0
});
content.style.display = 'none';
header.textContent = '▼';
header.addEventListener('click', () => {
if (isOpen) {
// 閉じる
gsap.to(app, {
duration: 0.4,
width: 60,
height: 40,
padding: 0,
ease: 'power3.inOut',
onComplete: () => {
content.style.display = 'none';
header.textContent = '▼';
}
});
} else {
// 開く
content.style.display = 'flex';
gsap.to(app, {
duration: 0.4,
width: 300,
height: 400,
padding: 10,
ease: 'power3.inOut',
onComplete: () => {
header.textContent = '▲';
}
});
}
isOpen = !isOpen;
});
});
</script>
</div>
<div class="container">
<div class="audio-controls">
<h2>音声コントロール</h2>
<span>各パートの音の大きさを設定できます、自分のパートの音量を1、それ以外のパートの音量を0.4くらいにすると他のパートのタイミングを確認しながら練習できます。<br>
※「ピアノ」と「全体」は他のパートと音がずれるため、組み合わせて使用しないでください。</span>
<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="0">
<span class="slider-value volume-value">0.00</span>
</div>
<div class="audio-item">
<label>全体(非推奨)</label>
<input type="range" class="audio-slider" data-audio="k" min="0" max="1" step="0.01" value="0">
<span class="slider-value volume-value">0.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="preview-section" id="preview-section" hidden>
<h3 hidden>プレビュー</h3>
<p hidden>合成された音声をプレビューできます。再生ボタンをクリックして確認してください。</p>
<button class="control-button" id="preview-button" hidden></button>
<span id="preview-time" hidden>00:00 / 00:00</span>
</div>
<div class="video-container" id="video-container">
<!-- バッファリングインジケーター -->
<div id="buffering-indicator">
<div class="loader">
</div>
</div>
<!-- 同期ステータス -->
<div class="sync-status" id="sync-status">
<span id="sync-status-text">
</span>
<button id="sync-status-close">×</button>
</div>
<script>
new Draggabilly('#sync-status');
</script>
<!-- 無効状態のオーバーレイ -->
<div class="disabled-overlay" id="disabledOverlay">
<div class="disabled-message">
<p>音声の合成が完了していません</p>
<p>上の音声コントロールで各パートの音量を調整し、「合成」ボタンを押してください</p>
</div>
</div>
<video id="video" muted>
<source src="v.mp4" type="video/mp4" allowpictureinpicture>
</source>
</video>
<div id="app">
<div id="header"></div>
<div id="content">
<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" min="0" value="66" /> 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>
window.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
document.getElementById('bpmInput').value = urlParams.get('mode') === 't' ? 66 : 92;
document.getElementById('title-name').textContent = urlParams.get('mode') === 't' ? "地球星歌" : "時の旅人";
});
</script>
<div class="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" id="reset-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="speed-control">
<span class="speed-value" id="speed-value">1.00x</span>
<input type="range" class="speed-slider" id="speed-slider" min="0.01" max="5" step="0.01" value="1" disabled>
</div>
<button class="control-button" id="pip-btn" disabled 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>このボックスをドラッグして「再生開始秒数」や「再生終了秒数」の入力ボックスの上で離すと秒数を簡単に変更できます。各秒数は楽譜に合わせています。<br>
時の旅人はAからE、地球星歌は①から⑤で区切っていて、地球星歌はリピート記号があるので①と②は1番と2番があります。</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">
<label for="hide-video">消画モード:</label>
<input type="checkbox" id="hide-video">
</div>
<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="92" step="0.1">
<span id="tempo-speed-value">1.00x</span>
</div>
<span>↑練習したいテンポを入力するとそのテンポに合わせて再生速度スライダーを変更できます。
<br>楽譜に記載された「♪=」の横のテンポ(時の旅人は約92、地球星歌は約66)を基準に計算します。</span>
</div>
</div>
<!-- 全画面時のロックボタン -->
<button class="lock-controls-btn" id="lock-controls-btn" title="コントロールバーを固定">🔒</button>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 同期管理用の変数
let lastSyncTime = 0;
let isBuffering = false;
let syncDriftLog = [];
let syncCheckInterval;
let audioContext;
let controlsHideTimeout;
let isControlsLocked = false;
let controlsVisible = true;
let isCheckingSync = false;
let isInBackgroundTab = false;
try {
audioContext = new(window.AudioContext || window.webkitAudioContext)
();
} catch (e) {
console.error('Web Audio APIがサポートされていません:', e);
}
// テクノロジー風背景を生成
function createTechBackground() {
const bg = document.getElementById('techBg');
for (let i = 0; i < 200; i++) {
const line = document.createElement('div');
line.className = 'circuit-line';
const isHorizontal = Math.random() > 0.5;
if (isHorizontal) {
line.style.width = `${Math.random() * 300 + 100}px`;
line.style.height = '1px';
} else {
line.style.width = '1px';
line.style.height = `${Math.random() * 300 + 100}px`;
}
line.style.left = `${Math.random() * 100}%`;
line.style.top = `${Math.random() * 100}%`;
line.style.opacity = Math.random() * 0.5 + 0.1;
bg.appendChild(line);
}
for (let i = 0; i < 200; i++) {
const dot = document.createElement('div');
dot.className = 'grid-dot';
dot.style.left = `${Math.random() * 100}%`;
dot.style.top = `${Math.random() * 100}%`;
bg.appendChild(dot);
}
for (let i = 0; i < 15; i++) {
const hex = document.createElement('div');
hex.className = 'hexagon';
hex.style.left = `${Math.random() * 100}%`;
hex.style.top = `${Math.random() * 100}%`;
hex.style.transform = `rotate(${Math.random() * 360}deg)`;
hex.style.animation =
`float ${Math.random() * 10 + 5}s infinite ease-in-out`;
bg.appendChild(hex);
}
for (let i = 0; i < 8; i++) {
const pulse = document.createElement('div');
pulse.className = 'pulse';
pulse.style.left = `${Math.random() * 100}%`;
pulse.style.top = `${Math.random() * 100}%`;
pulse.style.animationDelay = `${Math.random() * 2}s`;
bg.appendChild(pulse);
}
}
createTechBackground();
const urlParams = new URLSearchParams(window.location.search);
const isTMode = urlParams.has('mode') && urlParams.get('mode') ===
't';
const basePath = isTMode ? '/t/' : '/';
// ローディング状態を管理
let loadingCount = 0;
let totalToLoad = 6; // 動画 + 5つの音声ファイル
let lastUpdateTime = 0;
const updateInterval = 1;
function checkLoadingComplete() {
loadingCount++;
if (loadingCount >= totalToLoad) {
setTimeout(function() {
const loadingOverlay = document.getElementById(
'loadingOverlay');
loadingOverlay.style.opacity = '0';
setTimeout(function() {
loadingOverlay.style.display = 'none';
}, 1000);
}, 500);
}
}
function handleError(error, message) {
console.error(message, error);
window.alert(`${message}\n\nエラー詳細: ${error.message}`);
}
// 要素を取得
const video = document.getElementById('video');
video.preservesPitch = true;
video.mozPreservesPitch = true; // Firefox用
video.webkitPreservesPitch = true; // 古いWebKit用
const videoContainer = document.getElementById('video-container');
const playPauseBtn = document.getElementById('play-pause-btn');
const timeDisplay = document.getElementById('time-display');
const progressContainer = document.getElementById(
'progress-container');
const progressBar = document.getElementById('progress-bar');
const progressTime = document.getElementById('progress-time');
const volumeBtn = document.getElementById('volume-btn');
const volumeSlider = document.getElementById('volume-slider');
const speedSlider = document.getElementById('speed-slider');
const speedValue = document.getElementById('speed-value');
const playbackSpeedSlider = document.getElementById(
'playback-speed');
const playbackSpeedValue = document.getElementById(
'playback-speed-value');
const fullscreenBtn = document.getElementById('fullscreen-btn');
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
const loopCheckbox = document.getElementById('loop');
const globalVolumeSlider = document.getElementById(
'global-volume');
const globalVolumeValue = document.getElementById(
'global-volume-value');
const audioSliders = document.querySelectorAll('.audio-slider');
const volumeValues = document.querySelectorAll('.volume-value');
const setStartTimeBtn = document.getElementById('set-start-time');
const setEndTimeBtn = document.getElementById('set-end-time');
const resetEndTimeBtn = document.getElementById('reset-end-time');
const disabledOverlay = document.getElementById(
'disabledOverlay');
const combineButton = document.getElementById('combine-button');
const combineStatus = document.getElementById('combine-status');
const previewSection = document.getElementById('preview-section');
const previewButton = document.getElementById('preview-button');
const previewTime = document.getElementById('preview-time');
const bufferingIndicator = document.getElementById(
'buffering-indicator');
const syncStatus = document.getElementById('sync-status');
const syncStatusText = document.getElementById(
'sync-status-text');
const syncStatusClose = document.getElementById(
'sync-status-close');
const lockControlsBtn = document.getElementById(
'lock-controls-btn');
const startMarker = document.getElementById('start-marker');
const endMarker = document.getElementById('end-marker');
const tempoInput = document.getElementById('tempo');
const tempoSpeedValue = document.getElementById(
'tempo-speed-value');
const videoControls = document.querySelector('.video-controls');
const applyTimeBtn = document.getElementById('apply-time-btn');
const pipBtn = document.getElementById('pip-btn');
const resetBtn = document.getElementById('reset-btn');
// 音声オブジェクトを作成
const audioElements = {};
const audioBuffers = {};
const audioFiles = ['p', 'a', 't', 's', 'k'];
let combinedAudioElement = null;
let isAudioCombined = false;
let currentVolumes = {
p: 0,
a: 1,
t: 1,
s: 1,
k: 0
};
// 初期化
let videoDuration = 0;
let isPlaying = false;
let lastVolume = 1;
let currentPlaybackRate = 1;
let isFullscreen = false;
async function enterPiP() {
if (!document.pictureInPictureElement && !video.paused) {
try {
await video.requestPictureInPicture();
} catch (err) {
console.warn('PiP開始失敗:', err);
}
}
}
async function exitPiP() {
if (document.pictureInPictureElement) {
try {
await document.exitPictureInPicture();
} catch (err) {
console.warn('PiP終了失敗:', err);
}
}
}
const hideVideoCheckbox = document.getElementById('hide-video');
hideVideoCheckbox.addEventListener('change', function() {
video.style.backgroundColor = this.checked ? 'black' : '';
video.style.opacity = this.checked ? '0' : '1';
});
// 動画のバッファリング状態を監視
video.addEventListener('waiting', function() {
isBuffering = true;
bufferingIndicator.style.display = 'block';
if (combinedAudioElement) {
combinedAudioElement.pause();
}
});
video.addEventListener('playing', function() {
isBuffering = false;
bufferingIndicator.style.display = 'none';
if (combinedAudioElement && isPlaying) {
syncAudioWithVideo();
}
});
video.addEventListener('suspend', function() {
console.log('動画の読み込みが一時停止しました');
});
video.addEventListener('stalled', function() {
console.log('動画の読み込みが停滞しました');
if (isPlaying) {
pauseMedia();
}
});
function updatePipButton() {
if (document.pictureInPictureElement) {
pipBtn.textContent = '↸';
pipBtn.title = 'ピクチャーインピクチャーを終了';
} else {
pipBtn.textContent = '⇲';
pipBtn.title = 'ピクチャーインピクチャーで再生';
}
}
pipBtn.addEventListener('click', async () => {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await video.requestPictureInPicture();
}
updatePipButton();
} catch (err) {
console.error('PiP操作エラー:', err);
}
});
video.addEventListener('enterpictureinpicture', updatePipButton);
video.addEventListener('leavepictureinpicture', async () => {
updatePipButton();
if (isPlaying && !document.hidden) {
try {
await video.play();
} catch (err) {
console.error('PiP終了後の再生エラー:', err);
}
}
});
// リセットボタンクリック処理
resetBtn.addEventListener('click', () => {
const startTime = parseFloat(startTimeInput.value) || 0;
seekMedia(startTime);
if (isPlaying) {
playMedia();
}
});
// 初期化時にPiPボタンの状態を設定
updatePipButton();
function startSyncCheck() {
if (isCheckingSync) return;
isCheckingSync = true;
if (syncCheckInterval) clearInterval(syncCheckInterval);
syncCheckInterval = setInterval(checkSync, 1000);
}
function stopSyncCheck() {
isCheckingSync = false;
if (syncCheckInterval) clearInterval(syncCheckInterval);
}
function checkSync() {
if (!isAudioCombined || !isPlaying || isBuffering ||
isInBackgroundTab) return;
const videoTime = video.currentTime;
const audioTime = combinedAudioElement.currentTime;
const drift = videoTime - audioTime;
syncDriftLog.push(drift);
if (syncDriftLog.length > 5) syncDriftLog.shift();
const avgDrift = syncDriftLog.reduce((a, b) => a + b, 0) /
syncDriftLog.length;
syncStatusText.textContent = `同期ズレ: ${avgDrift.toFixed(3)}秒`;
if (Math.abs(avgDrift) > 0.1) {
console.log(`同期ズレを修正: ${avgDrift.toFixed(3)}秒`);
syncAudioWithVideo();
}
}
applyTimeBtn.addEventListener('click', function() {
// 現在再生中なら一時停止
const wasPlaying = isPlaying;
if (isPlaying) {
pauseMedia();
}
// 開始時間と終了時間を取得
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || video.duration;
// 現在位置が開始時間より前なら開始時間に移動
if (video.currentTime < startTime) {
video.currentTime = startTime;
if (combinedAudioElement) {
combinedAudioElement.currentTime = startTime;
}
}
// 現在位置が終了時間より後なら開始時間に移動
else if (video.currentTime > endTime) {
video.currentTime = startTime;
if (combinedAudioElement) {
combinedAudioElement.currentTime = startTime;
}
}
// 再生中だった場合は再開
if (wasPlaying) {
playMedia();
}
// マーカーを更新
updateProgressMarkers();
});
// 音声を動画に同期させる関数
function syncAudioWithVideo() {
if (!isAudioCombined || !isPlaying) return;
const currentTime = video.currentTime;
if (combinedAudioElement) {
combinedAudioElement.currentTime = currentTime;
if (isPlaying) {
combinedAudioElement.play().catch(e => console.error(
'音声再生エラー:', e));
}
}
// ズレ記録をリセット
syncDriftLog = [];
lastSyncTime = performance.now();
}
// 音声ファイルをロード
function loadAudioFiles() {
audioFiles.forEach(file => {
try {
const audio = new Audio(`${basePath}${file}.mp3`);
audio.preload = 'auto';
audio.loop = false;
audioElements[file] = audio;
audio.addEventListener('loadedmetadata', function() {
console.log(`${basePath}${file}.mp3 loaded`);
checkLoadingComplete();
});
audio.addEventListener('error', function() {
console.error(`音声ファイル読み込みエラー (${basePath}${file}.mp3):`,
audio.error);
checkLoadingComplete();
});
} catch (error) {
console.error(`音声ファイル初期化エラー (${basePath}${file}.mp3):`,
error);
checkLoadingComplete();
}
});
}
// 音声を結合する関数
async function combineAudio() {
if (combinedAudioElement) {
combinedAudioElement.pause();
combinedAudioElement.src = '';
URL.revokeObjectURL(combinedAudioElement.src);
}
combineButton.disabled = true;
combineStatus.textContent = "音声を合成中...";
try {
// 現在の音量設定を保存
audioFiles.forEach(file => {
currentVolumes[file] = parseFloat(document.querySelector(
`.audio-slider[data-audio="${file}"]`).value);
});
// 各音声ファイルをデコード
const audioBufferPromises = audioFiles.map(async file => {
const audio = audioElements[file];
if (!audio) return null;
// 音量が0の場合はスキップ
if (currentVolumes[file] === 0) return null;
const response = await fetch(`${basePath}${file}.mp3`);
const arrayBuffer = await response.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer);
});
// すべての音声バッファを取得
const buffers = await Promise.all(audioBufferPromises);
audioFiles.forEach((file, index) => {
audioBuffers[file] = buffers[index];
});
// 最長の音声バッファの長さを取得
const maxDuration = Math.max(...buffers.filter(b => b).map(b =>
b.duration));
// 新しい音声バッファを作成
const combinedAudioBuffer = audioContext.createBuffer(
2, // ステレオ
audioContext.sampleRate * maxDuration,
audioContext.sampleRate
);
// 各音声バッファを結合
for (let file of audioFiles) {
if (!audioBuffers[file] || currentVolumes[file] === 0)
continue;
const buffer = audioBuffers[file];
const volume = currentVolumes[file];
// 各チャンネルに音声を加算
for (let channel = 0; channel < 2; channel++) {
const inputData = buffer.getChannelData(channel % buffer.numberOfChannels);
const outputData = combinedAudioBuffer.getChannelData(
channel);
for (let i = 0; i < inputData.length; i++) {
outputData[i] += inputData[i] * volume;
}
}
}
// 音量を正規化 (クリッピング防止)
for (let channel = 0; channel < 2; channel++) {
const outputData = combinedAudioBuffer.getChannelData(channel);
let max = 0;
for (let i = 0; i < outputData.length; i++) {
if (Math.abs(outputData[i]) > max) {
max = Math.abs(outputData[i]);
}
}
if (max > 1) {
for (let i = 0; i < outputData.length; i++) {
outputData[i] /= max;
}
}
}
// AudioBufferをBlobに変換
const blob = bufferToWave(combinedAudioBuffer);
const url = URL.createObjectURL(blob);
// 新しいaudio要素を作成
combinedAudioElement = new Audio(url);
combinedAudioElement.preservesPitch = true;
combinedAudioElement.mozPreservesPitch = true;
combinedAudioElement.webkitPreservesPitch = true;
combinedAudioElement.playbackRate = currentPlaybackRate;
isAudioCombined = true;
combineStatus.textContent = "音声の合成が完了しました";
enablePlayerControls();
combineButton.disabled = false;
document.addEventListener('visibilitychange', async () => {
if (document.hidden) {
isInBackgroundTab = true;
// 動画のみ停止
if (!document.pictureInPictureElement) {
video.pause();
}
// 音声とメトロノームは継続
if (combinedAudioElement && !combinedAudioElement.paused) {
await combinedAudioElement.play();
}
if (tl.isActive()) {
tl.play();
}
} else {
isInBackgroundTab = false;
if (combinedAudioElement && !combinedAudioElement.paused) {
video.currentTime = combinedAudioElement.currentTime;
if (isPlaying) {
await video.play();
}
}
}
});
// 動画終了時に自動的にPiPを閉じる(次回再開のため)
video.addEventListener('ended', exitPiP);
// 合成後に音量と再生速度を適用
applyVolume();
applyPlaybackRate();
} catch (error) {
console.error('音声合成エラー:', error);
combineStatus.textContent = "音声の合成に失敗しました";
combineButton.disabled = false;
}
}
function bufferToWave(abuffer) {
const numOfChan = abuffer.numberOfChannels,
length = abuffer.length * numOfChan * 2 + 44,
buffer = new ArrayBuffer(length),
view = new DataView(buffer),
channels = [],
sampleRate = abuffer.sampleRate;
// posをletで宣言(constから変更)
let pos = 0;
// write WAV header
setUint32(0x46464952); // "RIFF"
setUint32(length - 8); // file length - 8
setUint32(0x45564157); // "WAVE"
setUint32(0x20746d66); // "fmt " chunk
setUint32(16); // length = 16
setUint16(1); // PCM (uncompressed)
setUint16(numOfChan);
setUint32(sampleRate);
setUint32(sampleRate * 2 * numOfChan);
setUint16(numOfChan * 2);
setUint16(16);
setUint32(0x61746164); // "data" - chunk
setUint32(length - pos - 4);
// write interleaved data
for (let i = 0; i < abuffer.length; i++) {
for (let channel = 0; channel < numOfChan; channel++) {
let sample = abuffer.getChannelData(channel)[i] * 0x7fff;
if (sample < -32768) sample = -32768;
if (sample > 32767) sample = 32767;
view.setInt16(pos, sample, true);
pos += 2;
}
}
function setUint16(data) {
view.setUint16(pos, data, true);
pos += 2;
}
function setUint32(data) {
view.setUint32(pos, data, true);
pos += 4;
}
return new Blob([buffer], {
type: 'audio/wav'
});
}
function applyVolume() {
if (!isAudioCombined) return;
// ベース音量 (0-1)
const baseVolume = parseFloat(volumeSlider.value);
// グローバル音量係数 (0-10)
const globalVolume = parseFloat(globalVolumeSlider.value) / 10; // 0-1に変換
// 最終音量 (0-1)
const finalVolume = Math.max(0, Math.min(1, baseVolume *
globalVolume));
// 動画と音声の音量を設定
video.volume = finalVolume;
if (combinedAudioElement) {
combinedAudioElement.volume = finalVolume;
}
// 音量アイコンを更新
updateVolumeIcon();
}
function applyPlaybackRate() {
if (!isAudioCombined) return;
const speed = parseFloat(playbackSpeedSlider.value);
currentPlaybackRate = speed;
video.playbackRate = speed;
if (combinedAudioElement) {
combinedAudioElement.playbackRate = speed;
}
speedValue.textContent = speed.toFixed(2) + 'x';
playbackSpeedValue.textContent = speed.toFixed(2) + 'x';
speedSlider.value = speed;
}
// プレイヤーコントロールを有効化
function enablePlayerControls() {
disabledOverlay.style.display = 'none';
playPauseBtn.disabled = false;
volumeBtn.disabled = false;
volumeSlider.disabled = false;
speedSlider.disabled = false;
fullscreenBtn.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;
resetBtn.disabled = false; // 追加
pipBtn.disabled = false; // 追加
}
// プレビュー再生
function togglePreview() {
if (!isAudioCombined || !combinedAudioElement) return;
if (previewButton.textContent === '▶') {
// 再生
combinedAudioElement.currentTime = 0;
combinedAudioElement.play()
.then(() => {
previewButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z"/></svg>';
// プレビューの時間表示を更新
const updatePreviewTime = () => {
if (!combinedAudioElement || !isAudioCombined) return;
const currentTime = combinedAudioElement.currentTime;
const duration = combinedAudioElement.duration;
if (currentTime >= duration) {
previewButton.textContent = '▶';
previewTime.textContent =
`00:00 / ${formatTime(duration)}`;
return;
}
previewTime.textContent =
`${formatTime(currentTime)} / ${formatTime(duration)}`;
requestAnimationFrame(updatePreviewTime);
};
updatePreviewTime();
})
.catch(e => console.error('プレビュー再生エラー:', e));
} else {
// 一時停止
combinedAudioElement.pause();
previewButton.textContent = '▶';
}
}
// 時間をフォーマットするヘルパー関数
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
// 動画のメタデータが読み込まれたら
video.addEventListener('loadedmetadata', function() {
try {
videoDuration = video.duration;
endTimeInput.value = videoDuration.toFixed(2);
endTimeInput.max = videoDuration;
startTimeInput.max = videoDuration - 0.1;
updateTimeDisplay();
checkLoadingComplete();
} catch (error) {
handleError(error, '動画メタデータ読み込み中にエラーが発生しました');
}
});
// 動画エラー処理
video.addEventListener('error', function() {
handleError(video.error, '動画読み込み中にエラーが発生しました');
});
// 再生ボタンクリック
playPauseBtn.addEventListener('click', function() {
const endTime = parseFloat(endTimeInput.value) ||
videoDuration;
if (video.currentTime >= endTime) {
const startTime = parseFloat(startTimeInput.value) || 0;
seekMedia(startTime);
}
togglePlayPause();
});
// 時間表示を更新
function updateTimeDisplay() {
const now = performance.now();
if (now - lastUpdateTime < updateInterval && !isFullscreen)
return;
lastUpdateTime = now;
try {
const currentTime = video.currentTime;
const duration = video.duration || videoDuration;
const currentMinutes = Math.floor(currentTime / 60);
const currentSeconds = Math.floor(currentTime % 60);
const currentMilliseconds = Math.floor((currentTime % 1) * 100);
const durationMinutes = Math.floor(duration / 60);
const durationSeconds = Math.floor(duration % 60);
const durationMilliseconds = Math.floor((duration % 1) * 100);
timeDisplay.textContent =
`${String(currentMinutes).padStart(2, '0')}:${String(currentSeconds).padStart(2, '0')}.${String(currentMilliseconds).padStart(2, '0')} / ` +
`${String(durationMinutes).padStart(2, '0')}:${String(durationSeconds).padStart(2, '0')}.${String(durationMilliseconds).padStart(2, '0')}`;
const progressPercent = (currentTime / duration) * 100;
progressBar.style.width = `${progressPercent}%`;
} catch (error) {
console.error('時間表示更新エラー:', error);
}
}
// 再生/一時停止をトグル
function togglePlayPause() {
if (isPlaying) {
pauseMedia();
} else {
playMedia();
}
}
function playMedia() {
try {
const duration = video.duration || videoDuration;
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || duration;
if (video.currentTime >= endTime) {
video.currentTime = startTime;
}
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise.then(() => {
isPlaying = true;
playPauseBtn.textContent = '⏸';
// 音声を同期して再生
if (isAudioCombined) {
combinedAudioElement.currentTime = video.currentTime;
combinedAudioElement.play().catch(e => console.error(
'音声再生エラー:', e));
}
startSyncCheck();
video.playbackRate = currentPlaybackRate;
}).catch(error => {
console.error('動画再生エラー:', error);
isPlaying = false;
playPauseBtn.textContent = '▶';
});
}
} catch (error) {
console.error('メディア再生エラー:', error);
isPlaying = false;
playPauseBtn.textContent = '▶';
}
}
function pauseMedia() {
try {
video.pause();
isPlaying = false;
playPauseBtn.textContent = '▶';
stopSyncCheck();
if (combinedAudioElement) {
combinedAudioElement.pause();
}
} catch (error) {
console.error('メディア一時停止エラー:', error);
}
}
// 時間更新時の処理
video.addEventListener('timeupdate', function() {
updateTimeDisplay();
});
// プログレスバークリックでシーク
progressContainer.addEventListener('click', function(e) {
if (!video.duration) return;
const rect = this.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
const seekTime = pos * video.duration;
seekMedia(seekTime);
});
// 指定した時間にシーク (改良版)
function seekMedia(time) {
try {
const duration = video.duration || videoDuration;
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || duration;
const seekTime = Math.max(startTime, Math.min(time, endTime));
video.currentTime = seekTime;
if (combinedAudioElement) {
combinedAudioElement.currentTime = seekTime;
if (isPlaying) {
combinedAudioElement.play().catch(e => console.error(
'音声再生エラー:', e));
}
}
} catch (error) {
console.error('メディアシークエラー:', error);
}
}
// プログレスバー上でマウス移動時に時間を表示
progressContainer.addEventListener('mousemove', function(e) {
if (!video.duration) return;
const rect = this.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
const hoverTime = pos * video.duration;
const minutes = Math.floor(hoverTime / 60);
const seconds = Math.floor(hoverTime % 60);
const milliseconds = Math.floor((hoverTime % 1) * 100);
progressTime.textContent =
`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(2, '0')}`;
progressTime.style.display = 'block';
progressTime.style.left = `${pos * 100}%`;
});
progressContainer.addEventListener('mouseleave', function() {
progressTime.style.display = 'none';
});
// 動画クリックで再生/一時停止
video.addEventListener('click', function() {
togglePlayPause();
});
volumeSlider.addEventListener('input', function() {
if (!isAudioCombined) return;
lastVolume = parseFloat(this.value);
applyVolume();
});
volumeBtn.addEventListener('click', function() {
if (!isAudioCombined) return;
if (video.volume > 0) {
lastVolume = parseFloat(volumeSlider.value);
volumeSlider.value = 0;
} else {
volumeSlider.value = lastVolume;
}
applyVolume();
});
// 音量アイコンを更新
function updateVolumeIcon() {
if (video.volume === 0) {
volumeBtn.textContent = '🔇';
} else if (video.volume < 3) {
volumeBtn.textContent = '🔈';
} else {
volumeBtn.textContent = '🔊';
}
}
// 再生速度スライダー (動画プレイヤー)
speedSlider.addEventListener('input', function() {
if (!isAudioCombined) return;
const speed = parseFloat(this.value);
speedValue.textContent = speed.toFixed(2) + 'x';
playbackSpeedSlider.value = speed;
playbackSpeedValue.textContent = speed.toFixed(2) + 'x';
updatePlaybackRate(speed);
});
// 再生速度スライダー (設定メニュー)
playbackSpeedSlider.addEventListener('input', function() {
if (!isAudioCombined) return;
const speed = parseFloat(this.value);
playbackSpeedValue.textContent = speed.toFixed(2) + 'x';
speedSlider.value = speed;
speedValue.textContent = speed.toFixed(2) + 'x';
updatePlaybackRate(speed);
});
// テンポ入力による再生速度更新
tempoInput.addEventListener('input', function() {
const tempo = parseFloat(this.value);
const baseTempo = isTMode ? 66 : 92;
const speed = tempo / baseTempo;
const clampedSpeed = Math.max(0.001, Math.min(5.0, speed));
playbackSpeedSlider.value = clampedSpeed;
playbackSpeedValue.textContent = clampedSpeed.toFixed(2) +
'x';
speedSlider.value = clampedSpeed;
speedValue.textContent = clampedSpeed.toFixed(2) + 'x';
tempoSpeedValue.textContent = clampedSpeed.toFixed(2) + 'x';
updatePlaybackRate(clampedSpeed);
});
function updatePlaybackRate(speed) {
if (!isAudioCombined) return;
currentPlaybackRate = speed;
video.playbackRate = speed;
// ピッチ保持を再設定
video.preservesPitch = true;
video.mozPreservesPitch = true;
video.webkitPreservesPitch = true;
if (combinedAudioElement) {
combinedAudioElement.playbackRate = speed;
// 合成音声のピッチ保持を設定
combinedAudioElement.preservesPitch = true;
combinedAudioElement.mozPreservesPitch = true;
combinedAudioElement.webkitPreservesPitch = true;
}
}
// 全画面ボタン
fullscreenBtn.addEventListener('click', function() {
if (!isFullscreen) {
if (videoContainer.requestFullscreen) {
videoContainer.requestFullscreen();
} else if (videoContainer.webkitRequestFullscreen) {
videoContainer.webkitRequestFullscreen();
} else if (videoContainer.msRequestFullscreen) {
videoContainer.msRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
});
// 全画面変更イベント
document.addEventListener('fullscreenchange',
handleFullscreenChange);
document.addEventListener('webkitfullscreenchange',
handleFullscreenChange);
document.addEventListener('msfullscreenchange',
handleFullscreenChange);
function handleFullscreenChange() {
isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement ||
document.msFullscreenElement);
fullscreenBtn.textContent = isFullscreen ? '⛶' : '⛶';
video.controls = false;
// 全画面時にロックボタンを表示
lockControlsBtn.style.display = isFullscreen ? 'flex' : 'none';
// 全画面時にコントロールバー自動非表示機能を有効化
if (isFullscreen) {
resetControlsHideTimer();
document.addEventListener('mousemove',
handleFullscreenMouseMove);
} else {
document.removeEventListener('mousemove',
handleFullscreenMouseMove);
clearTimeout(controlsHideTimeout);
showControls();
}
}
// 全画面時のマウス移動処理
function handleFullscreenMouseMove() {
if (!isControlsLocked) {
showControls();
resetControlsHideTimer();
}
}
// コントロールバーを表示
function showControls() {
if (!controlsVisible) {
videoControls.style.opacity = '1';
controlsVisible = true;
}
}
// コントロールバーを非表示
function hideControls() {
if (!isControlsLocked && controlsVisible) {
videoControls.style.opacity = '0';
controlsVisible = false;
}
}
// コントロールバー非表示タイマーをリセット
function resetControlsHideTimer() {
clearTimeout(controlsHideTimeout);
if (!isControlsLocked) {
controlsHideTimeout = setTimeout(hideControls, 1500); // 1.5秒後に非表示
}
}
// ロックボタンのクリック処理
lockControlsBtn.addEventListener('click', function() {
isControlsLocked = !isControlsLocked;
this.classList.toggle('locked', isControlsLocked);
if (isControlsLocked) {
showControls();
clearTimeout(controlsHideTimeout);
} else {
resetControlsHideTimer();
}
});
// キーボードイベント (ESCで全画面終了)
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isFullscreen) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
});
// ボリュームスライダーのイベント
audioSliders.forEach((slider, index) => {
slider.addEventListener('input', function() {
const value = parseFloat(this.value);
volumeValues[index].textContent = value.toFixed(2);
const percent = value * 100;
this.style.backgroundSize = `${percent}% 100%`;
});
});
globalVolumeSlider.addEventListener('input', function() {
const value = parseFloat(this.value);
globalVolumeValue.textContent = value.toFixed(1);
const percent = (value - this.min) / (this.max - this.min) *
100;
this.style.backgroundSize = `${percent}% 100%`;
applyVolume();
});
// ループ設定変更時
loopCheckbox.addEventListener('change', function() {
// 合成音声ではループは動画に依存する
});
// 現在の秒数を開始時間に設定
setStartTimeBtn.addEventListener('click', function() {
startTimeInput.value = video.currentTime.toFixed(2);
updateProgressMarkers();
});
// 現在の秒数を終了時間に設定
setEndTimeBtn.addEventListener('click', function() {
endTimeInput.value = video.currentTime.toFixed(2);
updateProgressMarkers();
});
// 終了時間を動画の長さにリセット
resetEndTimeBtn.addEventListener('click', function() {
endTimeInput.value = video.duration.toFixed(2);
updateProgressMarkers();
});
// プログレスバーのマーカーを更新
function updateProgressMarkers() {
const duration = video.duration || 0;
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || duration;
if (duration > 0) {
if (startTime > 0) {
startMarker.style.left = `${(startTime / duration) * 100}%`;
startMarker.style.display = 'block';
} else {
startMarker.style.display = 'none';
}
if (endTime < duration) {
endMarker.style.left = `${(endTime / duration) * 100}%`;
endMarker.style.display = 'block';
} else {
endMarker.style.display = 'none';
}
}
}
// 開始/終了時間変更時にマーカーを更新
startTimeInput.addEventListener('input', updateProgressMarkers);
endTimeInput.addEventListener('input', updateProgressMarkers);
// 合成ボタンクリック
combineButton.addEventListener('click', combineAudio);
// プレビューボタンクリック
previewButton.addEventListener('click', togglePreview);
// 同期ステータスを閉じる
syncStatusClose.addEventListener('click', function() {
syncStatus.style.display = 'none';
});
// 初期化
loadAudioFiles();
updateVolumeIcon();
volumeSlider.value = video.volume;
video.controls = false;
// スライダーの背景を初期化
function initSliderBackgrounds() {
const sliders = [
volumeSlider,
speedSlider,
globalVolumeSlider,
playbackSpeedSlider,
...audioSliders
];
sliders.forEach(slider => {
if (slider) {
slider.style.backgroundImage =
'linear-gradient(#64ffda, #64ffda)';
slider.style.backgroundRepeat = 'no-repeat';
if (slider === globalVolumeSlider) {
const percent = (slider.value - slider.min) / (slider.max -
slider.min) * 100;
slider.style.backgroundSize = `${percent}% 100%`;
globalVolumeValue.textContent = slider.value;
} else {
slider.style.backgroundSize =
`${slider.value * 100}% 100%`;
}
}
});
}
initSliderBackgrounds();
startSyncCheck(); // 同期チェックを開始
// 初期テンポ設定
tempoInput.value = isTMode ? 66 : 92;
tempoInput.dispatchEvent(new Event('input'));
});
// タイムマーカー関連のコードを修正
document.addEventListener('DOMContentLoaded', function() {
// まず必要なDOM要素を取得
const startMarker = document.getElementById('start-marker');
const endMarker = document.getElementById('end-marker');
const video = document.getElementById('video');
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
// URLパラメータをチェック
const urlParams = new URLSearchParams(window.location.search);
const isTMode = urlParams.has('mode') && urlParams.get('mode') ===
't';
// タイムマーカーデータ
const timeMarkers = isTMode ? [{
label: '①[1番]',
time: 15.2
}, {
label: '②[1番]',
time: 43.8
}, {
label: '①[2番]',
time: 79.0
}, {
label: '②[2番]',
time: 108.0
}, {
label: '③',
time: 137.25
}, {
label: '④',
time: 162.55
}, {
label: '⑤',
time: 191.65
}] : [{
label: 'A',
time: 8.35
}, {
label: 'B',
time: 72.1
}, {
label: 'C',
time: 98.67
}, {
label: 'D',
time: 155.83
}, {
label: 'E',
time: 192.55
}];
// タイムマーカーを表示
const timeMarkersContainer = document.getElementById(
'time-markers-container');
timeMarkers.forEach(marker => {
const markerElement = document.createElement('div');
markerElement.className = 'time-marker';
markerElement.innerHTML =
`<b>${marker.label}</b>(${marker.time}秒)`;
markerElement.dataset.time = marker.time;
// ドラッグ開始
markerElement.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', marker.time);
this.classList.add('dragging');
});
// ドラッグ終了
markerElement.addEventListener('dragend', function() {
this.classList.remove('dragging');
});
// ドラッグ可能に設定
markerElement.draggable = true;
// クリックで開始時間に設定
markerElement.addEventListener('click', function() {
startTimeInput.value = marker.time;
updateProgressMarkers();
});
timeMarkersContainer.appendChild(markerElement);
});
// ドロップターゲットの設定
[startTimeInput, endTimeInput].forEach(input => {
// ドラッグオーバー
input.addEventListener('dragover', function(e) {
e.preventDefault();
this.style.backgroundColor = 'rgba(100, 255, 218, 0.2)';
});
// ドラッグリーブ
input.addEventListener('dragleave', function() {
this.style.backgroundColor = '';
});
// ドロップ
input.addEventListener('drop', function(e) {
e.preventDefault();
this.style.backgroundColor = '';
const time = parseFloat(e.dataTransfer.getData(
'text/plain'));
if (!isNaN(time)) {
this.value = time;
updateProgressMarkers();
}
});
});
// 入力ボックスの変更時にマーカーを更新
startTimeInput.addEventListener('input', updateProgressMarkers);
endTimeInput.addEventListener('input', updateProgressMarkers);
// updateProgressMarkers関数
function updateProgressMarkers() {
const duration = video.duration || 0;
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || duration;
if (duration > 0) {
if (startMarker) startMarker.style.left =
`${(startTime / duration) * 100}%`;
if (endMarker) endMarker.style.left =
`${(endTime / duration) * 100}%`;
if (startMarker) startMarker.style.display = 'block';
if (endMarker) endMarker.style.display = 'block';
}
}
});
</script>
</body>
</html>