add chinese subtitles

This commit is contained in:
2026-10-03 12:14:17 +08:00
parent 138d446d73
commit 6bd64ee4a9
22 changed files with 922 additions and 77 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)")
}
}
}
}
}

View File

@@ -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 ? "已开启中文字幕" : "已关闭中文字幕")
}
}
}