155 lines
5.7 KiB
HTML
155 lines
5.7 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>口语配音</title>
|
||
<style>
|
||
body{margin:0;background:#0b0f13;color:#e7edf3;font-family:-apple-system,BlinkMacSystemFont,sans-serif}
|
||
.wrap{max-width:900px;margin:0 auto;padding:20px}
|
||
video{width:100%;border-radius:12px;background:#000;aspect-ratio:16/9}
|
||
h1{font-size:22px}.muted{color:#94a3b8;font-size:14px}
|
||
.controls{display:flex;gap:10px;margin:14px 0}
|
||
button{flex:1;height:44px;border:0;border-radius:10px;color:#fff;background:#2563eb;font-weight:700;font-size:15px}
|
||
.active{background:#14b8a6}
|
||
.sentences{display:flex;flex-direction:column;gap:8px}
|
||
.sentence{padding:12px;border-radius:10px;background:#1c2228;color:#cbd5e1;line-height:1.5}
|
||
.active-sentence{outline:2px solid #14b8a6;color:#fff}
|
||
.selected-sentence{background:#334155;color:#fff}
|
||
.score{margin-top:6px;font-size:12px;color:#94a3b8}
|
||
.score.good{color:#14b8a6}.score.low{color:#f97316}
|
||
.footer{margin-top:16px;font-size:13px;color:#94a3b8}
|
||
.footer a{color:#60a5fa;text-decoration:none}
|
||
</style>
|
||
|
||
<div class="wrap">
|
||
<h1 id="title">加载中…</h1>
|
||
<p class="muted" id="status">正在读取配音数据</p>
|
||
<video id="video" playsinline controls></video>
|
||
<div class="controls">
|
||
<button id="original">播放原音</button>
|
||
<button id="dubbing">播放配音</button>
|
||
</div>
|
||
<div class="sentences" id="sentences"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const video = document.getElementById('video');
|
||
const originalButton = document.getElementById('original');
|
||
const dubbingButton = document.getElementById('dubbing');
|
||
const statusText = document.getElementById('status');
|
||
const sentenceList = document.getElementById('sentences');
|
||
let dubAudio = new Audio();
|
||
dubAudio.preload = 'auto';
|
||
let segments = [];
|
||
let selectedIndex = 0;
|
||
let mode = null;
|
||
|
||
function stopAll() {
|
||
video.pause();
|
||
dubAudio.pause();
|
||
originalButton.classList.remove('active');
|
||
dubbingButton.classList.remove('active');
|
||
mode = null;
|
||
}
|
||
|
||
function playOriginal() {
|
||
stopAll();
|
||
mode = 'original';
|
||
originalButton.classList.add('active');
|
||
video.muted = false;
|
||
playSelectedSentence();
|
||
}
|
||
|
||
function syncDub() {
|
||
const timeMs = video.currentTime * 1000;
|
||
const sentence = segments[selectedIndex];
|
||
if (sentence && timeMs >= sentence.end_ms) {
|
||
video.pause();
|
||
dubAudio.pause();
|
||
video.currentTime = sentence.end_ms / 1000;
|
||
return;
|
||
}
|
||
const active = segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms);
|
||
document.querySelectorAll('.sentence').forEach((node, index) => {
|
||
node.classList.toggle('active-sentence', segments[index] === active);
|
||
});
|
||
if (mode !== 'dubbing') return;
|
||
if (!active) {
|
||
dubAudio.pause();
|
||
return;
|
||
}
|
||
const source = active.audio_url;
|
||
if (dubAudio.dataset.src !== source) {
|
||
dubAudio.dataset.src = source;
|
||
dubAudio.src = source;
|
||
}
|
||
const target = Math.max(0, timeMs - active.start_ms) / 1000;
|
||
if (Math.abs(dubAudio.currentTime - target) > 0.25 && Number.isFinite(target)) {
|
||
dubAudio.currentTime = target;
|
||
}
|
||
if (video.paused || dubAudio.paused) dubAudio.play().catch(() => {});
|
||
}
|
||
|
||
function playDubbing() {
|
||
stopAll();
|
||
mode = 'dubbing';
|
||
dubbingButton.classList.add('active');
|
||
video.muted = true;
|
||
playSelectedSentence();
|
||
}
|
||
|
||
function playSelectedSentence() {
|
||
const sentence = segments[selectedIndex];
|
||
if (!sentence) return;
|
||
video.currentTime = sentence.start_ms / 1000;
|
||
dubAudio.dataset.src = '';
|
||
dubAudio.src = '';
|
||
video.play().catch(() => {});
|
||
}
|
||
|
||
async function load() {
|
||
try {
|
||
const shareId = location.pathname.split('/').pop();
|
||
const shareResponse = await fetch(`/api/v1/dub-shares/${shareId}`);
|
||
if (!shareResponse.ok) throw new Error(`HTTP ${shareResponse.status}`);
|
||
const share = await shareResponse.json();
|
||
segments = share.segments;
|
||
const detailResponse = await fetch(`/api/v1/videos/${share.video_hash}`);
|
||
const detail = await detailResponse.json();
|
||
video.src = detail.video.stream_url;
|
||
document.title = share.title;
|
||
document.getElementById('title').textContent = share.title;
|
||
statusText.textContent = `共 ${segments.length} 句配音`;
|
||
segments.forEach(item => {
|
||
const node = document.createElement('div');
|
||
node.className = 'sentence';
|
||
if (item === segments[selectedIndex]) node.classList.add('selected-sentence');
|
||
node.textContent = item.text || `第 ${item.index + 1} 句`;
|
||
node.onclick = () => {
|
||
selectedIndex = segments.indexOf(item);
|
||
document.querySelectorAll('.sentence').forEach((other, index) => {
|
||
other.classList.toggle('selected-sentence', index === selectedIndex);
|
||
});
|
||
video.currentTime = item.start_ms / 1000;
|
||
};
|
||
const score = document.createElement('div');
|
||
score.className = 'score' + (item.overall_score >= 80 ? ' good' : item.overall_score && item.overall_score < 60 ? ' low' : '');
|
||
score.textContent = `总分 ${Number(item.overall_score || 0).toFixed(1)}`;
|
||
if (item.recognized_text) score.textContent += ` · 识别:${item.recognized_text}`;
|
||
node.appendChild(score);
|
||
sentenceList.appendChild(node);
|
||
});
|
||
originalButton.onclick = playOriginal;
|
||
dubbingButton.onclick = playDubbing;
|
||
video.addEventListener('timeupdate', syncDub);
|
||
video.addEventListener('play', () => { if (mode === 'dubbing') syncDub(); });
|
||
video.addEventListener('pause', () => dubAudio.pause());
|
||
video.addEventListener('seeked', syncDub);
|
||
} catch (error) {
|
||
statusText.textContent = `加载失败:${error.message}`;
|
||
}
|
||
}
|
||
load();
|
||
</script>
|
||
<p class="footer">了解更多或下载跟读App:<a href="https://www.pgyer.com/genduchong" target="_blank" rel="noopener">www.pgyer.com/genduchong</a></p>
|