226 lines
8.7 KiB
Swift
226 lines
8.7 KiB
Swift
import SwiftUI
|
||
|
||
/// 与安卓端横屏布局一致:全屏播放 + 半透明句面板 + 退出全屏按钮。
|
||
struct FullscreenPlayerView: View {
|
||
@ObservedObject var controller: PlayerController
|
||
var showsLessonTitle: Bool = true
|
||
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) {
|
||
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<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 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))
|
||
}
|
||
}
|