fix a bug about ios
This commit is contained in:
Binary file not shown.
@@ -18,6 +18,8 @@ final class PlayerController: ObservableObject {
|
|||||||
|
|
||||||
private var mediaItem: TrainingMediaItem?
|
private var mediaItem: TrainingMediaItem?
|
||||||
private var lastSentenceIndex: Int?
|
private var lastSentenceIndex: Int?
|
||||||
|
private var pendingSeekPositionMs: Int64?
|
||||||
|
private var seekGeneration = 0
|
||||||
private var stopAtSentenceEnd = false
|
private var stopAtSentenceEnd = false
|
||||||
private var stopAtSentenceEndIndex: Int?
|
private var stopAtSentenceEndIndex: Int?
|
||||||
private var speedBeforeLongPress: Float?
|
private var speedBeforeLongPress: Float?
|
||||||
@@ -67,6 +69,7 @@ final class PlayerController: ObservableObject {
|
|||||||
|
|
||||||
func load(_ item: TrainingMediaItem) {
|
func load(_ item: TrainingMediaItem) {
|
||||||
lastSentenceIndex = nil
|
lastSentenceIndex = nil
|
||||||
|
pendingSeekPositionMs = nil
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
mediaItem = item
|
mediaItem = item
|
||||||
@@ -109,18 +112,17 @@ final class PlayerController: ObservableObject {
|
|||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
player.pause()
|
player.pause()
|
||||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
seek(to: .zero)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(to positionMs: Int64) {
|
func seek(to positionMs: Int64) {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
performSeek(to: positionMs)
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekRelative(_ deltaMs: Int64) {
|
func seekRelative(_ deltaMs: Int64) {
|
||||||
seek(to: snapshot.positionMs + deltaMs)
|
seek(to: referencePositionMs() + deltaMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewind() {
|
func rewind() {
|
||||||
@@ -138,7 +140,7 @@ final class PlayerController: ObservableObject {
|
|||||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let position = snapshot.positionMs
|
let position = referencePositionMs()
|
||||||
guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
|
guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
|
||||||
?? item.sentences.first else {
|
?? item.sentences.first else {
|
||||||
return false
|
return false
|
||||||
@@ -152,7 +154,7 @@ final class PlayerController: ObservableObject {
|
|||||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let position = snapshot.positionMs
|
let position = referencePositionMs()
|
||||||
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
|
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -177,8 +179,7 @@ final class PlayerController: ObservableObject {
|
|||||||
guard let sentence = currentSentence else {
|
guard let sentence = currentSentence else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let time = CMTime(seconds: Double(sentence.startMs) / 1000, preferredTimescale: 600)
|
performSeek(to: sentence.startMs)
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
stopAtSentenceEnd = true
|
stopAtSentenceEnd = true
|
||||||
stopAtSentenceEndIndex = sentence.index
|
stopAtSentenceEndIndex = sentence.index
|
||||||
play()
|
play()
|
||||||
@@ -277,9 +278,14 @@ final class PlayerController: ObservableObject {
|
|||||||
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
|
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let position = currentPositionMs()
|
let position = referencePositionMs()
|
||||||
|
if pendingSeekPositionMs == nil, lastSentenceIndex == nil {
|
||||||
|
lastSentenceIndex = sentence(at: position)?.index
|
||||||
|
}
|
||||||
|
if pendingSeekPositionMs == nil {
|
||||||
enforceSentenceBoundary(positionMs: position)
|
enforceSentenceBoundary(positionMs: position)
|
||||||
let finalPosition = currentPositionMs()
|
}
|
||||||
|
let finalPosition = referencePositionMs()
|
||||||
let sentence = sentence(at: finalPosition)
|
let sentence = sentence(at: finalPosition)
|
||||||
if sentence?.index != lastSentenceIndex {
|
if sentence?.index != lastSentenceIndex {
|
||||||
lastSentenceIndex = sentence?.index
|
lastSentenceIndex = sentence?.index
|
||||||
@@ -307,6 +313,34 @@ final class PlayerController: ObservableObject {
|
|||||||
return max(0, Int64(seconds * 1000))
|
return max(0, Int64(seconds * 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 连续手势/快进快退时使用 pending seek 的目标位置,避免仍基于旧的播放位置计算。
|
||||||
|
private func referencePositionMs() -> Int64 {
|
||||||
|
pendingSeekPositionMs ?? currentPositionMs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
|
||||||
|
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
|
||||||
|
private func performSeek(to positionMs: Int64) {
|
||||||
|
let targetPositionMs = max(0, positionMs)
|
||||||
|
seekGeneration += 1
|
||||||
|
let generation = seekGeneration
|
||||||
|
pendingSeekPositionMs = targetPositionMs
|
||||||
|
// 立即切换句子锚点。否则 periodic observer 仍可能以旧句子为锚点,
|
||||||
|
// 在 seek 完成前后把新位置拉回旧句开头。
|
||||||
|
lastSentenceIndex = sentence(at: targetPositionMs)?.index
|
||||||
|
let time = CMTime(seconds: Double(targetPositionMs) / 1000, preferredTimescale: 600)
|
||||||
|
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self, self.seekGeneration == generation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.pendingSeekPositionMs = nil
|
||||||
|
self.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
private func durationMs(of item: AVPlayerItem) -> Int64 {
|
private func durationMs(of item: AVPlayerItem) -> Int64 {
|
||||||
let seconds = item.duration.seconds
|
let seconds = item.duration.seconds
|
||||||
guard seconds.isFinite, seconds > 0 else {
|
guard seconds.isFinite, seconds > 0 else {
|
||||||
@@ -393,8 +427,7 @@ final class PlayerController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func seekRaw(_ positionMs: Int64) {
|
private func seekRaw(_ positionMs: Int64) {
|
||||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
performSeek(to: positionMs)
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEnded() {
|
private func handleEnded() {
|
||||||
@@ -402,7 +435,7 @@ final class PlayerController: ObservableObject {
|
|||||||
tick()
|
tick()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
performSeek(to: .zero)
|
||||||
player.play()
|
player.play()
|
||||||
tick()
|
tick()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,29 @@ import UIKit
|
|||||||
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
|
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
|
||||||
/// - 单击:播放 / 暂停
|
/// - 单击:播放 / 暂停
|
||||||
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
|
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
|
||||||
|
/// - 上划 / 下划:显示 / 隐藏字幕层(由 SwiftUI 横屏页接收)
|
||||||
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
|
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
|
||||||
struct PlayerSurfaceView: UIViewRepresentable {
|
struct PlayerSurfaceView: UIViewRepresentable {
|
||||||
let controller: PlayerController
|
let controller: PlayerController
|
||||||
|
var onVerticalSwipe: ((Bool) -> Void)?
|
||||||
|
|
||||||
func makeUIView(context: Context) -> VideoSurfaceView {
|
func makeUIView(context: Context) -> VideoSurfaceView {
|
||||||
let view = VideoSurfaceView()
|
let view = VideoSurfaceView()
|
||||||
view.playerLayer.player = controller.player
|
view.playerLayer.player = controller.player
|
||||||
view.controller = controller
|
view.controller = controller
|
||||||
|
view.onVerticalSwipe = onVerticalSwipe
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
|
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
|
||||||
uiView.playerLayer.player = controller.player
|
uiView.playerLayer.player = controller.player
|
||||||
|
uiView.onVerticalSwipe = onVerticalSwipe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class VideoSurfaceView: UIView {
|
final class VideoSurfaceView: UIView {
|
||||||
weak var controller: PlayerController?
|
weak var controller: PlayerController?
|
||||||
|
var onVerticalSwipe: ((Bool) -> Void)?
|
||||||
|
|
||||||
override class var layerClass: AnyClass {
|
override class var layerClass: AnyClass {
|
||||||
AVPlayerLayer.self
|
AVPlayerLayer.self
|
||||||
@@ -82,6 +87,12 @@ final class VideoSurfaceView: UIView {
|
|||||||
let velocity = recognizer.velocity(in: self)
|
let velocity = recognizer.velocity(in: self)
|
||||||
let dx = translation.x
|
let dx = translation.x
|
||||||
let dy = translation.y
|
let dy = translation.y
|
||||||
|
if abs(dy) > abs(dx),
|
||||||
|
abs(dy) >= minSwipeDistance,
|
||||||
|
abs(velocity.y) >= minSwipeVelocity {
|
||||||
|
onVerticalSwipe?(dy < 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard abs(dx) >= abs(dy),
|
guard abs(dx) >= abs(dy),
|
||||||
abs(dx) >= minSwipeDistance,
|
abs(dx) >= minSwipeDistance,
|
||||||
abs(velocity.x) >= minSwipeVelocity else {
|
abs(velocity.x) >= minSwipeVelocity else {
|
||||||
|
|||||||
@@ -5,13 +5,31 @@ struct FullscreenPlayerView: View {
|
|||||||
@ObservedObject var controller: PlayerController
|
@ObservedObject var controller: PlayerController
|
||||||
let onExit: () -> Void
|
let onExit: () -> Void
|
||||||
|
|
||||||
|
@State private var subtitleVisible = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
PlayerSurfaceView(controller: controller)
|
PlayerSurfaceView(controller: controller) { isSwipeUp in
|
||||||
|
guard subtitleVisible != isSwipeUp else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
withAnimation(.easeInOut(duration: 0.22)) {
|
||||||
|
subtitleVisible = isSwipeUp
|
||||||
|
}
|
||||||
|
}
|
||||||
.ignoresSafeArea()
|
.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 {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if subtitleVisible {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(controller.currentItem?.title ?? "未选择课程")
|
Text(controller.currentItem?.title ?? "未选择课程")
|
||||||
.font(.system(size: 17, weight: .bold))
|
.font(.system(size: 17, weight: .bold))
|
||||||
@@ -32,6 +50,8 @@ struct FullscreenPlayerView: View {
|
|||||||
.padding(18)
|
.padding(18)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Theme.overlayFaint)
|
.background(Theme.overlayFaint)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -55,6 +75,11 @@ struct FullscreenPlayerView: View {
|
|||||||
@State private var progressDragging = false
|
@State private var progressDragging = false
|
||||||
@State private var dragPositionMs: Int64 = 0
|
@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 {
|
private var progressRow: some View {
|
||||||
let duration = controller.snapshot.durationMs
|
let duration = controller.snapshot.durationMs
|
||||||
return HStack(spacing: 8) {
|
return HStack(spacing: 8) {
|
||||||
@@ -133,4 +158,14 @@ struct FullscreenPlayerView: View {
|
|||||||
}
|
}
|
||||||
return controller.playbackStatusText(controller.snapshot)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user