added ios module
This commit is contained in:
194
ios/OralTrainer/Views/PlayerSectionView.swift
Normal file
194
ios/OralTrainer/Views/PlayerSectionView.swift
Normal file
@@ -0,0 +1,194 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 播放区 + 悬浮信息 + 进度/句面板。训练与全屏横屏共用。
|
||||
struct PlayerSectionView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
var showsContinuousToggle: Bool
|
||||
@Binding var continuousPlayback: Bool
|
||||
var onFullscreen: () -> Void
|
||||
|
||||
@State private var progressDragging = false
|
||||
@State private var dragPositionMs: Int64 = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
playerSurface
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user