374 lines
13 KiB
Swift
374 lines
13 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 showsLessonTitle: Bool = 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(
|
|
mediaId: controller.currentItem?.id ?? "",
|
|
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) {
|
|
if showsLessonTitle {
|
|
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(4)
|
|
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 ""
|
|
case .idle, .loaded:
|
|
guard let sentence = controller.currentSentence else {
|
|
return ""
|
|
}
|
|
if let text = sentence.text, !text.isEmpty {
|
|
return text
|
|
}
|
|
return "当前句子"
|
|
}
|
|
}
|
|
|
|
private var statusText: String {
|
|
if let message = controller.cacheState.statusMessage {
|
|
return message
|
|
}
|
|
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 mediaId: String
|
|
let courseTitle: String
|
|
let sentence: SentenceBoundary
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var explanation = ""
|
|
@State private var errorMessage: String?
|
|
@State private var saved = false
|
|
|
|
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 {
|
|
MarkdownExplanationText(text: explanation)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(20)
|
|
}
|
|
.background(Color.white.ignoresSafeArea())
|
|
.navigationTitle("句子讲解")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
HStack(spacing: 6) {
|
|
Button(saved ? "已保存" : "保存") {
|
|
saveExplanation()
|
|
}
|
|
.disabled(saved || explanation.isEmpty || errorMessage != nil)
|
|
Text(saved ? "(已保存本地)" : "(下次直接看本地)")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(Theme.textMuted)
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundColor(Theme.textMuted)
|
|
}
|
|
.accessibilityLabel("关闭句子讲解")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
if let cached = SentenceExplanationStore.shared.load(
|
|
mediaId: mediaId,
|
|
sentenceIndex: sentence.index
|
|
) {
|
|
explanation = cached
|
|
saved = true
|
|
return
|
|
}
|
|
guard explanation.isEmpty, errorMessage == nil else { return }
|
|
do {
|
|
explanation = try await SentenceBoundaryAPI.explain(
|
|
sentence,
|
|
courseTitle: courseTitle
|
|
)
|
|
} catch {
|
|
errorMessage = "讲解失败:\(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveExplanation() {
|
|
guard !saved, !explanation.isEmpty, errorMessage == nil else { return }
|
|
saved = SentenceExplanationStore.shared.save(
|
|
mediaId: mediaId,
|
|
sentenceIndex: sentence.index,
|
|
explanation: explanation
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct MarkdownExplanationText: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
Text(Self.attributed(from: text))
|
|
.font(.system(size: 16))
|
|
.foregroundColor(Theme.textDark)
|
|
.tint(Theme.buttonBlue)
|
|
.lineSpacing(5)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private static func attributed(from source: String) -> AttributedString {
|
|
let options = AttributedString.MarkdownParsingOptions(
|
|
interpretedSyntax: .full,
|
|
failurePolicy: .returnPartiallyParsedIfPossible
|
|
)
|
|
if let parsed = try? AttributedString(
|
|
markdown: preservingLineBreaks(source),
|
|
options: options
|
|
) {
|
|
return parsed
|
|
}
|
|
return AttributedString(source)
|
|
}
|
|
|
|
private static func preservingLineBreaks(_ source: String) -> String {
|
|
let normalized = source
|
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
|
.replacingOccurrences(of: "\r", with: "\n")
|
|
var result = ""
|
|
var inFence = false
|
|
let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false)
|
|
for (index, rawLine) in lines.enumerated() {
|
|
let line = String(rawLine)
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if trimmed.hasPrefix("```") {
|
|
inFence.toggle()
|
|
}
|
|
result += line
|
|
guard index < lines.count - 1 else { continue }
|
|
if inFence || trimmed.isEmpty {
|
|
result += "\n"
|
|
} else {
|
|
result += "\n\n"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|