198 lines
6.7 KiB
Swift
198 lines
6.7 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 onFullscreen: () -> Void
|
|
|
|
@State private var progressDragging = false
|
|
@State private var dragPositionMs: Int64 = 0
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
playerSurface
|
|
if showsSubtitle?.wrappedValue == true {
|
|
sentencePanel
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
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)
|
|
Text(sentenceTextText)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(.white)
|
|
.lineLimit(2)
|
|
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 controller.currentItem?.title ?? "未选择课程"
|
|
case .idle, .loaded:
|
|
guard let sentence = controller.currentSentence else {
|
|
return controller.currentItem?.title ?? "未选择课程"
|
|
}
|
|
if let text = sentence.text, !text.isEmpty {
|
|
return text
|
|
}
|
|
return "当前句子"
|
|
}
|
|
}
|
|
|
|
private var statusText: String {
|
|
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)
|
|
}
|
|
}
|