Files
mediaplayer/ios/OralTrainer/Views/FullscreenPlayerView.swift
2026-10-03 12:14:17 +08:00

250 lines
9.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
@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) {
if showsLessonTitle {
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) {
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,
!(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<Double> {
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 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
}
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))
}
}