import AVFoundation import Foundation import SwiftUI final class SeekModel: ObservableObject { static let shared = SeekModel() @Published var currentTime = CMTime.zero @Published var duration = CMTime.zero @Published var lastSeekTime: CMTime? { didSet { onSeek() } } @Published var lastSeekType: SeekType? @Published var restoreSeekTime: CMTime? @Published var gestureSeek: Double? @Published var gestureStart: Double? @Published var presentingOSD = false var player: PlayerModel! { .shared } var dismissTimer: Timer? var isSeeking: Bool { gestureSeek != nil } var progress: Double { let seconds = duration.seconds guard seconds.isFinite, seconds > 0 else { return 0 } if isSeeking { return gestureSeekDestinationTime / seconds } guard let seekTime = lastSeekTime else { return currentTime.seconds / seconds } return seekTime.seconds / seconds } var lastSeekPlaybackTime: String { guard let time = lastSeekTime else { return 0.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? PlayerTimeModel.timePlaceholder } return time.seconds.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? PlayerTimeModel.timePlaceholder } var restoreSeekPlaybackTime: String { guard let time = restoreSeekTime else { return PlayerTimeModel.timePlaceholder } return time.seconds.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? PlayerTimeModel.timePlaceholder } var gestureSeekDestinationTime: Double { guard let gestureSeek, let gestureStart else { return -1 } return min(duration.seconds, max(0, gestureStart + gestureSeek)) } var gestureSeekDestinationPlaybackTime: String { guard gestureSeek != 0 else { return PlayerTimeModel.timePlaceholder } return gestureSeekDestinationTime.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? PlayerTimeModel.timePlaceholder } var durationPlaybackTime: String { if player?.currentItem.isNil ?? true { return PlayerTimeModel.timePlaceholder } return duration.seconds.formattedAsPlaybackTime() ?? PlayerTimeModel.timePlaceholder } func showOSD() { guard !presentingOSD else { return } withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true } } func hideOSD() { guard presentingOSD else { return } withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false } } func hideOSDWithDelay() { dismissTimer?.invalidate() dismissTimer = Delay.by(3) { self.hideOSD() } } func updateCurrentTime(completionHandler: (() -> Void?)? = nil) { player.backend.getTimeUpdates() DispatchQueue.main.async { self.currentTime = self.player.backend.currentTime ?? .zero self.duration = self.player.backend.playerItemDuration ?? .zero completionHandler?() } } func onSeekGestureStart() { updateCurrentTime { self.gestureStart = self.currentTime.seconds self.dismissTimer?.invalidate() self.showOSD() } } func onSeekGestureEnd() { dismissTimer?.invalidate() dismissTimer = Delay.by(3) { self.hideOSD() } player.backend.seek(to: gestureSeekDestinationTime, seekType: .userInteracted) } func onSeek() { guard !lastSeekTime.isNil else { return } gestureSeek = nil gestureStart = nil showOSD() hideOSDWithDelay() } func registerSeek(at time: CMTime, type: SeekType, restore restoreTime: CMTime? = nil) { updateCurrentTime { withAnimation { self.lastSeekTime = time self.lastSeekType = type self.restoreSeekTime = restoreTime } } } func restoreTime() { guard let time = restoreSeekTime else { return } switch lastSeekType { case .segmentSkip: player.restoreLastSkippedSegment() default: player.backend.seek(to: time, seekType: .userInteracted) } } func resetSeek() { withAnimation { lastSeekTime = nil lastSeekType = nil } } func reset() { currentTime = .zero duration = .zero resetSeek() gestureSeek = nil } var forceHours: Bool { duration.seconds >= 60 * 60 } }