fix a bug about ios

This commit is contained in:
2026-08-31 13:37:38 +08:00
parent 637101bcdb
commit 7e4c0f5f90
4 changed files with 113 additions and 34 deletions

View File

@@ -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()
enforceSentenceBoundary(positionMs: position) if pendingSeekPositionMs == nil, lastSentenceIndex == nil {
let finalPosition = currentPositionMs() lastSentenceIndex = sentence(at: position)?.index
}
if pendingSeekPositionMs == nil {
enforceSentenceBoundary(positionMs: position)
}
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()
} }

View File

@@ -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 {

View File

@@ -5,33 +5,53 @@ 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()
VStack(alignment: .leading, spacing: 6) { if subtitleVisible {
Text(controller.currentItem?.title ?? "未选择课程") VStack(alignment: .leading, spacing: 6) {
.font(.system(size: 17, weight: .bold)) Text(controller.currentItem?.title ?? "未选择课程")
.foregroundColor(.white) .font(.system(size: 17, weight: .bold))
.lineLimit(1) .foregroundColor(.white)
Text(sentenceMetaText) .lineLimit(1)
.font(.system(size: 13)) Text(sentenceMetaText)
.foregroundColor(Theme.textOverlay) .font(.system(size: 13))
Text(sentenceTextText) .foregroundColor(Theme.textOverlay)
.font(.system(size: 15)) Text(sentenceTextText)
.foregroundColor(.white) .font(.system(size: 15))
.lineLimit(2) .foregroundColor(.white)
progressRow .lineLimit(2)
Text(statusText) progressRow
.font(.system(size: 13)) Text(statusText)
.foregroundColor(Theme.textOverlay) .font(.system(size: 13))
.foregroundColor(Theme.textOverlay)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.overlayFaint)
.transition(.move(edge: .bottom).combined(with: .opacity))
} }
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.overlayFaint)
} }
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))
}
} }