import SwiftUI import Darwin #if canImport(VLCKitSPM) import VLCKitSPM #if os(iOS) import UIKit #endif @MainActor final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate { static let supportedPlaybackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] private static let defaultVolume = 100 private static let maxVolume = 200 private static let drawableSizeChangeThreshold: CGFloat = 24 private static let drawableRebindMinimumInterval: TimeInterval = 1.2 // 这些选项在播放器实例级别生效,优先约束 libvlc 的追时钟/丢帧行为。 private static let stablePlaybackOptions: [String] = [ "--no-drop-late-frames", "--no-skip-frames", "--clock-synchro=0", "--clock-jitter=0" ] private static let playerInstanceSelector = NSSelectorFromString("playerInstance") private static let libVLCStopAsync: LibVLCStopAsyncFunction? = { // RTLD_DEFAULT 在 Swift 中不可直接用常量名,-2 等价于 C 宏 RTLD_DEFAULT。 let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) return "libvlc_media_player_stop_async".withCString { symbolName in guard let symbol = dlsym(defaultHandle, symbolName) else { return nil } return unsafeBitCast(symbol, to: LibVLCStopAsyncFunction.self) } }() private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void let mediaPlayer = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions) @Published var isPreparing = true @Published var isPlaying = false @Published var currentTimeSeconds: Double = 0 @Published var durationSeconds: Double = 0 @Published var playbackRate: Float = 1.0 @Published var volume: Int = defaultVolume #if os(macOS) private let persistentDrawableView = NSView(frame: .zero) private weak var lastAttachedContainer: NSView? #else private let persistentDrawableView = UIView(frame: .zero) private weak var lastAttachedContainer: UIView? #endif private var rebindWorkItems: [DispatchWorkItem] = [] private var lastDrawableContainerIdentifier: ObjectIdentifier? private var lastDrawableContainerSize: CGSize = .zero private var lastDrawableRebindAt: Date = .distantPast var hasValidDuration: Bool { durationSeconds > 0 } private var progressTimer: Timer? private var pendingSeekSeconds: Double? private var isLive = false private var isInBufferingState = false private var pendingVodBufferingConfirmWorkItem: DispatchWorkItem? private var bufferingBaselineSecondsVod: Double = 0 private var decodeMode: VideoDecodeMode = .auto private var decodeModeOverride: VideoDecodeMode? private var hasAttemptedSoftDecodeFallback = false private var bufferMode: VLCBufferMode = .defaultMode private var bufferingFallbackWorkItem: DispatchWorkItem? private var delayedPreparingWorkItem: DispatchWorkItem? private var currentMediaURLString: String? private var currentMediaIsLive = false private var currentMediaDecodeMode: VideoDecodeMode = .auto private var currentMediaBufferMode: VLCBufferMode = .defaultMode private var onProgressChanged: ((Double, Double?) -> Void)? private var onPlaybackEnded: (() -> Void)? private var onPlaybackFailed: (() -> Void)? private let progressUpdateIntervalVod: TimeInterval = 0.5 private let progressUpdateIntervalLive: TimeInterval = 1.0 private let bufferingFallbackThresholdLive: TimeInterval = 4.0 private let bufferingConfirmDelayVod: TimeInterval = 0.35 private let bufferingIndicatorDelayVod: TimeInterval = 1.2 private let vodBufferingProgressAdvanceThreshold: Double = 0.25 private let progressPublishThreshold: Double = 0.25 private let durationPublishThreshold: Double = 0.5 private var lastNonZeroVolume = defaultVolume override init() { super.init() let savedObj = UserDefaults.standard.object(forKey: HawkConfig.PLAY_SPEED) let savedRate = savedObj != nil ? Float(UserDefaults.standard.double(forKey: HawkConfig.PLAY_SPEED)) : 1.0 playbackRate = Self.normalizedPlaybackRate(from: savedRate) decodeMode = VideoDecodeMode.fromStoredValue( UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE) ) bufferMode = VLCBufferMode.fromStoredValue( UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) ) let savedVolumeObj = UserDefaults.standard.object(forKey: HawkConfig.PLAY_VOLUME) let savedVolume = savedVolumeObj != nil ? UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VOLUME) : Self.defaultVolume volume = Self.normalizedVolume(from: savedVolume) if volume > 0 { lastNonZeroVolume = volume } #if os(macOS) persistentDrawableView.wantsLayer = true persistentDrawableView.layer?.backgroundColor = NSColor.black.cgColor #else persistentDrawableView.backgroundColor = .black #endif mediaPlayer.delegate = self mediaPlayer.drawable = persistentDrawableView } func play( url: URL, startPosition: Double, isLive: Bool, onProgressChanged: ((Double, Double?) -> Void)?, onPlaybackEnded: (() -> Void)?, onPlaybackFailed: (() -> Void)? ) { let targetURLString = url.absoluteString let isNewMedia = currentMediaURLString != targetURLString || currentMediaIsLive != isLive if isNewMedia { resetPlaybackRecoveryState() } syncDecodeModeFromSettings() syncBufferModeFromSettings() // 同一路径/同场景(点播或直播)时复用当前实例,避免切全屏触发重新加载 if currentMediaURLString == targetURLString, currentMediaIsLive == isLive, currentMediaDecodeMode == decodeMode, currentMediaBufferMode == bufferMode, mediaPlayer.media != nil { self.onProgressChanged = onProgressChanged self.onPlaybackEnded = onPlaybackEnded self.onPlaybackFailed = onPlaybackFailed self.isLive = isLive applyPlaybackRate() applyVolume() refreshPlaybackFlags() emitProgress() return } stopProgressTimer() cancelBufferingFallbackTimer() cancelDelayedPreparingIndicator() cancelPendingVodBufferingConfirmation() isInBufferingState = false self.onProgressChanged = onProgressChanged self.onPlaybackEnded = onPlaybackEnded self.onPlaybackFailed = onPlaybackFailed self.isLive = isLive pendingSeekSeconds = isLive ? nil : max(startPosition, 0) setPlaybackStatus(preparing: true, playing: false) resetProgressState() mediaPlayer.stop() let media = VLCMedia(url: url) let cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode) let enableFrameDrop = isLive ? bufferMode.enableFrameDrop : false let enableSkipFrames = isLive && bufferMode.enableFrameDrop var mediaOptions: [String: Any] = [ "network-caching": cacheConfig.network, "live-caching": cacheConfig.live, "file-caching": cacheConfig.file, "http-reconnect": 1 ] if !isLive { mediaOptions["avcodec-hurry-up"] = 0 mediaOptions["clock-synchro"] = 0 } if let hwOption = decodeMode.vlcHardwareDecodeOption { mediaOptions["avcodec-hw"] = hwOption } if isLive { mediaOptions["avcodec-fast"] = 1 } if url.scheme?.lowercased() == "rtsp" { mediaOptions["rtsp-tcp"] = 1 } media.addOptions(mediaOptions) // 对布尔型选项使用显式 no- 前缀,避免 0/1 在不同 libvlc 版本下解释不一致。 media.addOption(enableFrameDrop ? "drop-late-frames" : "no-drop-late-frames") media.addOption(enableSkipFrames ? "skip-frames" : "no-skip-frames") mediaPlayer.media = media mediaPlayer.play() applyPlaybackRate() applyVolume() startProgressTimer() currentMediaURLString = targetURLString currentMediaIsLive = isLive currentMediaDecodeMode = decodeMode currentMediaBufferMode = bufferMode } func stop() { stopProgressTimer() resetPlaybackRecoveryState() cancelScheduledRebinds() stopMediaPlayer() mediaPlayer.media = nil onProgressChanged = nil onPlaybackEnded = nil onPlaybackFailed = nil pendingSeekSeconds = nil setPlaybackStatus(preparing: false, playing: false) resetProgressState() currentMediaURLString = nil currentMediaIsLive = false currentMediaDecodeMode = .auto currentMediaBufferMode = .defaultMode } func togglePlayback() { if isPlaying { mediaPlayer.pause() } else { mediaPlayer.play() applyPlaybackRate() } } func setPlaybackRate(_ rate: Float) { let normalized = Self.normalizedPlaybackRate(from: rate) playbackRate = normalized UserDefaults.standard.set(Double(normalized), forKey: HawkConfig.PLAY_SPEED) applyPlaybackRate() } func increasePlaybackRate() { guard let index = Self.supportedPlaybackRates.firstIndex(of: playbackRate), index + 1 < Self.supportedPlaybackRates.count else { return } setPlaybackRate(Self.supportedPlaybackRates[index + 1]) } func decreasePlaybackRate() { guard let index = Self.supportedPlaybackRates.firstIndex(of: playbackRate), index - 1 >= 0 else { return } setPlaybackRate(Self.supportedPlaybackRates[index - 1]) } func setVolume(_ value: Int) { let normalized = Self.normalizedVolume(from: value) if volume != normalized { volume = normalized } if normalized > 0 { lastNonZeroVolume = normalized } UserDefaults.standard.set(normalized, forKey: HawkConfig.PLAY_VOLUME) applyVolume() } func toggleMute() { if volume == 0 { let restored = lastNonZeroVolume > 0 ? lastNonZeroVolume : Self.defaultVolume setVolume(restored) } else { setVolume(0) } } #if os(macOS) func attachDrawable(to container: NSView) { let containerIdentifier = ObjectIdentifier(container) let containerSize = container.bounds.size let containerChanged = lastDrawableContainerIdentifier != containerIdentifier let sizeChanged = hasSignificantContainerSizeChange(to: containerSize) let canRebindForSizeChange = canRebindDrawableForSizeChange() lastAttachedContainer = container lastDrawableContainerIdentifier = containerIdentifier lastDrawableContainerSize = containerSize let shouldRebind = containerChanged || (sizeChanged && canRebindForSizeChange) || persistentDrawableView.superview !== container || mediaPlayer.drawable == nil if persistentDrawableView.superview !== container { persistentDrawableView.removeFromSuperview() persistentDrawableView.translatesAutoresizingMaskIntoConstraints = false container.addSubview(persistentDrawableView) NSLayoutConstraint.activate([ persistentDrawableView.leadingAnchor.constraint(equalTo: container.leadingAnchor), persistentDrawableView.trailingAnchor.constraint(equalTo: container.trailingAnchor), persistentDrawableView.topAnchor.constraint(equalTo: container.topAnchor), persistentDrawableView.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) container.layoutSubtreeIfNeeded() } guard shouldRebind else { return } cancelScheduledRebinds() refreshDrawableBinding() scheduleDelayedDrawableRebind(for: container) resumePlaybackAfterDrawableRebindIfNeeded() } func detachDrawable(from container: NSView) { if lastAttachedContainer === container { lastAttachedContainer = nil lastDrawableContainerIdentifier = nil lastDrawableContainerSize = .zero } cancelScheduledRebinds() if persistentDrawableView.superview === container { persistentDrawableView.removeFromSuperview() } } #else func attachDrawable(to container: UIView) { let containerIdentifier = ObjectIdentifier(container) let containerSize = container.bounds.size let containerChanged = lastDrawableContainerIdentifier != containerIdentifier let sizeChanged = hasSignificantContainerSizeChange(to: containerSize) let canRebindForSizeChange = canRebindDrawableForSizeChange() lastAttachedContainer = container lastDrawableContainerIdentifier = containerIdentifier lastDrawableContainerSize = containerSize let shouldRebind = containerChanged || (sizeChanged && canRebindForSizeChange) || persistentDrawableView.superview !== container || mediaPlayer.drawable == nil if persistentDrawableView.superview !== container { persistentDrawableView.removeFromSuperview() persistentDrawableView.translatesAutoresizingMaskIntoConstraints = false container.addSubview(persistentDrawableView) NSLayoutConstraint.activate([ persistentDrawableView.leadingAnchor.constraint(equalTo: container.leadingAnchor), persistentDrawableView.trailingAnchor.constraint(equalTo: container.trailingAnchor), persistentDrawableView.topAnchor.constraint(equalTo: container.topAnchor), persistentDrawableView.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) container.layoutIfNeeded() } guard shouldRebind else { return } cancelScheduledRebinds() refreshDrawableBinding() scheduleDelayedDrawableRebind(for: container) resumePlaybackAfterDrawableRebindIfNeeded() } func detachDrawable(from container: UIView) { if lastAttachedContainer === container { lastAttachedContainer = nil lastDrawableContainerIdentifier = nil lastDrawableContainerSize = .zero } cancelScheduledRebinds() if persistentDrawableView.superview === container { persistentDrawableView.removeFromSuperview() } } #endif private func refreshDrawableBinding() { // 强制重绑视频输出,规避 macOS 切全屏后偶发“有声音无画面” mediaPlayer.drawable = nil mediaPlayer.drawable = persistentDrawableView lastDrawableRebindAt = Date() } private func stopMediaPlayer() { // 优先走 libvlc 异步 stop,避免菜单切换时主线程被同步 stop 卡住。 if let playerPointer = playerInstancePointer(), let stopAsync = Self.libVLCStopAsync { stopAsync(playerPointer) return } mediaPlayer.stop() } private func playerInstancePointer() -> UnsafeMutableRawPointer? { let selector = Self.playerInstanceSelector guard mediaPlayer.responds(to: selector) else { return nil } typealias PlayerInstanceGetter = @convention(c) (AnyObject, Selector) -> UnsafeMutableRawPointer? let imp = mediaPlayer.method(for: selector) let getter = unsafeBitCast(imp, to: PlayerInstanceGetter.self) return getter(mediaPlayer, selector) } private func cancelScheduledRebinds() { rebindWorkItems.forEach { $0.cancel() } rebindWorkItems.removeAll() } private func hasSignificantContainerSizeChange(to newSize: CGSize) -> Bool { let previousSize = lastDrawableContainerSize guard previousSize != .zero else { return false } let widthChanged = abs(newSize.width - previousSize.width) > Self.drawableSizeChangeThreshold let heightChanged = abs(newSize.height - previousSize.height) > Self.drawableSizeChangeThreshold return widthChanged || heightChanged } private func canRebindDrawableForSizeChange() -> Bool { Date().timeIntervalSince(lastDrawableRebindAt) >= Self.drawableRebindMinimumInterval } private func resumePlaybackAfterDrawableRebindIfNeeded() { guard mediaPlayer.media != nil else { return } if !mediaPlayer.isPlaying, mediaPlayer.state != .opening, mediaPlayer.state != .buffering { mediaPlayer.play() } applyPlaybackRate() } #if os(macOS) private func scheduleDelayedDrawableRebind(for container: NSView) { [0.05, 0.18].forEach { delay in let workItem = DispatchWorkItem { [weak self, weak container] in guard let self, let container else { return } guard self.lastAttachedContainer === container else { return } guard self.persistentDrawableView.superview === container else { return } self.refreshDrawableBinding() self.resumePlaybackAfterDrawableRebindIfNeeded() } rebindWorkItems.append(workItem) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } } #else private func scheduleDelayedDrawableRebind(for container: UIView) { [0.05, 0.18].forEach { delay in let workItem = DispatchWorkItem { [weak self, weak container] in guard let self, let container else { return } guard self.lastAttachedContainer === container else { return } guard self.persistentDrawableView.superview === container else { return } self.refreshDrawableBinding() self.resumePlaybackAfterDrawableRebindIfNeeded() } rebindWorkItems.append(workItem) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } } #endif func seek(by offset: Double) { guard !isLive else { return } var target = max(currentTimeSeconds + offset, 0) if hasValidDuration { target = min(target, durationSeconds) } seek(to: target) } func seek(to seconds: Double) { guard !isLive else { return } let maxSeconds = min(durationSeconds > 0 ? durationSeconds : Double(Int32.max) / 1000.0, Double(Int32.max) / 1000.0) let value = max(0, min(seconds, maxSeconds)) mediaPlayer.time = VLCTime(int: Int32(value * 1000.0)) emitProgress() } nonisolated func mediaPlayerStateChanged(_ aNotification: Notification) { Task { @MainActor [weak self] in self?.handlePlayerStateChanged() } } nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) { // 使用定时器统一采样进度,避免 VLC 高频 time 回调带来主线程负载。 } private func handlePlayerStateChanged() { switch mediaPlayer.state { case .opening, .buffering: handleBufferingState() case .playing: handlePlayingState() case .paused: isInBufferingState = false cancelDelayedPreparingIndicator() cancelPendingVodBufferingConfirmation() setPlaybackStatus(preparing: false, playing: false) cancelBufferingFallbackTimer() case .ended: isInBufferingState = false cancelDelayedPreparingIndicator() cancelPendingVodBufferingConfirmation() setPlaybackStatus(preparing: false, playing: false) cancelBufferingFallbackTimer() onPlaybackEnded?() case .error: isInBufferingState = false cancelDelayedPreparingIndicator() cancelPendingVodBufferingConfirmation() setPlaybackStatus(preparing: false, playing: false) cancelBufferingFallbackTimer() onPlaybackFailed?() case .stopped: isInBufferingState = false cancelDelayedPreparingIndicator() cancelPendingVodBufferingConfirmation() setPlaybackStatus(preparing: false, playing: false) cancelBufferingFallbackTimer() default: break } } private func startProgressTimer() { stopProgressTimer() let interval = isLive ? progressUpdateIntervalLive : progressUpdateIntervalVod let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.emitProgress() } } timer.tolerance = interval * 0.25 progressTimer = timer RunLoop.main.add(timer, forMode: .common) } private func stopProgressTimer() { progressTimer?.invalidate() progressTimer = nil } private func applyPendingSeekIfNeeded() { guard let pendingSeekSeconds, pendingSeekSeconds > 0 else { return } seek(to: pendingSeekSeconds) self.pendingSeekSeconds = nil } private func emitProgress() { refreshPlaybackFlags() guard !isLive else { return } let current = currentSeconds() guard current.isFinite, current >= 0 else { return } let roundedCurrent = (current / progressPublishThreshold).rounded() * progressPublishThreshold let didUpdateCurrent = abs(roundedCurrent - currentTimeSeconds) >= progressPublishThreshold if didUpdateCurrent { currentTimeSeconds = roundedCurrent } var didUpdateDuration = false if let duration = durationSecondsFromMedia() { if abs(duration - durationSeconds) >= durationPublishThreshold { durationSeconds = duration didUpdateDuration = true } } guard didUpdateCurrent || didUpdateDuration else { return } onProgressChanged?(currentTimeSeconds, hasValidDuration ? durationSeconds : nil) } private func refreshPlaybackFlags() { // 部分直播源会长时间停留在 buffering/opening 回调,但实际已开始渲染。 // 使用底层 isPlaying 兜底,避免“永远加载中”。 if mediaPlayer.isPlaying { isInBufferingState = false cancelDelayedPreparingIndicator() cancelPendingVodBufferingConfirmation() setPlaybackStatus(preparing: false, playing: true) cancelBufferingFallbackTimer() return } switch mediaPlayer.state { case .opening, .buffering: setPlaybackStatus(preparing: true, playing: false) case .playing: setPlaybackStatus(preparing: false, playing: true) case .paused, .stopped, .ended, .error: setPlaybackStatus(preparing: false, playing: false) default: break } } private func applyPlaybackRate() { guard mediaPlayer.rate != playbackRate else { return } mediaPlayer.rate = playbackRate } private func applyVolume() { let target = Int32(volume) guard mediaPlayer.audio?.volume != target else { return } mediaPlayer.audio?.volume = target } private func syncDecodeModeFromSettings() { if let decodeModeOverride { decodeMode = decodeModeOverride return } decodeMode = VideoDecodeMode.fromStoredValue( UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE) ) } private func syncBufferModeFromSettings() { bufferMode = VLCBufferMode.fromStoredValue( UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) ) } private static func normalizedPlaybackRate(from raw: Float) -> Float { guard !supportedPlaybackRates.isEmpty else { return 1.0 } return supportedPlaybackRates.min(by: { abs($0 - raw) < abs($1 - raw) }) ?? 1.0 } private static func normalizedVolume(from raw: Int) -> Int { min(max(raw, 0), maxVolume) } private static func cacheConfig(isLive: Bool, bufferMode: VLCBufferMode) -> (network: Int, live: Int, file: Int) { bufferMode.cacheConfig(isLive: isLive) } private func scheduleBufferingFallbackIfNeeded() { guard isLive else { return } guard bufferingFallbackWorkItem == nil else { return } guard !hasAttemptedSoftDecodeFallback else { return } guard decodeMode != .software else { return } guard mediaPlayer.media != nil else { return } let delay = bufferingFallbackThresholdLive let workItem = DispatchWorkItem { [weak self] in Task { @MainActor [weak self] in self?.attemptSoftDecodeFallbackIfNeeded() } } bufferingFallbackWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } private func cancelBufferingFallbackTimer() { bufferingFallbackWorkItem?.cancel() bufferingFallbackWorkItem = nil } private func attemptSoftDecodeFallbackIfNeeded() { cancelBufferingFallbackTimer() guard !hasAttemptedSoftDecodeFallback else { return } guard decodeMode != .software else { return } guard let urlString = currentMediaURLString, let url = URL(string: urlString) else { return } guard mediaPlayer.media != nil else { return } hasAttemptedSoftDecodeFallback = true decodeModeOverride = .software let resumePosition = isLive ? 0 : max(currentSeconds(), 0) play( url: url, startPosition: resumePosition, isLive: isLive, onProgressChanged: onProgressChanged, onPlaybackEnded: onPlaybackEnded, onPlaybackFailed: onPlaybackFailed ) } private func handleBufferingState() { if isLive { if !isInBufferingState { isInBufferingState = true } setPlaybackStatus(preparing: true, playing: false) scheduleBufferingFallbackIfNeeded() return } if isInBufferingState { if isPlaying { isPlaying = false } scheduleDelayedPreparingIndicatorForVod() return } scheduleVodBufferingConfirmationIfNeeded() } private func handlePlayingState() { cancelPendingVodBufferingConfirmation() isInBufferingState = false cancelDelayedPreparingIndicator() setPlaybackStatus(preparing: false, playing: true) cancelBufferingFallbackTimer() applyPlaybackRate() applyVolume() applyPendingSeekIfNeeded() } private func scheduleDelayedPreparingIndicatorForVod() { guard !isLive else { return } guard delayedPreparingWorkItem == nil else { return } guard !isPreparing else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } guard self.isInBufferingState, !self.mediaPlayer.isPlaying else { return } guard self.isStillStalledSinceBufferingStartedVod() else { return } self.setPlaybackStatus(preparing: true, playing: false) } delayedPreparingWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + bufferingIndicatorDelayVod, execute: workItem) } private func cancelDelayedPreparingIndicator() { delayedPreparingWorkItem?.cancel() delayedPreparingWorkItem = nil } private func scheduleVodBufferingConfirmationIfNeeded() { guard !isLive else { return } guard pendingVodBufferingConfirmWorkItem == nil else { return } bufferingBaselineSecondsVod = max(currentSeconds(), currentTimeSeconds) let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingVodBufferingConfirmWorkItem = nil guard self.isBufferingLikeState(self.mediaPlayer.state) else { return } guard !self.mediaPlayer.isPlaying else { return } guard self.isStillStalledSinceBufferingStartedVod() else { return } self.enterConfirmedVodBufferingState() } pendingVodBufferingConfirmWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + bufferingConfirmDelayVod, execute: workItem) } private func cancelPendingVodBufferingConfirmation() { pendingVodBufferingConfirmWorkItem?.cancel() pendingVodBufferingConfirmWorkItem = nil } private func enterConfirmedVodBufferingState() { guard !isLive else { return } guard !mediaPlayer.isPlaying else { return } if !isInBufferingState { isInBufferingState = true } if isPlaying { isPlaying = false } scheduleDelayedPreparingIndicatorForVod() } private func isStillStalledSinceBufferingStartedVod() -> Bool { let current = max(currentSeconds(), currentTimeSeconds) return (current - bufferingBaselineSecondsVod) < vodBufferingProgressAdvanceThreshold } private func isBufferingLikeState(_ state: VLCMediaPlayerState) -> Bool { switch state { case .opening, .buffering: return true default: return false } } private func resetPlaybackRecoveryState() { cancelBufferingFallbackTimer() cancelDelayedPreparingIndicator() cancelPendingVodBufferingConfirmation() hasAttemptedSoftDecodeFallback = false decodeModeOverride = nil isInBufferingState = false bufferingBaselineSecondsVod = 0 } private func setPlaybackStatus(preparing: Bool, playing: Bool) { if isPreparing != preparing { isPreparing = preparing } if isPlaying != playing { isPlaying = playing } } private func resetProgressState() { if currentTimeSeconds != 0 { currentTimeSeconds = 0 } if durationSeconds != 0 { durationSeconds = 0 } } private func currentSeconds() -> Double { let raw = mediaPlayer.time.intValue if raw < 0 { return 0 } return Double(raw) / 1000.0 } private func durationSecondsFromMedia() -> Double? { let raw = mediaPlayer.media?.length.intValue ?? 0 guard raw > 0 else { return nil } return Double(raw) / 1000.0 } } struct VLCVodPlayerView: View { let urlString: String var startPosition: Double = 0 var onProgressChanged: ((Double, Double?) -> Void)? = nil var onPlaybackEnded: (() -> Void)? = nil var onToggleFullScreen: (() -> Void)? = nil var canPlayNext: Bool = false var onPlayNext: (() -> Void)? = nil var sharedController: VLCPlayerController? = nil @StateObject private var ownedController = VLCPlayerController() @State private var isDraggingProgress = false @State private var draggingSeconds: Double = 0 @State private var showControls = true @State private var controlsTimer: Timer? @State private var osdIcon: String? @State private var osdOpacity: Double = 0 @State private var osdTimer: Timer? @State private var startPlaybackTask: Task? private var controller: VLCPlayerController { sharedController ?? ownedController } var body: some View { ZStack { VLCDrawableView(controller: controller) .background(Color.black) .onTapGesture(count: 2) { onToggleFullScreen?() } .onTapGesture(count: 1) { togglePlaybackWithOSD() } if controller.isPreparing { ProgressView() .tint(.white) } if let osdIcon = osdIcon { Image(systemName: osdIcon) .font(.system(size: 60, weight: .semibold)) .foregroundColor(.white) .padding(30) .background(.ultraThinMaterial) .clipShape(Circle()) .opacity(osdOpacity) .allowsHitTesting(false) } } .overlay(alignment: .bottom) { GeometryReader { proxy in playbackControls(containerWidth: proxy.size.width) .padding(12) .opacity(showControls ? 1.0 : 0.0) .animation(.easeInOut(duration: 0.3), value: showControls) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } .overlay { KeyboardShortcutCaptureView( onLeft: { wakeUpControls(); controller.seek(by: -seekStep); showOSD(icon: "gobackward.\(Int(seekStep))") }, onRight: { wakeUpControls(); controller.seek(by: seekStep); showOSD(icon: "goforward.\(Int(seekStep))") }, onTogglePlayPause: { wakeUpControls(); togglePlaybackWithOSD() }, onToggleFullScreen: { wakeUpControls(); onToggleFullScreen?() }, onDecreaseSpeed: { wakeUpControls(); controller.decreasePlaybackRate(); showOSD(icon: "tortoise.fill") }, onIncreaseSpeed: { wakeUpControls(); controller.increasePlaybackRate(); showOSD(icon: "hare.fill") }, onVolumeDown: { wakeUpControls() controller.setVolume(controller.volume - volumeStep) showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") }, onVolumeUp: { wakeUpControls() controller.setVolume(controller.volume + volumeStep) showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") } ) .frame(width: 1, height: 1) .opacity(0.01) .allowsHitTesting(false) } .onContinuousHover { phase in switch phase { case .active(_): wakeUpControls() case .ended: break } } .onAppear { startPlayback() wakeUpControls() } .onChange(of: urlString) { _, _ in startPlayback() wakeUpControls() } .onChange(of: controller.currentTimeSeconds) { _, newValue in if !isDraggingProgress { draggingSeconds = newValue } } .onDisappear { startPlaybackTask?.cancel() startPlaybackTask = nil if sharedController == nil { controller.stop() } controlsTimer?.invalidate() osdTimer?.invalidate() } } private func wakeUpControls() { withAnimation { showControls = true } controlsTimer?.invalidate() controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in withAnimation(.easeOut(duration: 0.5)) { showControls = false } } } private func showOSD(icon: String) { osdIcon = icon osdOpacity = 1.0 osdTimer?.invalidate() osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in withAnimation(.easeOut(duration: 0.5)) { osdOpacity = 0.0 } } } private func togglePlaybackWithOSD() { controller.togglePlayback() showOSD(icon: controller.isPlaying ? "pause.fill" : "play.fill") } private func startPlayback() { guard let url = URL(string: urlString) else { return } let targetStartPosition = max(startPosition, 0) draggingSeconds = targetStartPosition startPlaybackTask?.cancel() startPlaybackTask = Task { @MainActor in // 先让出一个主线程周期,避免点击瞬间布局与播放器初始化竞争。 await Task.yield() guard !Task.isCancelled else { return } controller.play( url: url, startPosition: targetStartPosition, isLive: false, onProgressChanged: onProgressChanged, onPlaybackEnded: onPlaybackEnded, onPlaybackFailed: nil ) } } private var seekStep: Double { let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP) return Double(saved > 0 ? saved : 10) } private var volumeStep: Int { 10 } private var progressUpperBound: Double { max(controller.durationSeconds, max(controller.currentTimeSeconds, 1)) } private var currentDisplaySeconds: Double { isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds } private var totalDisplayText: String { controller.hasValidDuration ? controller.durationSeconds.durationString : "--:--" } private func playbackControls(containerWidth: CGFloat) -> some View { VStack(spacing: 8) { // 第一行:进度条和时间 HStack(spacing: 12) { Text(currentDisplaySeconds.durationString) .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white.opacity(0.9)) .lineLimit(1) .frame(width: 62, alignment: .leading) Slider( value: Binding( get: { isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds }, set: { draggingSeconds = $0 wakeUpControls() } ), in: 0...progressUpperBound, onEditingChanged: { editing in isDraggingProgress = editing wakeUpControls() if !editing { controller.seek(to: draggingSeconds) } } ) .accentColor(.white) .disabled(!controller.hasValidDuration) Text(totalDisplayText) .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white.opacity(0.6)) .lineLimit(1) .frame(width: 62, alignment: .trailing) } .padding(.horizontal, 4) // 第二行:控制按钮 HStack(spacing: 0) { // 左侧区:倍速 HStack(spacing: 16) { playbackRateMenu } .frame(width: 150, alignment: .leading) Spacer() // 中间区:主控 HStack(spacing: 24) { Button { wakeUpControls() controller.seek(by: -seekStep) showOSD(icon: "gobackward.\(Int(seekStep))") } label: { Image(systemName: "gobackward.\(Int(seekStep))") .font(.system(size: 18, weight: .medium)) } .buttonStyle(.plain) Button { wakeUpControls() togglePlaybackWithOSD() } label: { ZStack { Circle() .fill(Color.white.opacity(0.15)) .frame(width: 38, height: 38) Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 18, weight: .bold)) } } .buttonStyle(.plain) Button { wakeUpControls() controller.seek(by: seekStep) showOSD(icon: "goforward.\(Int(seekStep))") } label: { Image(systemName: "goforward.\(Int(seekStep))") .font(.system(size: 18, weight: .medium)) } .buttonStyle(.plain) if let onPlayNext { Button { guard canPlayNext else { return } wakeUpControls() onPlayNext() showOSD(icon: "forward.end.fill") } label: { Image(systemName: "forward.end.fill") .font(.system(size: 18, weight: .medium)) } .buttonStyle(.plain) .disabled(!canPlayNext) .opacity(canPlayNext ? 1 : 0.4) } } Spacer() // 右侧区:音量和全屏 HStack(spacing: 14) { HStack(spacing: 6) { Button { wakeUpControls() controller.toggleMute() showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") } label: { Image(systemName: volumeIconName) .font(.system(size: 14, weight: .bold)) .frame(width: 20) } .buttonStyle(.plain) Slider( value: Binding( get: { Double(controller.volume) }, set: { controller.setVolume(Int($0.rounded())) wakeUpControls() } ), in: 0...200, step: 1 ) .accentColor(.white.opacity(0.8)) .frame(width: 80) } if let onToggleFullScreen { Button { wakeUpControls() onToggleFullScreen() } label: { Image(systemName: "arrow.up.left.and.arrow.down.right") .font(.system(size: 15, weight: .bold)) } .buttonStyle(.plain) } } .frame(width: 150, alignment: .trailing) } } .padding(.horizontal, 24) .padding(.vertical, 10) .foregroundColor(.white) .glassCard(cornerRadius: 18) .padding(.horizontal, 20) .padding(.bottom, 6) .frame(width: containerWidth * 0.7) .environment(\.colorScheme, .dark) } private var playbackRateMenu: some View { Menu { ForEach(VLCPlayerController.supportedPlaybackRates, id: \.self) { rate in Button { wakeUpControls() controller.setPlaybackRate(rate) showOSD(icon: "speedometer") } label: { HStack { Text(playbackRateLabel(rate)) if rate == controller.playbackRate { Spacer() Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Text(playbackRateLabel(controller.playbackRate)) Image(systemName: "chevron.up") .font(.system(size: 8, weight: .bold)) } .font(.system(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(.white) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.white.opacity(0.12)) .clipShape(Capsule()) } .buttonStyle(.plain) } private func playbackRateLabel(_ rate: Float) -> String { if rate.rounded() == rate { return "\(Int(rate))x" } if (rate * 10).rounded() == rate * 10 { return "\(String(format: "%.1f", rate))x" } return "\(String(format: "%.2f", rate))x" } private var volumeIconName: String { switch controller.volume { case ...0: return "speaker.slash.fill" case 1...66: return "speaker.wave.1.fill" default: return "speaker.wave.2.fill" } } } struct VLCLivePlayerView: View { let urlString: String var activityToken: Int = 0 var onPlaybackFailed: (() -> Void)? = nil var onToggleFullScreen: (() -> Void)? = nil @StateObject private var controller = VLCPlayerController() private var volumeStep: Int { 10 } @State private var osdIcon: String? @State private var osdOpacity: Double = 0 @State private var osdTimer: Timer? var body: some View { ZStack { VLCDrawableView(controller: controller) .background(Color.black) .onTapGesture(count: 2) { onToggleFullScreen?() } .onTapGesture(count: 1) { togglePlaybackWithOSD() } if controller.isPreparing { ProgressView() .tint(.white) } if let osdIcon = osdIcon { Image(systemName: osdIcon) .font(.system(size: 60, weight: .semibold)) .foregroundColor(.white) .padding(30) .background(.ultraThinMaterial) .clipShape(Circle()) .opacity(osdOpacity) .allowsHitTesting(false) } } .overlay { KeyboardShortcutCaptureView( onLeft: { }, onRight: { }, onTogglePlayPause: { togglePlaybackWithOSD() }, onToggleFullScreen: { onToggleFullScreen?() }, onDecreaseSpeed: { }, onIncreaseSpeed: { }, onVolumeDown: { controller.setVolume(controller.volume - volumeStep) showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") }, onVolumeUp: { controller.setVolume(controller.volume + volumeStep) showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") } ) .frame(width: 1, height: 1) .opacity(0.01) .allowsHitTesting(false) } .onAppear { startPlayback() } .onChange(of: urlString) { _, _ in startPlayback() } .onDisappear { controller.stop() osdTimer?.invalidate() } } private func showOSD(icon: String) { osdIcon = icon osdOpacity = 1.0 osdTimer?.invalidate() osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in withAnimation(.easeOut(duration: 0.5)) { osdOpacity = 0.0 } } } private func togglePlaybackWithOSD() { controller.togglePlayback() showOSD(icon: controller.isPlaying ? "pause.fill" : "play.fill") } private func startPlayback() { guard let url = URL(string: urlString) else { onPlaybackFailed?() return } controller.play( url: url, startPosition: 0, isLive: true, onProgressChanged: nil, onPlaybackEnded: nil, onPlaybackFailed: onPlaybackFailed ) } } private struct VLCDrawableView: View { let controller: VLCPlayerController var body: some View { #if os(macOS) VLCMacDrawableView(controller: controller) #else VLCIOSDrawableView(controller: controller) #endif } } private struct KeyboardShortcutCaptureView: View { let onLeft: () -> Void let onRight: () -> Void let onTogglePlayPause: () -> Void let onToggleFullScreen: () -> Void let onDecreaseSpeed: () -> Void let onIncreaseSpeed: () -> Void let onVolumeDown: () -> Void let onVolumeUp: () -> Void var body: some View { #if os(macOS) MacKeyboardCaptureView( onLeft: onLeft, onRight: onRight, onTogglePlayPause: onTogglePlayPause, onToggleFullScreen: onToggleFullScreen, onDecreaseSpeed: onDecreaseSpeed, onIncreaseSpeed: onIncreaseSpeed, onVolumeDown: onVolumeDown, onVolumeUp: onVolumeUp ) #else IOSKeyboardCaptureView( onLeft: onLeft, onRight: onRight, onTogglePlayPause: onTogglePlayPause, onToggleFullScreen: onToggleFullScreen, onDecreaseSpeed: onDecreaseSpeed, onIncreaseSpeed: onIncreaseSpeed, onVolumeDown: onVolumeDown, onVolumeUp: onVolumeUp ) #endif } } #if os(macOS) private struct VLCMacDrawableView: NSViewRepresentable { let controller: VLCPlayerController final class Coordinator { let controller: VLCPlayerController init(controller: VLCPlayerController) { self.controller = controller } } func makeCoordinator() -> Coordinator { Coordinator(controller: controller) } func makeNSView(context: Context) -> VLCOutputNSView { let view = VLCOutputNSView(frame: .zero) view.wantsLayer = true view.layer?.backgroundColor = NSColor.black.cgColor view.onLifecycle = { container in context.coordinator.controller.attachDrawable(to: container) } view.requestLifecycleUpdate() return view } func updateNSView(_ nsView: VLCOutputNSView, context: Context) { nsView.onLifecycle = { container in context.coordinator.controller.attachDrawable(to: container) } nsView.requestLifecycleUpdate() } static func dismantleNSView(_ nsView: VLCOutputNSView, coordinator: Coordinator) { nsView.onLifecycle = nil coordinator.controller.detachDrawable(from: nsView) } } private final class VLCOutputNSView: NSView { var onLifecycle: ((NSView) -> Void)? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() requestLifecycleUpdate() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() requestLifecycleUpdate() } override func layout() { super.layout() requestLifecycleUpdate() } func requestLifecycleUpdate() { onLifecycle?(self) } } private struct MacKeyboardCaptureView: NSViewRepresentable { let onLeft: () -> Void let onRight: () -> Void let onTogglePlayPause: () -> Void let onToggleFullScreen: () -> Void let onDecreaseSpeed: () -> Void let onIncreaseSpeed: () -> Void let onVolumeDown: () -> Void let onVolumeUp: () -> Void func makeNSView(context: Context) -> MacKeyCaptureNSView { let view = MacKeyCaptureNSView(frame: .zero) applyCallbacks(to: view) DispatchQueue.main.async { view.activate() } return view } func updateNSView(_ nsView: MacKeyCaptureNSView, context: Context) { applyCallbacks(to: nsView) DispatchQueue.main.async { nsView.activate() } } private func applyCallbacks(to view: MacKeyCaptureNSView) { view.onLeft = onLeft view.onRight = onRight view.onTogglePlayPause = onTogglePlayPause view.onToggleFullScreen = onToggleFullScreen view.onDecreaseSpeed = onDecreaseSpeed view.onIncreaseSpeed = onIncreaseSpeed view.onVolumeDown = onVolumeDown view.onVolumeUp = onVolumeUp } } private final class MacKeyCaptureNSView: NSView { var onLeft: (() -> Void)? var onRight: (() -> Void)? var onTogglePlayPause: (() -> Void)? var onToggleFullScreen: (() -> Void)? var onDecreaseSpeed: (() -> Void)? var onIncreaseSpeed: (() -> Void)? var onVolumeDown: (() -> Void)? var onVolumeUp: (() -> Void)? override var acceptsFirstResponder: Bool { true } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() activate() } func activate() { window?.makeFirstResponder(self) } override func keyDown(with event: NSEvent) { if event.modifierFlags.intersection([.command, .control, .option]).isEmpty == false { super.keyDown(with: event) return } switch event.keyCode { case 123: // left onLeft?() return case 124: // right onRight?() return case 125: // down onVolumeDown?() return case 126: // up onVolumeUp?() return case 49: // space onTogglePlayPause?() return default: break } let key = event.charactersIgnoringModifiers?.lowercased() ?? "" switch key { case "k": onTogglePlayPause?() case "f": onToggleFullScreen?() case "[": onDecreaseSpeed?() case "]": onIncreaseSpeed?() default: super.keyDown(with: event) } } } #else private struct VLCIOSDrawableView: UIViewRepresentable { let controller: VLCPlayerController final class Coordinator { let controller: VLCPlayerController init(controller: VLCPlayerController) { self.controller = controller } } func makeCoordinator() -> Coordinator { Coordinator(controller: controller) } func makeUIView(context: Context) -> VLCOutputUIView { let view = VLCOutputUIView(frame: .zero) view.backgroundColor = .black view.onLifecycle = { container in context.coordinator.controller.attachDrawable(to: container) } view.requestLifecycleUpdate() return view } func updateUIView(_ uiView: VLCOutputUIView, context: Context) { uiView.onLifecycle = { container in context.coordinator.controller.attachDrawable(to: container) } uiView.requestLifecycleUpdate() } static func dismantleUIView(_ uiView: VLCOutputUIView, coordinator: Coordinator) { uiView.onLifecycle = nil coordinator.controller.detachDrawable(from: uiView) } } private final class VLCOutputUIView: UIView { var onLifecycle: ((UIView) -> Void)? override func didMoveToWindow() { super.didMoveToWindow() requestLifecycleUpdate() } override func didMoveToSuperview() { super.didMoveToSuperview() requestLifecycleUpdate() } override func layoutSubviews() { super.layoutSubviews() requestLifecycleUpdate() } func requestLifecycleUpdate() { onLifecycle?(self) } } private struct IOSKeyboardCaptureView: UIViewRepresentable { let onLeft: () -> Void let onRight: () -> Void let onTogglePlayPause: () -> Void let onToggleFullScreen: () -> Void let onDecreaseSpeed: () -> Void let onIncreaseSpeed: () -> Void let onVolumeDown: () -> Void let onVolumeUp: () -> Void func makeUIView(context: Context) -> IOSKeyCaptureView { let view = IOSKeyCaptureView(frame: .zero) applyCallbacks(to: view) DispatchQueue.main.async { view.activate() } return view } func updateUIView(_ uiView: IOSKeyCaptureView, context: Context) { applyCallbacks(to: uiView) DispatchQueue.main.async { uiView.activate() } } private func applyCallbacks(to view: IOSKeyCaptureView) { view.onLeft = onLeft view.onRight = onRight view.onTogglePlayPause = onTogglePlayPause view.onToggleFullScreen = onToggleFullScreen view.onDecreaseSpeed = onDecreaseSpeed view.onIncreaseSpeed = onIncreaseSpeed view.onVolumeDown = onVolumeDown view.onVolumeUp = onVolumeUp } } private final class IOSKeyCaptureView: UIView { var onLeft: (() -> Void)? var onRight: (() -> Void)? var onTogglePlayPause: (() -> Void)? var onToggleFullScreen: (() -> Void)? var onDecreaseSpeed: (() -> Void)? var onIncreaseSpeed: (() -> Void)? var onVolumeDown: (() -> Void)? var onVolumeUp: (() -> Void)? override var canBecomeFirstResponder: Bool { true } override var keyCommands: [UIKeyCommand]? { [ UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeft)), UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRight)), UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleVolumeDown)), UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleVolumeUp)), UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleTogglePlayPause)), UIKeyCommand(input: "k", modifierFlags: [], action: #selector(handleTogglePlayPause)), UIKeyCommand(input: "f", modifierFlags: [], action: #selector(handleToggleFullScreen)), UIKeyCommand(input: "[", modifierFlags: [], action: #selector(handleDecreaseSpeed)), UIKeyCommand(input: "]", modifierFlags: [], action: #selector(handleIncreaseSpeed)) ] } override func didMoveToWindow() { super.didMoveToWindow() activate() } func activate() { becomeFirstResponder() } @objc private func handleLeft() { onLeft?() } @objc private func handleRight() { onRight?() } @objc private func handleTogglePlayPause() { onTogglePlayPause?() } @objc private func handleToggleFullScreen() { onToggleFullScreen?() } @objc private func handleDecreaseSpeed() { onDecreaseSpeed?() } @objc private func handleIncreaseSpeed() { onIncreaseSpeed?() } @objc private func handleVolumeDown() { onVolumeDown?() } @objc private func handleVolumeUp() { onVolumeUp?() } } #endif #else final class VLCPlayerController: ObservableObject { func stop() {} } struct VLCVodPlayerView: View { let urlString: String var startPosition: Double = 0 var onProgressChanged: ((Double, Double?) -> Void)? = nil var onPlaybackEnded: (() -> Void)? = nil var onToggleFullScreen: (() -> Void)? = nil var canPlayNext: Bool = false var onPlayNext: (() -> Void)? = nil var body: some View { AVPlayerContentView( urlString: urlString, startPosition: startPosition, onProgressChanged: onProgressChanged, onPlaybackEnded: onPlaybackEnded, onToggleFullScreen: onToggleFullScreen, canPlayNext: canPlayNext, onPlayNext: onPlayNext ) } } struct VLCLivePlayerView: View { let urlString: String var onPlaybackFailed: (() -> Void)? = nil var onToggleFullScreen: (() -> Void)? = nil var body: some View { Color.black } } #endif