import SwiftUI /// 与安卓端横屏布局一致:全屏播放 + 半透明句面板 + 退出全屏按钮。 struct FullscreenPlayerView: View { @ObservedObject var controller: PlayerController let onExit: () -> Void @State private var subtitleVisible = true @State private var explanationSentence: SentenceBoundary? var body: some View { GeometryReader { proxy in let fakeLandscape = proxy.size.height > proxy.size.width playerChrome .frame( width: fakeLandscape ? proxy.size.height : proxy.size.width, height: fakeLandscape ? proxy.size.width : proxy.size.height ) .rotationEffect(.degrees(fakeLandscape ? -90 : 0)) .frame(width: proxy.size.width, height: proxy.size.height) } .background(Color.black) .ignoresSafeArea() .statusBarHidden(true) .sheet(item: $explanationSentence) { sentence in SentenceExplanationSheet( mediaId: controller.currentItem?.id ?? "", courseTitle: controller.currentItem?.title ?? "口语训练课程", sentence: sentence ) } } private var playerChrome: some View { ZStack { Color.black.ignoresSafeArea() PlayerSurfaceView(controller: controller) { isSwipeUp in guard subtitleVisible != isSwipeUp else { return } withAnimation(.easeInOut(duration: 0.22)) { subtitleVisible = isSwipeUp } } .ignoresSafeArea() HStack(spacing: 8) { overlayPill(timeText) Spacer() overlayPill(Theme.formatSpeed(controller.snapshot.speed)) } .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .allowsHitTesting(false) VStack { Spacer() if subtitleVisible { VStack(alignment: .leading, spacing: 6) { Text(controller.currentItem?.title ?? "未选择课程") .font(.system(size: 17, weight: .bold)) .foregroundColor(.white) .lineLimit(1) Text(sentenceMetaText) .font(.system(size: 13)) .foregroundColor(Theme.textOverlay) 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: 21, weight: .semibold)) .foregroundColor( controller.currentSentence?.text?.isEmpty == false ? Theme.accentLight : Theme.textSubtle ) .frame(width: 30, height: 30) .background(Theme.overlay) .clipShape(Circle()) .accessibilityLabel("AI 讲解这句话") } .buttonStyle(.plain) } progressRow Text(statusText) .font(.system(size: 13)) .foregroundColor(Theme.textOverlay) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(Theme.overlayFaint) .transition(.move(edge: .bottom).combined(with: .opacity)) } } VStack { Spacer() HStack { Spacer() Button(action: onExit) { Image(systemName: "arrow.down.right.and.arrow.up.left") .font(.system(size: 18, weight: .bold)) .foregroundColor(.white) .frame(width: 44, height: 44) .background(Theme.overlay) .clipShape(Circle()) } .buttonStyle(.plain) } } .padding(16) } } @State private var progressDragging = false @State private var dragPositionMs: Int64 = 0 private var timeText: String { let snapshot = controller.snapshot return "\(Theme.formatTime(snapshot.positionMs)) / \(Theme.formatTime(snapshot.durationMs))" } private var progressRow: some View { let duration = controller.snapshot.durationMs return HStack(spacing: 8) { Text(Theme.formatTime(controller.snapshot.positionMs)) .font(.system(size: 13)) .foregroundColor(Theme.textOverlay) 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.accentLight) Text(Theme.formatTime(controller.snapshot.durationMs)) .font(.system(size: 13)) .foregroundColor(Theme.textOverlay) } } private var progressBinding: Binding { Binding( get: { if progressDragging { return Double(dragPositionMs) } return Double(controller.snapshot.positionMs) }, set: { newValue in dragPositionMs = Int64(newValue) } ) } 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): return isLocal ? "正在匹配服务端句子边界" : (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)) } }