add chinese subtitles
This commit is contained in:
@@ -15,6 +15,7 @@ enum AppConfig {
|
||||
static let connectTimeout: TimeInterval = 15
|
||||
static let readTimeout: TimeInterval = 30
|
||||
static let assessmentTimeout: TimeInterval = 180
|
||||
static let translationTimeout: TimeInterval = 120
|
||||
|
||||
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ struct SentenceBoundary: Identifiable, Equatable, Decodable {
|
||||
let text: String?
|
||||
let language: String?
|
||||
let referenceSpeechDurationMs: Int64?
|
||||
let zhText: String?
|
||||
|
||||
var id: Int { index }
|
||||
|
||||
@@ -15,6 +16,48 @@ struct SentenceBoundary: Identifiable, Equatable, Decodable {
|
||||
case startMs = "start_ms"
|
||||
case endMs = "end_ms"
|
||||
case referenceSpeechDurationMs = "reference_speech_duration_ms"
|
||||
case zhText = "zh_text"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
index = try container.decode(Int.self, forKey: .index)
|
||||
startMs = try container.decode(Int64.self, forKey: .startMs)
|
||||
endMs = try container.decode(Int64.self, forKey: .endMs)
|
||||
text = try container.decodeIfPresent(String.self, forKey: .text)
|
||||
language = try container.decodeIfPresent(String.self, forKey: .language)
|
||||
referenceSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceSpeechDurationMs)
|
||||
zhText = try container.decodeIfPresent(String.self, forKey: .zhText)
|
||||
}
|
||||
|
||||
init(
|
||||
index: Int,
|
||||
startMs: Int64,
|
||||
endMs: Int64,
|
||||
text: String?,
|
||||
language: String?,
|
||||
referenceSpeechDurationMs: Int64?,
|
||||
zhText: String? = nil
|
||||
) {
|
||||
self.index = index
|
||||
self.startMs = startMs
|
||||
self.endMs = endMs
|
||||
self.text = text
|
||||
self.language = language
|
||||
self.referenceSpeechDurationMs = referenceSpeechDurationMs
|
||||
self.zhText = zhText
|
||||
}
|
||||
|
||||
func withZhText(_ zhText: String?) -> SentenceBoundary {
|
||||
SentenceBoundary(
|
||||
index: index,
|
||||
startMs: startMs,
|
||||
endMs: endMs,
|
||||
text: text,
|
||||
language: language,
|
||||
referenceSpeechDurationMs: referenceSpeechDurationMs,
|
||||
zhText: zhText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,26 @@ struct SentenceExplanationResponse: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct SubtitleTranslationItem: Decodable {
|
||||
let index: Int
|
||||
let zhText: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case index
|
||||
case zhText = "zh_text"
|
||||
}
|
||||
}
|
||||
|
||||
struct SubtitleTranslationResponse: Decodable {
|
||||
let videoHash: String
|
||||
let translations: [SubtitleTranslationItem]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case translations
|
||||
case videoHash = "video_hash"
|
||||
}
|
||||
}
|
||||
|
||||
extension SentenceBoundaryAPI {
|
||||
static func explain(
|
||||
_ sentence: SentenceBoundary,
|
||||
@@ -79,4 +99,32 @@ extension SentenceBoundaryAPI {
|
||||
.decode(SentenceExplanationResponse.self, from: result.body)
|
||||
.explanation
|
||||
}
|
||||
|
||||
static func translateSubtitles(videoHash: String) async throws -> [Int: String] {
|
||||
let hash = videoHash.lowercased()
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw SentenceBoundaryAPIError.invalidHash
|
||||
}
|
||||
var headers: [String: String] = ["Accept": "application/json"]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos/\(hash)/subtitle-translation",
|
||||
method: "POST",
|
||||
timeout: AppConfig.translationTimeout,
|
||||
headers: headers
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let detail = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, detail)
|
||||
}
|
||||
let payload = try JSONDecoder().decode(SubtitleTranslationResponse.self, from: result.body)
|
||||
var mapped: [Int: String] = [:]
|
||||
for item in payload.translations where !item.zhText.isEmpty {
|
||||
mapped[item.index] = item.zhText
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftUI
|
||||
struct FullscreenPlayerView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
var showsLessonTitle: Bool = true
|
||||
var showChineseSubtitles: Bool = false
|
||||
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||
let onExit: () -> Void
|
||||
|
||||
@State private var subtitleVisible = true
|
||||
@@ -66,10 +68,18 @@ struct FullscreenPlayerView: View {
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
if let chineseSubtitleText = chineseSubtitleText {
|
||||
Text(chineseSubtitleText)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
guard let sentence = controller.currentSentence,
|
||||
@@ -203,6 +213,20 @@ struct FullscreenPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var chineseSubtitleText: String? {
|
||||
guard showChineseSubtitles else {
|
||||
return nil
|
||||
}
|
||||
if let zh = controller.currentSentence?.zhText?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!zh.isEmpty {
|
||||
return zh
|
||||
}
|
||||
if (controller.currentSentence?.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
return chineseSubtitlePlaceholder
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.cacheState.statusMessage {
|
||||
return message
|
||||
|
||||
@@ -7,6 +7,8 @@ struct PlayerSectionView: View {
|
||||
@Binding var continuousPlayback: Bool
|
||||
var showsSubtitle: Binding<Bool>? = .constant(true)
|
||||
var showsLessonTitle: Bool = true
|
||||
var showChineseSubtitles: Binding<Bool>? = nil
|
||||
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||
var onFullscreen: () -> Void
|
||||
|
||||
@State private var progressDragging = false
|
||||
@@ -93,14 +95,34 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
if let showChineseSubtitles = showChineseSubtitles {
|
||||
HStack(spacing: 0) {
|
||||
Text("中文字幕")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
Spacer()
|
||||
Toggle("", isOn: showChineseSubtitles)
|
||||
.labelsHidden()
|
||||
.tint(Theme.accent)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(4)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(4)
|
||||
if let chineseSubtitleText = chineseSubtitleText {
|
||||
Text(chineseSubtitleText)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.lineLimit(4)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
guard let sentence = controller.currentSentence, !(sentence.text ?? "").isEmpty else {
|
||||
@@ -205,6 +227,20 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var chineseSubtitleText: String? {
|
||||
guard showChineseSubtitles?.wrappedValue == true else {
|
||||
return nil
|
||||
}
|
||||
if let zh = controller.currentSentence?.zhText?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!zh.isEmpty {
|
||||
return zh
|
||||
}
|
||||
if (controller.currentSentence?.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
return chineseSubtitlePlaceholder
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.cacheState.statusMessage {
|
||||
return message
|
||||
|
||||
@@ -9,6 +9,10 @@ struct RootView: View {
|
||||
@StateObject private var historyManager = TrainingHistoryManager()
|
||||
@State private var activeModule: Module = .course
|
||||
@State private var continuousPlayback = false
|
||||
@State private var showChineseSubtitles = false
|
||||
@State private var chineseTranslationLoading = false
|
||||
@State private var chineseTranslationError = ""
|
||||
@State private var chineseTranslationTask: Task<Void, Never>?
|
||||
@State private var showImporter = false
|
||||
@State private var showFullscreenPlayer = false
|
||||
@State private var courses: [Course] = []
|
||||
@@ -53,6 +57,8 @@ struct RootView: View {
|
||||
controller: controller,
|
||||
catalogVM: catalogVM,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
showChineseSubtitles: $showChineseSubtitles,
|
||||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
|
||||
onFullscreen: enterFullscreen
|
||||
)
|
||||
case .test:
|
||||
@@ -88,9 +94,24 @@ struct RootView: View {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
}
|
||||
.onChange(of: showChineseSubtitles) { enabled in
|
||||
if enabled {
|
||||
ensureChineseTranslations()
|
||||
} else {
|
||||
chineseTranslationTask?.cancel()
|
||||
chineseTranslationLoading = false
|
||||
}
|
||||
}
|
||||
.onChange(of: controller.currentItem?.id) { _ in
|
||||
ensureChineseTranslations()
|
||||
}
|
||||
.onChange(of: controller.currentItem?.sentences.count) { _ in
|
||||
ensureChineseTranslations()
|
||||
}
|
||||
.onChange(of: activeModule) { module in
|
||||
if module == .train {
|
||||
controller.setContinuousPlayback(continuousPlayback)
|
||||
ensureChineseTranslations()
|
||||
} else if module == .test {
|
||||
// 与安卓测试页一致:按当前句窗口播放,避免滑进下一句。
|
||||
controller.setContinuousPlayback(false)
|
||||
@@ -115,7 +136,9 @@ struct RootView: View {
|
||||
.fullScreenCover(isPresented: $showFullscreenPlayer) {
|
||||
FullscreenPlayerView(
|
||||
controller: controller,
|
||||
showsLessonTitle: activeModule != .train
|
||||
showsLessonTitle: activeModule != .train,
|
||||
showChineseSubtitles: showChineseSubtitles,
|
||||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder
|
||||
) {
|
||||
exitFullscreen()
|
||||
}
|
||||
@@ -593,4 +616,59 @@ struct RootView: View {
|
||||
AppDelegate.lockOrientation(restored)
|
||||
showFullscreenPlayer = false
|
||||
}
|
||||
|
||||
private var chineseSubtitlePlaceholder: String {
|
||||
if chineseTranslationLoading {
|
||||
return "正在翻译中文字幕…"
|
||||
}
|
||||
if !chineseTranslationError.isEmpty {
|
||||
return chineseTranslationError
|
||||
}
|
||||
return "暂无中文字幕"
|
||||
}
|
||||
|
||||
private func ensureChineseTranslations() {
|
||||
guard showChineseSubtitles, activeModule == .train else {
|
||||
return
|
||||
}
|
||||
guard let item = controller.currentItem else {
|
||||
return
|
||||
}
|
||||
let needsTranslation = item.sentences.contains {
|
||||
!($0.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
($0.zhText ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
guard needsTranslation else {
|
||||
chineseTranslationLoading = false
|
||||
chineseTranslationError = ""
|
||||
return
|
||||
}
|
||||
chineseTranslationTask?.cancel()
|
||||
chineseTranslationLoading = true
|
||||
chineseTranslationError = ""
|
||||
let mediaId = item.id
|
||||
chineseTranslationTask = Task {
|
||||
do {
|
||||
let map = try await SentenceBoundaryAPI.translateSubtitles(videoHash: mediaId)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard controller.currentItem?.id == mediaId else { return }
|
||||
let updated = (controller.currentItem?.sentences ?? []).map { sentence in
|
||||
sentence.withZhText(map[sentence.index] ?? sentence.zhText)
|
||||
}
|
||||
controller.updateBoundaries(updated)
|
||||
chineseTranslationLoading = false
|
||||
chineseTranslationError = ""
|
||||
controller.showTransientMessage("已加载中文字幕")
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
chineseTranslationLoading = false
|
||||
chineseTranslationError = "中文字幕翻译失败"
|
||||
controller.showTransientMessage("中文字幕翻译失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ struct TrainingView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
@ObservedObject var catalogVM: CatalogViewModel
|
||||
@Binding var continuousPlayback: Bool
|
||||
@Binding var showChineseSubtitles: Bool
|
||||
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||
let onFullscreen: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -14,6 +16,8 @@ struct TrainingView: View {
|
||||
showsContinuousToggle: true,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
showsLessonTitle: false,
|
||||
showChineseSubtitles: $showChineseSubtitles,
|
||||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
|
||||
onFullscreen: onFullscreen
|
||||
)
|
||||
CatalogView(vm: catalogVM)
|
||||
@@ -28,5 +32,8 @@ struct TrainingView: View {
|
||||
: "已关闭连续播放,将循环播放当前句子"
|
||||
)
|
||||
}
|
||||
.onChange(of: showChineseSubtitles) { enabled in
|
||||
controller.showTransientMessage(enabled ? "已开启中文字幕" : "已关闭中文字幕")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user