Files
mediaplayer/ios/OralTrainer/Views/PlayerSectionView.swift
2026-09-06 07:38:56 +08:00

278 lines
9.8 KiB
Swift

import SwiftUI
/// 播放区 + 悬浮信息 + 进度/句面板。训练与全屏横屏共用。
struct PlayerSectionView: View {
@ObservedObject var controller: PlayerController
var showsContinuousToggle: Bool
@Binding var continuousPlayback: Bool
var showsSubtitle: Binding<Bool>? = .constant(true)
var onFullscreen: () -> Void
@State private var progressDragging = false
@State private var dragPositionMs: Int64 = 0
@State private var explanationSentence: SentenceBoundary?
var body: some View {
VStack(spacing: 0) {
playerSurface
if showsSubtitle?.wrappedValue == true {
sentencePanel
}
}
.padding(.horizontal, 16)
.sheet(item: $explanationSentence) { sentence in
SentenceExplanationSheet(
courseTitle: controller.currentItem?.title ?? "口语训练课程",
sentence: sentence
)
}
}
private var playerSurface: some View {
ZStack {
PlayerSurfaceView(controller: controller)
.aspectRatio(16.0 / 9.0, contentMode: .fit)
.frame(maxWidth: .infinity)
VStack {
HStack(spacing: 8) {
overlayPill(timeText)
Spacer()
Button(action: cycleSpeed) {
overlayPill(Theme.formatSpeed(controller.snapshot.speed))
}
.buttonStyle(.plain)
}
Spacer()
}
.padding(12)
VStack {
Spacer()
HStack {
Spacer()
Button(action: onFullscreen) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Theme.overlay)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
}
.padding(12)
}
.background(Color.black)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Theme.border, lineWidth: 1)
)
.padding(.bottom, 10)
}
private var sentencePanel: some View {
VStack(alignment: .leading, spacing: 6) {
Text(controller.currentItem?.title ?? "未选择课程")
.font(.system(size: 17, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
if showsContinuousToggle {
HStack(spacing: 0) {
Text("连续播放")
.font(.system(size: 13, weight: .bold))
.foregroundColor(Theme.textSubtle)
Spacer()
Toggle("", isOn: $continuousPlayback)
.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(2)
Spacer()
Button {
guard let sentence = controller.currentSentence, !(sentence.text ?? "").isEmpty else {
return
}
controller.pause()
explanationSentence = sentence
} label: {
Image(systemName: "lightbulb.circle.fill")
.font(.system(size: 23, weight: .semibold))
.foregroundColor(
controller.currentSentence?.text?.isEmpty == false
? Theme.accentLight
: Theme.textSubtle
)
.frame(width: 32, height: 32)
.background(Theme.overlay)
.clipShape(Circle())
.accessibilityLabel("AI 讲解这句话")
}
.buttonStyle(.plain)
}
progressRow
Text(statusText)
.font(.system(size: 13))
.foregroundColor(Theme.textSubtle)
}
.padding(.bottom, 10)
}
private var progressRow: some View {
let duration = controller.snapshot.durationMs
return HStack(spacing: 8) {
Slider(
value: progressBinding,
in: 0...max(1, Double(duration)),
onEditingChanged: { editing in
if editing {
progressDragging = true
} else {
progressDragging = false
controller.seek(to: dragPositionMs)
}
}
)
.tint(Theme.accent)
}
}
private var progressBinding: Binding<Double> {
Binding(
get: {
if progressDragging {
return Double(dragPositionMs)
}
return Double(controller.snapshot.positionMs)
},
set: { newValue in
dragPositionMs = Int64(newValue)
}
)
}
private var timeText: String {
let snapshot = controller.snapshot
return "\(Theme.formatTime(snapshot.positionMs)) / \(Theme.formatTime(snapshot.durationMs))"
}
private var sentenceMetaText: String {
switch controller.boundaryState {
case .loading(let isLocal):
return isLocal ? "句子边界分析中" : "句子边界加载中"
case .failed:
return "暂无句子边界"
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return "暂无句子边界"
}
let count = controller.currentItem?.sentences.count ?? 0
let countText = count > 0 ? " / \(count)" : ""
return "第 \(sentence.index + 1)\(countText) 句 \(Theme.formatTime(sentence.startMs))-\(Theme.formatTime(sentence.endMs))"
}
}
private var sentenceTextText: String {
switch controller.boundaryState {
case .loading(let isLocal):
if isLocal {
return "正在匹配服务端句子边界"
}
return controller.currentItem?.title ?? "加载中"
case .failed:
return controller.currentItem?.title ?? "未选择课程"
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return controller.currentItem?.title ?? "未选择课程"
}
if let text = sentence.text, !text.isEmpty {
return text
}
return "当前句子"
}
}
private var statusText: String {
if let message = controller.transientMessage {
return message
}
return controller.playbackStatusText(controller.snapshot)
}
private func overlayPill(_ text: String) -> some View {
Text(text)
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.overlay)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func cycleSpeed() {
let current = controller.snapshot.speed
let cycle: [Float] = [1.0, 1.25, 1.5, 1.75, 2.0]
let next = cycle.first { $0 > current + 0.001 } ?? cycle.first ?? 1.0
controller.setPlaybackSpeed(next)
}
}
struct SentenceExplanationSheet: View {
let courseTitle: String
let sentence: SentenceBoundary
@State private var explanation = ""
@State private var errorMessage: String?
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(sentence.text ?? "")
.font(.system(size: 18, weight: .bold))
.foregroundColor(Theme.textDark)
if let errorMessage {
Text(errorMessage)
.font(.system(size: 15))
.foregroundColor(Theme.danger)
} else if explanation.isEmpty {
Text("AI 正在讲解这句话…")
.font(.system(size: 15))
.foregroundColor(Theme.textSubtle)
} else {
Text(explanation)
.font(.system(size: 16))
.foregroundColor(Theme.textDark)
.lineSpacing(5)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
.background(Color.white.ignoresSafeArea())
.navigationTitle("句子讲解")
.navigationBarTitleDisplayMode(.inline)
}
.task {
guard explanation.isEmpty, errorMessage == nil else { return }
do {
explanation = try await SentenceBoundaryAPI.explain(
sentence,
courseTitle: courseTitle
)
} catch {
errorMessage = "讲解失败:\(error.localizedDescription)"
}
}
}
}