1984 lines
71 KiB
Swift
1984 lines
71 KiB
Swift
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
|
||
|
||
private var _mediaPlayer: VLCMediaPlayer?
|
||
var mediaPlayer: VLCMediaPlayer {
|
||
if let existing = _mediaPlayer {
|
||
return existing
|
||
}
|
||
let player = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
|
||
_mediaPlayer = player
|
||
player.delegate = self
|
||
player.drawable = persistentDrawableView
|
||
return player
|
||
}
|
||
@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
|
||
}
|
||
|
||
deinit {
|
||
if let player = _mediaPlayer {
|
||
_mediaPlayer = nil
|
||
// VLCMediaPlayer 的 dealloc 会阻塞(libvlc_media_player_release 内部 dispatch_sync 到主线程)。
|
||
// 将引用移到后台线程,让 dealloc 在后台发生,避免阻塞主线程。
|
||
nonisolated(unsafe) let p = player
|
||
DispatchQueue.global(qos: .utility).async { [p] in
|
||
_ = p
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
"http-user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||
]
|
||
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()
|
||
onProgressChanged = nil
|
||
onPlaybackEnded = nil
|
||
onPlaybackFailed = nil
|
||
pendingSeekSeconds = nil
|
||
setPlaybackStatus(preparing: false, playing: false)
|
||
resetProgressState()
|
||
currentMediaURLString = nil
|
||
currentMediaIsLive = false
|
||
currentMediaDecodeMode = .auto
|
||
currentMediaBufferMode = .defaultMode
|
||
guard let player = _mediaPlayer else { return }
|
||
// 立即静音(audio.volume 是轻量属性设置,不会 dispatch_sync)
|
||
player.audio?.volume = 0
|
||
// 将 mediaPlayer 引用移到后台释放。
|
||
// 不调用 pause/stop/media=nil 等 VLC API — 它们会 dispatch_sync 回主线程导致卡顿。
|
||
// VLCMediaPlayer 的 dealloc 会在后台线程自然完成清理。
|
||
_mediaPlayer = nil
|
||
nonisolated(unsafe) let p = player
|
||
DispatchQueue.global(qos: .utility).async { [p] in
|
||
_ = p
|
||
}
|
||
}
|
||
|
||
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() {
|
||
#if os(macOS)
|
||
// macOS: 强制重绑视频输出,规避切全屏后偶发“有声音无画面”
|
||
mediaPlayer.drawable = nil
|
||
mediaPlayer.drawable = persistentDrawableView
|
||
#else
|
||
// iOS: 直接赋值,不置空 drawable,避免触发 state change 导致 SwiftUI dismiss 动画中断
|
||
mediaPlayer.drawable = persistentDrawableView
|
||
#endif
|
||
lastDrawableRebindAt = Date()
|
||
}
|
||
|
||
private func stopMediaPlayer() {
|
||
// 优先走 libvlc 异步 stop,避免菜单切换时主线程被同步 stop 卡住。
|
||
if let playerPointer = playerInstancePointer(),
|
||
let stopAsync = Self.libVLCStopAsync {
|
||
stopAsync(playerPointer)
|
||
return
|
||
}
|
||
// VLCKit 3.x 没有 async stop API,同步 stop 会阻塞主线程 2-3 秒。
|
||
// 将其放到后台队列执行,避免 UI 卡顿。
|
||
nonisolated(unsafe) let player = mediaPlayer
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
player.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<Void, Never>?
|
||
|
||
private var controller: VLCPlayerController {
|
||
sharedController ?? ownedController
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
VLCDrawableView(controller: controller)
|
||
.background(Color.black)
|
||
#if !os(iOS)
|
||
.onTapGesture(count: 2) {
|
||
onToggleFullScreen?()
|
||
}
|
||
.onTapGesture(count: 1) {
|
||
togglePlaybackWithOSD()
|
||
}
|
||
#endif
|
||
|
||
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)
|
||
}
|
||
}
|
||
#if os(iOS)
|
||
.overlay {
|
||
PlayerGestureLayer(
|
||
onSeek: { offset in controller.seek(by: offset) },
|
||
onTogglePlayPause: { togglePlaybackWithOSD() },
|
||
onToggleControls: { wakeUpControls() },
|
||
onZoomChanged: { _ in },
|
||
currentTime: controller.currentTimeSeconds,
|
||
duration: controller.durationSeconds
|
||
)
|
||
}
|
||
#endif
|
||
.overlay(alignment: .bottom) {
|
||
GeometryReader { proxy in
|
||
playbackControls(containerWidth: proxy.size.width)
|
||
#if os(macOS)
|
||
.padding(12)
|
||
#endif
|
||
.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 = Self.sanitizedURL(from: urlString) else {
|
||
print("[VLC] URL sanitization failed for: \(urlString)")
|
||
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
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 将原始 URL 字符串转换为合法的 URL,处理未编码的特殊字符。
|
||
private static func sanitizedURL(from urlString: String) -> URL? {
|
||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if let url = URL(string: trimmed) {
|
||
return url
|
||
}
|
||
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||
let url = URL(string: encoded) {
|
||
return url
|
||
}
|
||
return 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 {
|
||
#if os(iOS)
|
||
let controlWidth = containerWidth * 1.0
|
||
#else
|
||
let controlWidth = containerWidth * 0.7
|
||
#endif
|
||
|
||
return VStack(spacing: 0) {
|
||
#if os(iOS)
|
||
// iOS: 紧凑单行布局 — 进度条在上,按钮在下紧贴
|
||
HStack(spacing: 8) {
|
||
Text(currentDisplaySeconds.durationString)
|
||
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||
.foregroundColor(.white.opacity(0.8))
|
||
.lineLimit(1)
|
||
|
||
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: 10, weight: .medium, design: .monospaced))
|
||
.foregroundColor(.white.opacity(0.5))
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
.padding(.bottom, 4)
|
||
|
||
// 控制按钮行 — 紧凑排列
|
||
HStack(spacing: 0) {
|
||
// 左:倍速
|
||
playbackRateMenu
|
||
.frame(minWidth: 36, alignment: .leading)
|
||
|
||
Spacer()
|
||
|
||
// 中间:主控按钮
|
||
HStack(spacing: 20) {
|
||
Button {
|
||
wakeUpControls()
|
||
controller.seek(by: -seekStep)
|
||
showOSD(icon: "gobackward.\(Int(seekStep))")
|
||
} label: {
|
||
Image(systemName: "gobackward.\(Int(seekStep))")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.frame(minWidth: 36, minHeight: 36)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button {
|
||
wakeUpControls()
|
||
togglePlaybackWithOSD()
|
||
} label: {
|
||
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 20, weight: .bold))
|
||
.frame(minWidth: 36, minHeight: 36)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button {
|
||
wakeUpControls()
|
||
controller.seek(by: seekStep)
|
||
showOSD(icon: "goforward.\(Int(seekStep))")
|
||
} label: {
|
||
Image(systemName: "goforward.\(Int(seekStep))")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.frame(minWidth: 36, minHeight: 36)
|
||
}
|
||
.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: 16, weight: .medium))
|
||
.frame(minWidth: 36, minHeight: 36)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!canPlayNext)
|
||
.opacity(canPlayNext ? 1 : 0.4)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// 右:全屏
|
||
if let onToggleFullScreen {
|
||
Button {
|
||
wakeUpControls()
|
||
onToggleFullScreen()
|
||
} label: {
|
||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.frame(minWidth: 36, minHeight: 36)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, 6)
|
||
#else
|
||
// macOS: 保持两行布局
|
||
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(.top, 8)
|
||
#endif
|
||
}
|
||
#if os(iOS)
|
||
.padding(.vertical, 2)
|
||
.foregroundColor(.white)
|
||
.background(
|
||
LinearGradient(
|
||
colors: [.clear, .black.opacity(0.6)],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
)
|
||
.padding(.bottom, 0)
|
||
.frame(width: controlWidth)
|
||
#else
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 10)
|
||
.foregroundColor(.white)
|
||
.glassCard(cornerRadius: 18)
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 6)
|
||
.frame(width: controlWidth)
|
||
#endif
|
||
.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?
|
||
@State private var showControls = true
|
||
@State private var controlsTimer: Timer?
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
VLCDrawableView(controller: controller)
|
||
.background(Color.black)
|
||
#if !os(iOS)
|
||
.onTapGesture(count: 2) {
|
||
onToggleFullScreen?()
|
||
}
|
||
.onTapGesture(count: 1) {
|
||
togglePlaybackWithOSD()
|
||
}
|
||
#endif
|
||
|
||
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)
|
||
}
|
||
}
|
||
#if os(iOS)
|
||
.overlay {
|
||
LivePlayerGestureLayer(
|
||
onTogglePlayPause: { togglePlaybackWithOSD() },
|
||
onToggleControls: { wakeUpControls() },
|
||
onVolumeChanged: { delta in
|
||
let newVol = max(0, min(200, controller.volume + Int(delta * 200)))
|
||
controller.setVolume(newVol)
|
||
}
|
||
)
|
||
}
|
||
.overlay(alignment: .bottom) {
|
||
liveControlsOverlay
|
||
.opacity(showControls ? 1.0 : 0.0)
|
||
.animation(.easeInOut(duration: 0.3), value: showControls)
|
||
}
|
||
#endif
|
||
.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()
|
||
wakeUpControls()
|
||
}
|
||
.onChange(of: urlString) { _, _ in
|
||
startPlayback()
|
||
wakeUpControls()
|
||
}
|
||
.onChange(of: activityToken) { _, _ in
|
||
wakeUpControls()
|
||
}
|
||
.onDisappear {
|
||
controller.stop()
|
||
osdTimer?.invalidate()
|
||
controlsTimer?.invalidate()
|
||
}
|
||
}
|
||
|
||
// MARK: - iOS Live Controls Overlay
|
||
|
||
#if os(iOS)
|
||
private var liveControlsOverlay: some View {
|
||
HStack(spacing: 20) {
|
||
// Play/Pause button
|
||
Button {
|
||
wakeUpControls()
|
||
togglePlaybackWithOSD()
|
||
} label: {
|
||
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 20, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.frame(width: 48, height: 48)
|
||
.background(Color.white.opacity(0.15))
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 12)
|
||
.glassCard(cornerRadius: 14)
|
||
.padding(.bottom, 20)
|
||
.padding(.horizontal, 16)
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Controls Timer
|
||
|
||
private func wakeUpControls() {
|
||
withAnimation { showControls = true }
|
||
controlsTimer?.invalidate()
|
||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.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() {
|
||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard let url = URL(string: trimmed) ?? trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed).flatMap({ URL(string: $0) }) 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
|