added share function

This commit is contained in:
2026-08-26 09:47:52 +08:00
parent ccd8ec16bb
commit f80da8a5f6
5 changed files with 435 additions and 18 deletions

View File

@@ -22,8 +22,10 @@ import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.StrictMode
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -57,8 +59,12 @@ import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
import java.io.File import java.io.File
import java.io.DataOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.Locale import java.util.Locale
import org.json.JSONObject
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlin.math.max import kotlin.math.max
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@@ -95,6 +101,7 @@ class MainActivity : Activity() {
private var recordingFile: File? = null private var recordingFile: File? = null
private val dubSegments = mutableMapOf<Int, File>() private val dubSegments = mutableMapOf<Int, File>()
private var mergedDubFile: File? = null private var mergedDubFile: File? = null
private var dubbingStatusText: TextView? = null
private var dubbingPlayback = false private var dubbingPlayback = false
private var activeItemId: String = "" private var activeItemId: String = ""
@@ -712,6 +719,12 @@ class MainActivity : Activity() {
setLineSpacing(4.dp.toFloat(), 1f) setLineSpacing(4.dp.toFloat(), 1f)
setPadding(0, 8.dp, 0, 0) setPadding(0, 8.dp, 0, 0)
} }
dubbingStatusText = TextView(this).apply {
setTextColor(COLOR_ACCENT_DEEP)
textSize = 13f
setPadding(0, 10.dp, 0, 0)
text = "配音:尚未生成"
}
val sentenceButton = { label: String, action: () -> Unit -> val sentenceButton = { label: String, action: () -> Unit ->
Button(this).apply { Button(this).apply {
@@ -840,9 +853,14 @@ class MainActivity : Activity() {
}) })
addView(scoreSummaryText) addView(scoreSummaryText)
addView(scoreDetailText) addView(scoreDetailText)
addView(dubbingStatusText)
} }
} }
private fun setDubbingStatus(message: String) {
dubbingStatusText?.text = message
}
private fun refreshTestUi() { private fun refreshTestUi() {
if (!::testStatusText.isInitialized) { if (!::testStatusText.isInitialized) {
return return
@@ -862,6 +880,13 @@ class MainActivity : Activity() {
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel" "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
} }
refreshDubButtons() refreshDubButtons()
if (mergedDubFile == null) {
dubbingStatusText?.text = when (dubSegments.size) {
0 -> "配音:请先录音"
1 -> "配音:已保存 1 句,可生成配音"
else -> "配音:已保存 ${dubSegments.size} 句,可生成配音"
}
}
} }
private fun applyTestSubtitleVisibility() { private fun applyTestSubtitleVisibility() {
@@ -976,7 +1001,7 @@ class MainActivity : Activity() {
private fun refreshDubButtons() { private fun refreshDubButtons() {
playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
mergeDubButton.isEnabled = dubSegments.size >= 2 && !dubbingPlayback mergeDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback
val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] } val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] }
playDubButton.text = when { playDubButton.text = when {
@@ -1049,16 +1074,19 @@ class MainActivity : Activity() {
} }
private fun mergeDubSegments() { private fun mergeDubSegments() {
if (dubSegments.size < 2) { val segments = dubSegments.toSortedMap().values.toList()
if (segments.isEmpty()) {
return return
} }
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a") val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
var muxer: MediaMuxer? = null
try { try {
val muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var muxerAudioTrack = -1 var muxerAudioTrack = -1
var muxerStarted = false
var presentationTimeUs = 0L var presentationTimeUs = 0L
dubSegments.toSortedMap().values.forEach { segment -> segments.forEach { segment ->
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) } val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
try { try {
val trackIndex = (0 until extractor.trackCount).firstOrNull { index -> val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
@@ -1069,6 +1097,8 @@ class MainActivity : Activity() {
val format = extractor.getTrackFormat(trackIndex) val format = extractor.getTrackFormat(trackIndex)
if (muxerAudioTrack == -1) { if (muxerAudioTrack == -1) {
muxerAudioTrack = muxer.addTrack(format) muxerAudioTrack = muxer.addTrack(format)
muxer.start()
muxerStarted = true
} }
val buffer = ByteBuffer.allocateDirect( val buffer = ByteBuffer.allocateDirect(
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) { if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
@@ -1093,14 +1123,24 @@ class MainActivity : Activity() {
extractor.release() extractor.release()
} }
} }
require(muxerStarted && muxerAudioTrack != -1) { "录音没有可用音频轨道" }
muxer.stop() muxer.stop()
muxer.release() muxer.release()
muxer = null
mergedDubFile = output mergedDubFile = output
refreshTestUi() refreshTestUi()
statusText.text = "已合成 ${dubSegments.size} 配音${output.name}" setDubbingStatus("已合成 ${segments.size} 配音,可点击“分享配音”")
} catch (error: Throwable) { } catch (error: Throwable) {
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
output.delete() output.delete()
statusText.text = "合成失败:${error.message.orEmpty()}" Log.e(TAG_DUBBING, "merge failed", error)
setDubbingStatus(
buildString {
append("生成失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
append(" · 已保存 ${dubSegments.size}")
}
)
} }
} }
@@ -1120,22 +1160,74 @@ class MainActivity : Activity() {
} }
private fun shareMergedDubbing() { private fun shareMergedDubbing() {
val file = mergedDubFile?.takeIf { it.exists() } ?: return val item = controller.currentTrainingItem()
if (item == null || !SHA256_PATTERN.matches(item.id)) {
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
return
}
try { try {
val uri = FileProvider.getUriForFile( val shareUrl = uploadDubShare(item.id, item.title)
this,
"${packageName}.fileprovider",
file,
)
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = "audio/mp4" type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
putExtra(Intent.EXTRA_TITLE, "我的口语配音") putExtra(Intent.EXTRA_TITLE, "我的口语配音")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
startActivity(Intent.createChooser(intent, "分享配音")) startActivity(Intent.createChooser(intent, "分享配音"))
setDubbingStatus("已生成分享链接:$shareUrl")
} catch (error: Throwable) { } catch (error: Throwable) {
statusText.text = "分享失败:${error.message.orEmpty()}" Log.e(TAG_DUBBING, "share failed", error)
setDubbingStatus("分享失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
}
}
private fun uploadDubShare(videoHash: String, videoTitle: String): String {
val policy = StrictMode.getThreadPolicy()
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
val boundary = "oral-trainer-${System.currentTimeMillis()}"
var connection: HttpURLConnection? = null
try {
connection = URL(URL(SERVER_BASE_URL), "/api/v1/dub-shares").openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
DataOutputStream(connection.outputStream).use { output ->
fun text(name: String, value: String) {
output.writeBytes("--$boundary\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
output.writeBytes(value.replace("\n", "\\n") + "\r\n")
}
text("video_hash", videoHash.lowercase(Locale.US))
text("title", videoTitle.ifBlank { "我的口语配音" })
// Build explicitly to avoid fragile string replacement.
val payload = dubSegments.keys.sorted().joinToString(",", "[", "]") {
"{\"sentence_index\":$it}"
}
text("segments", payload)
dubSegments.toSortedMap().forEach { (index, file) ->
output.writeBytes("--$boundary\r\n")
output.writeBytes(
"Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
)
output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
file.inputStream().use { input -> input.copyTo(output) }
output.writeBytes("\r\n")
}
output.writeBytes("--$boundary--\r\n")
}
val code = connection.responseCode
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
require(code in 200..299) { "HTTP $code: $body" }
val response = JSONObject(body).getString("share_id")
return "$SERVER_BASE_URL/dub-shares/$response"
} finally {
connection?.disconnect()
StrictMode.setThreadPolicy(policy)
} }
} }
@@ -1511,6 +1603,7 @@ class MainActivity : Activity() {
const val PICK_VIDEO_REQUEST = 1001 const val PICK_VIDEO_REQUEST = 1001
const val RECORD_AUDIO_REQUEST = 1002 const val RECORD_AUDIO_REQUEST = 1002
const val PROGRESS_MAX = 1000 const val PROGRESS_MAX = 1000
private const val TAG_DUBBING = "Dubbing"
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$") private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")

View File

@@ -68,10 +68,14 @@ class Settings:
def attempts_dir(self) -> Path: def attempts_dir(self) -> Path:
return self.data_dir / "attempts" return self.data_dir / "attempts"
@property
def dub_shares_dir(self) -> Path:
return self.data_dir / "dub_shares"
@property @property
def database_path(self) -> Path: def database_path(self) -> Path:
return self.data_dir / "oral_trainer.sqlite3" return self.data_dir / "oral_trainer.sqlite3"
def ensure_directories(self) -> None: def ensure_directories(self) -> None:
for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir): for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir, self.dub_shares_dir):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)

View File

@@ -4,7 +4,8 @@ import logging
import mimetypes import mimetypes
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any, BinaryIO, Dict, Optional from typing import Any, BinaryIO, Dict, List, Optional
import json
from fastapi import ( from fastapi import (
BackgroundTasks, BackgroundTasks,
@@ -51,6 +52,7 @@ SHA256_PATH = ApiPath(
description="SHA-256 hex digest of the exact uploaded video bytes", description="SHA-256 hex digest of the exact uploaded video bytes",
) )
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"} ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
class UploadTooLargeError(ValueError): class UploadTooLargeError(ValueError):
@@ -380,6 +382,116 @@ def create_app(
if not service_settings.keep_attempt_audio or not completed: if not service_settings.keep_attempt_audio or not completed:
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
@application.post("/api/v1/dub-shares", status_code=201)
async def create_dub_share(
request: Request,
video_hash: str = Form(...),
title: str = Form(default="我的口语配音"),
segments: str = Form(...),
files: List[UploadFile] = File(...),
) -> Dict[str, Any]:
try:
segment_items = json.loads(segments)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="segments must be valid JSON.") from exc
if not isinstance(segment_items, list) or not segment_items:
raise HTTPException(status_code=400, detail="At least one dubbing segment is required.")
if len(segment_items) != len(files):
raise HTTPException(status_code=400, detail="Segment count does not match audio file count.")
document = find_document(video_hash.lower())
if document is None:
raise HTTPException(status_code=404, detail="Video or sentence boundaries were not found.")
prepared_segments: List[Dict[str, Any]] = []
saved_files: List[Path] = []
try:
for item, upload in zip(segment_items, files):
sentence_index = int(item["sentence_index"])
boundary = next((s for s in document.sentences if s.index == sentence_index), None)
if boundary is None:
raise HTTPException(status_code=404, detail=f"Sentence {sentence_index} was not found.")
suffix = Path(upload.filename or "dub.m4a").suffix.lower()
if suffix == ".mp4":
suffix = ".m4a"
if suffix not in ALLOWED_AUDIO_EXTENSIONS:
raise HTTPException(status_code=415, detail=f"Unsupported audio extension: {suffix or '(none)'}")
audio_name = f"{uuid.uuid4().hex}{suffix}"
audio_path = service_settings.dub_shares_dir / audio_name
await run_in_threadpool(_persist_upload, upload.file, audio_path, 50 * 1024 * 1024)
saved_files.append(audio_path)
prepared_segments.append({
"sentence_index": sentence_index,
"start_ms": boundary.start_ms,
"end_ms": boundary.end_ms,
"text": boundary.text,
"audio_filename": audio_name,
"audio_size_bytes": audio_path.stat().st_size,
})
share = video_repository.create_dub_share(
video_hash=video_hash.lower(),
title=title,
segments=prepared_segments,
)
if share is None:
raise HTTPException(status_code=404, detail="Video was not found.")
return {
**share,
"share_url": f"/dub-shares/{share['share_id']}",
}
except UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except HTTPException:
raise
finally:
for upload in files:
await upload.close()
# Files intentionally remain until share deletion because the web page references them.
del saved_files
@application.get("/api/v1/dub-shares/{share_id}")
def get_dub_share(share_id: str) -> Dict[str, Any]:
share = video_repository.get_dub_share(share_id)
if share is None:
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
return {
"share_id": share["share_id"],
"title": share["title"],
"created_at": share["created_at"],
"segments": [
{
"index": row["sentence_index"],
"start_ms": row["start_ms"],
"end_ms": row["end_ms"],
"text": row["text"],
"audio_url": f"/api/v1/dub-shares/{share_id}/audio/{row['audio_filename']}",
}
for row in share["segments"]
],
}
@application.get("/api/v1/dub-shares/{share_id}/audio/{filename}")
def get_dub_share_audio(share_id: str, filename: str) -> FileResponse:
share = video_repository.get_dub_share(share_id)
if share is None:
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
safe_name = Path(filename).name
row = next((item for item in share["segments"] if item["audio_filename"] == safe_name), None)
if row is None:
raise HTTPException(status_code=404, detail="Audio was not found.")
media_path = service_settings.dub_shares_dir / safe_name
if not media_path.is_file():
raise HTTPException(status_code=404, detail="Stored audio file is missing.")
return FileResponse(media_path, media_type="audio/mp4", headers={"Cache-Control": "public, max-age=31536000"})
@application.get("/dub-shares/{share_id}", include_in_schema=False)
def dub_share_page(share_id: str) -> FileResponse:
if video_repository.get_dub_share(share_id) is None:
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
page = static_dir / "dub-share.html"
if not page.is_file():
raise HTTPException(status_code=404, detail="Dubbing share UI is not installed.")
return FileResponse(page)
return application return application

View File

@@ -3,6 +3,7 @@ import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import uuid
from .models import SentenceBoundary, SentenceBoundaryDocument from .models import SentenceBoundary, SentenceBoundaryDocument
from .store import normalize_video_hash from .store import normalize_video_hash
@@ -67,6 +68,27 @@ class VideoRepository:
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS dub_shares (
share_id TEXT PRIMARY KEY,
video_hash TEXT NOT NULL,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dub_share_segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_id TEXT NOT NULL,
sentence_index INTEGER NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
text TEXT,
audio_filename TEXT NOT NULL,
audio_size_bytes INTEGER NOT NULL,
FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE,
UNIQUE (share_id, sentence_index)
);
""" """
) )
@@ -266,6 +288,72 @@ class VideoRepository:
reference_speech_duration_ms=row["reference_speech_duration_ms"], reference_speech_duration_ms=row["reference_speech_duration_ms"],
) )
def create_dub_share(
self,
*,
video_hash: str,
title: str,
segments: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
normalized_hash = normalize_video_hash(video_hash)
if self.get_video(normalized_hash) is None:
return None
share_id = uuid.uuid4().hex
now = utc_now()
with self._connect() as connection:
connection.execute(
"""
INSERT INTO dub_shares (share_id, video_hash, title, created_at)
VALUES (?, ?, ?, ?)
""",
(share_id, normalized_hash, title[:200], now),
)
connection.executemany(
"""
INSERT INTO dub_share_segments (
share_id, sentence_index, start_ms, end_ms, text,
audio_filename, audio_size_bytes
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(
share_id,
segment["sentence_index"],
segment["start_ms"],
segment["end_ms"],
segment["text"],
segment["audio_filename"],
segment["audio_size_bytes"],
)
for segment in segments
],
)
return {
"share_id": share_id,
"video_hash": normalized_hash,
"title": title[:200],
"created_at": now,
}
def get_dub_share(self, share_id: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
share = connection.execute(
"SELECT * FROM dub_shares WHERE share_id = ?", (share_id,)
).fetchone()
if share is None:
return None
rows = connection.execute(
"""
SELECT * FROM dub_share_segments
WHERE share_id = ?
ORDER BY sentence_index
""",
(share_id,),
).fetchall()
result = dict(share)
result["segments"] = [dict(row) for row in rows]
return result
def record_attempt( def record_attempt(
self, self,
*, *,

View File

@@ -0,0 +1,120 @@
<!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}
</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 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;
video.play();
}
function syncDub() {
const timeMs = video.currentTime * 1000;
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;
video.play();
syncDub();
}
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';
node.textContent = item.text || `${item.index + 1}`;
node.onclick = () => { video.currentTime = item.start_ms / 1000; };
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>