import SwiftUI /// 播放区 + 悬浮信息 + 进度/句面板。训练与全屏横屏共用。 struct PlayerSectionView: View { @ObservedObject var controller: PlayerController var showsContinuousToggle: Bool @Binding var continuousPlayback: Bool var showsSubtitle: Binding? = .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 { 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 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)" } } } }