// // KSOptions.swift // KSPlayer-tvOS // // Created by kintan on 2018/3/9. // import AVFoundation #if os(tvOS) || os(xrOS) import DisplayCriteria #endif import OSLog #if canImport(UIKit) import UIKit #endif open class KSOptions { /// 最低缓存视频时间 @Published public var preferredForwardBufferDuration = KSOptions.preferredForwardBufferDuration /// 最大缓存视频时间 public var maxBufferDuration = KSOptions.maxBufferDuration /// 是否开启秒开 public var isSecondOpen = KSOptions.isSecondOpen /// 开启精确seek public var isAccurateSeek = KSOptions.isAccurateSeek /// Applies to short videos only public var isLoopPlay = KSOptions.isLoopPlay /// seek完是否自动播放 public var isSeekedAutoPlay = KSOptions.isSeekedAutoPlay /* AVSEEK_FLAG_BACKWARD: 1 AVSEEK_FLAG_BYTE: 2 AVSEEK_FLAG_ANY: 4 AVSEEK_FLAG_FRAME: 8 */ public var seekFlags = Int32(1) // ffmpeg only cache http // 这个开关不能用,因为ff_tempfile: Cannot open temporary file public var cache = false // record stream public var outputURL: URL? public var avOptions = [String: Any]() public var formatContextOptions = [String: Any]() public var decoderOptions = [String: Any]() public var probesize: Int64? public var maxAnalyzeDuration: Int64? public var lowres = UInt8(0) public var nobuffer = false public var codecLowDelay = false public var startPlayTime: TimeInterval = 0 public var startPlayRate: Float = 1.0 public var registerRemoteControll: Bool = true // 默认支持来自系统控制中心的控制 public var referer: String? { didSet { if let referer { formatContextOptions["referer"] = "Referer: \(referer)" } else { formatContextOptions["referer"] = nil } } } public var userAgent: String? = "KSPlayer" { didSet { formatContextOptions["user_agent"] = userAgent } } // audio public var audioFilters = [String]() public var syncDecodeAudio = false // sutile public var autoSelectEmbedSubtitle = true public var isSeekImageSubtitle = false // video public var display = DisplayEnum.plane public var videoDelay = 0.0 // s public var autoDeInterlace = false public var autoRotate = true public var destinationDynamicRange: DynamicRange? public var videoAdaptable = true public var videoFilters = [String]() public var syncDecodeVideo = false public var hardwareDecode = KSOptions.hardwareDecode public var asynchronousDecompression = KSOptions.asynchronousDecompression public var videoDisable = false public var canStartPictureInPictureAutomaticallyFromInline = KSOptions.canStartPictureInPictureAutomaticallyFromInline public var automaticWindowResize = true @Published public var videoInterlacingType: VideoInterlacingType? private var videoClockDelayCount = 0 public internal(set) var formatName = "" public internal(set) var prepareTime = 0.0 public internal(set) var dnsStartTime = 0.0 public internal(set) var tcpStartTime = 0.0 public internal(set) var tcpConnectedTime = 0.0 public internal(set) var openTime = 0.0 public internal(set) var findTime = 0.0 public internal(set) var readyTime = 0.0 public internal(set) var readAudioTime = 0.0 public internal(set) var readVideoTime = 0.0 public internal(set) var decodeAudioTime = 0.0 public internal(set) var decodeVideoTime = 0.0 public init() { formatContextOptions["user_agent"] = userAgent // 参数的配置可以参考protocols.texi 和 http.c // 这个一定要,不然有的流就会判断不准FieldOrder formatContextOptions["scan_all_pmts"] = 1 // ts直播流需要加这个才能一直直播下去,不然播放一小段就会结束了。 formatContextOptions["reconnect"] = 1 formatContextOptions["reconnect_streamed"] = 1 // 这个是用来开启http的链接复用(keep-alive)。vlc默认是打开的,所以这边也默认打开。 // 开启这个,百度网盘的视频链接无法播放 // formatContextOptions["multiple_requests"] = 1 // 下面是用来处理秒开的参数,有需要的自己打开。默认不开,不然在播放某些特殊的ts直播流会频繁卡顿。 // formatContextOptions["auto_convert"] = 0 // formatContextOptions["fps_probe_size"] = 3 // formatContextOptions["rw_timeout"] = 10_000_000 // formatContextOptions["max_analyze_duration"] = 300 * 1000 // 默认情况下允许所有协议,只有嵌套协议才需要指定这个协议子集,例如m3u8里面有http。 // formatContextOptions["protocol_whitelist"] = "file,http,https,tcp,tls,crypto,async,cache,data,httpproxy" // 开启这个,纯ipv6地址会无法播放。并且有些视频结束了,但还会一直尝试重连。所以这个值默认不设置 // formatContextOptions["reconnect_at_eof"] = 1 // 开启这个,会导致tcp Failed to resolve hostname 还会一直重试 // formatContextOptions["reconnect_on_network_error"] = 1 // There is total different meaning for 'listen_timeout' option in rtmp // set 'listen_timeout' = -1 for rtmp、rtsp // formatContextOptions["listen_timeout"] = 3 decoderOptions["threads"] = "auto" decoderOptions["refcounted_frames"] = "1" } /** you can add http-header or other options which mentions in https://developer.apple.com/reference/avfoundation/avurlasset/initialization_options to add http-header init options like this ``` options.appendHeader(["Referer":"https:www.xxx.com"]) ``` */ public func appendHeader(_ header: [String: String]) { var oldValue = avOptions["AVURLAssetHTTPHeaderFieldsKey"] as? [String: String] ?? [ String: String ]() oldValue.merge(header) { _, new in new } avOptions["AVURLAssetHTTPHeaderFieldsKey"] = oldValue var str = formatContextOptions["headers"] as? String ?? "" for (key, value) in header { str.append("\(key):\(value)\r\n") } formatContextOptions["headers"] = str } public func setCookie(_ cookies: [HTTPCookie]) { avOptions[AVURLAssetHTTPCookiesKey] = cookies let cookieStr = cookies.map { cookie in "\(cookie.name)=\(cookie.value)" }.joined(separator: "; ") appendHeader(["Cookie": cookieStr]) } // 缓冲算法函数 open func playable(capacitys: [CapacityProtocol], isFirst: Bool, isSeek: Bool) -> LoadingState { let packetCount = capacitys.map(\.packetCount).min() ?? 0 let frameCount = capacitys.map(\.frameCount).min() ?? 0 let isEndOfFile = capacitys.allSatisfy(\.isEndOfFile) let loadedTime = capacitys.map(\.loadedTime).min() ?? 0 let progress = preferredForwardBufferDuration == 0 ? 100 : loadedTime * 100.0 / preferredForwardBufferDuration let isPlayable = capacitys.allSatisfy { capacity in if capacity.isEndOfFile && capacity.packetCount == 0 { return true } guard capacity.frameCount >= 2 else { return false } if capacity.isEndOfFile { return true } if (syncDecodeVideo && capacity.mediaType == .video) || (syncDecodeAudio && capacity.mediaType == .audio) { return true } if isFirst || isSeek { // 让纯音频能更快的打开 if capacity.mediaType == .audio || isSecondOpen { if isFirst { return true } else { return capacity.loadedTime >= self.preferredForwardBufferDuration / 2 } } } return capacity.loadedTime >= self.preferredForwardBufferDuration } return LoadingState(loadedTime: loadedTime, progress: progress, packetCount: packetCount, frameCount: frameCount, isEndOfFile: isEndOfFile, isPlayable: isPlayable, isFirst: isFirst, isSeek: isSeek) } open func adaptable(state: VideoAdaptationState?) -> (Int64, Int64)? { guard let state, let last = state.bitRateStates.last, CACurrentMediaTime() - last.time > maxBufferDuration / 2, let index = state.bitRates.firstIndex(of: last.bitRate) else { return nil } let isUp = state.loadedCount > Int(Double(state.fps) * maxBufferDuration / 2) if isUp != state.isPlayable { return nil } if isUp { if index < state.bitRates.endIndex - 1 { return (last.bitRate, state.bitRates[index + 1]) } } else { if index > state.bitRates.startIndex { return (last.bitRate, state.bitRates[index - 1]) } } return nil } /// wanted video stream index, or nil for automatic selection /// - Parameter : video track /// - Returns: The index of the track open func wantedVideo(tracks _: [MediaPlayerTrack]) -> Int? { nil } /// wanted audio stream index, or nil for automatic selection /// - Parameter : audio track /// - Returns: The index of the track open func wantedAudio(tracks _: [MediaPlayerTrack]) -> Int? { nil } open func videoFrameMaxCount(fps _: Float, naturalSize _: CGSize, isLive: Bool) -> UInt8 { isLive ? 4 : 16 } open func audioFrameMaxCount(fps: Float, channelCount: Int) -> UInt8 { let count = (Int(fps) * channelCount) >> 2 if count >= UInt8.max { return UInt8.max } else { return UInt8(count) } } /// customize dar /// - Parameters: /// - sar: SAR(Sample Aspect Ratio) /// - dar: PAR(Pixel Aspect Ratio) /// - Returns: DAR(Display Aspect Ratio) open func customizeDar(sar _: CGSize, par _: CGSize) -> CGSize? { nil } // 虽然只有iOS才支持PIP。但是因为AVSampleBufferDisplayLayer能够支持HDR10+。所以默认还是推荐用AVSampleBufferDisplayLayer open func isUseDisplayLayer() -> Bool { display == .plane } open func urlIO(log: String) { if log.starts(with: "Original list of addresses"), dnsStartTime == 0 { dnsStartTime = CACurrentMediaTime() } else if log.starts(with: "Starting connection attempt to"), tcpStartTime == 0 { tcpStartTime = CACurrentMediaTime() } else if log.starts(with: "Successfully connected to"), tcpConnectedTime == 0 { tcpConnectedTime = CACurrentMediaTime() } } private var idetTypeMap = [VideoInterlacingType: UInt]() open func filter(log: String) { if log.starts(with: "Repeated Field:"), autoDeInterlace { for str in log.split(separator: ",") { let map = str.split(separator: ":") if map.count >= 2 { if String(map[0].trimmingCharacters(in: .whitespaces)) == "Multi frame" { if let type = VideoInterlacingType(rawValue: map[1].trimmingCharacters(in: .whitespacesAndNewlines)) { idetTypeMap[type] = (idetTypeMap[type] ?? 0) + 1 let tff = idetTypeMap[.tff] ?? 0 let bff = idetTypeMap[.bff] ?? 0 let progressive = idetTypeMap[.progressive] ?? 0 let undetermined = idetTypeMap[.undetermined] ?? 0 if progressive - tff - bff > 100 { videoInterlacingType = .progressive autoDeInterlace = false } else if bff - progressive > 100 { videoInterlacingType = .bff autoDeInterlace = false } else if tff - progressive > 100 { videoInterlacingType = .tff autoDeInterlace = false } else if undetermined - progressive - tff - bff > 100 { videoInterlacingType = .undetermined autoDeInterlace = false } } } } } } } open func sei(string: String) { KSLog("sei \(string)") } /** 在创建解码器之前可以对KSOptions和assetTrack做一些处理。例如判断fieldOrder为tt或bb的话,那就自动加videofilters */ open func process(assetTrack: some MediaPlayerTrack) { if assetTrack.mediaType == .video { if [FFmpegFieldOrder.bb, .bt, .tt, .tb].contains(assetTrack.fieldOrder) { // todo 先不要用yadif_videotoolbox,不然会crash。这个后续在看下要怎么解决 hardwareDecode = false asynchronousDecompression = false let yadif = hardwareDecode ? "yadif_videotoolbox" : "yadif" var yadifMode = KSOptions.yadifMode // if let assetTrack = assetTrack as? FFmpegAssetTrack { // if assetTrack.realFrameRate.num == 2 * assetTrack.avgFrameRate.num, assetTrack.realFrameRate.den == assetTrack.avgFrameRate.den { // if yadifMode == 1 { // yadifMode = 0 // } else if yadifMode == 3 { // yadifMode = 2 // } // } // } if KSOptions.deInterlaceAddIdet { videoFilters.append("idet") } videoFilters.append("\(yadif)=mode=\(yadifMode):parity=-1:deint=1") if yadifMode == 1 || yadifMode == 3 { assetTrack.nominalFrameRate = assetTrack.nominalFrameRate * 2 } } } } @MainActor open func updateVideo(refreshRate: Float, isDovi: Bool, formatDescription: CMFormatDescription?) { #if os(tvOS) || os(xrOS) /** 快速更改preferredDisplayCriteria,会导致isDisplayModeSwitchInProgress变成true。 例如退出一个视频,然后在3s内重新进入的话。所以不判断isDisplayModeSwitchInProgress了 */ guard let displayManager = UIApplication.shared.windows.first?.avDisplayManager, displayManager.isDisplayCriteriaMatchingEnabled else { return } if let dynamicRange = isDovi ? .dolbyVision : formatDescription?.dynamicRange { displayManager.preferredDisplayCriteria = AVDisplayCriteria(refreshRate: refreshRate, videoDynamicRange: dynamicRange.rawValue) } #endif } open func videoClockSync(main: KSClock, nextVideoTime: TimeInterval, fps: Double, frameCount: Int) -> (Double, ClockProcessType) { let desire = main.getTime() - videoDelay let diff = nextVideoTime - desire // print("[video] video diff \(diff) nextVideoTime \(nextVideoTime) main \(main.time.seconds)") if diff >= 1 / fps / 2 { videoClockDelayCount = 0 return (diff, .remain) } else { if diff < -4 / fps { videoClockDelayCount += 1 let log = "[video] video delay=\(diff), clock=\(desire), delay count=\(videoClockDelayCount), frameCount=\(frameCount)" if frameCount == 1 { if diff < -1, videoClockDelayCount % 10 == 0 { KSLog("\(log) drop gop Packet") return (diff, .dropGOPPacket) } else if videoClockDelayCount % 5 == 0 { KSLog("\(log) drop next frame") return (diff, .dropNextFrame) } else { return (diff, .next) } } else { if diff < -8, videoClockDelayCount % 100 == 0 { KSLog("\(log) seek video track") return (diff, .seek) } if diff < -1, videoClockDelayCount % 10 == 0 { KSLog("\(log) flush video track") return (diff, .flush) } if videoClockDelayCount % 2 == 0 { KSLog("\(log) drop next frame") return (diff, .dropNextFrame) } else { return (diff, .next) } } } else { videoClockDelayCount = 0 return (diff, .next) } } } open func availableDynamicRange(_ contentRange: DynamicRange?) -> DynamicRange? { #if canImport(UIKit) let availableHDRModes = AVPlayer.availableHDRModes if let preferedDynamicRange = destinationDynamicRange { // value of 0 indicates that no HDR modes are supported. if availableHDRModes == AVPlayer.HDRMode(rawValue: 0) { return .sdr } else if availableHDRModes.contains(preferedDynamicRange.hdrMode) { return preferedDynamicRange } else if let contentRange, availableHDRModes.contains(contentRange.hdrMode) { return contentRange } else if preferedDynamicRange != .sdr { // trying update to HDR mode return availableHDRModes.dynamicRange } } return contentRange #else return destinationDynamicRange ?? contentRange #endif } open func playerLayerDeinit() { #if os(tvOS) || os(xrOS) runOnMainThread { UIApplication.shared.windows.first?.avDisplayManager.preferredDisplayCriteria = nil } #endif } open func liveAdaptivePlaybackRate(loadingState _: LoadingState) -> Float? { nil // if loadingState.isFirst { // return nil // } // if loadingState.loadedTime > preferredForwardBufferDuration + 5 { // return 1.2 // } else if loadingState.loadedTime < preferredForwardBufferDuration / 2 { // return 0.8 // } else { // return 1 // } } open func process(url _: URL) -> AbstractAVIOContext? { nil } } public enum VideoInterlacingType: String { case tff case bff case progressive case undetermined } public extension KSOptions { static var firstPlayerType: MediaPlayerProtocol.Type = KSAVPlayer.self static var secondPlayerType: MediaPlayerProtocol.Type? = KSMEPlayer.self /// 最低缓存视频时间 static var preferredForwardBufferDuration = 3.0 /// 最大缓存视频时间 static var maxBufferDuration = 30.0 /// 是否开启秒开 static var isSecondOpen = false /// 开启精确seek static var isAccurateSeek = false /// Applies to short videos only static var isLoopPlay = false /// 是否自动播放,默认true static var isAutoPlay = true /// seek完是否自动播放 static var isSeekedAutoPlay = true static var hardwareDecode = true // 默认不用自研的硬解,因为有些视频的AVPacket的pts顺序是不对的,只有解码后的AVFrame里面的pts是对的。 static var asynchronousDecompression = false static var isPipPopViewController = false static var canStartPictureInPictureAutomaticallyFromInline = true static var preferredFrame = true static var useSystemHTTPProxy = true /// 日志级别 static var logLevel = LogLevel.warning static var logger: LogHandler = OSLog(lable: "KSPlayer") internal static func deviceCpuCount() -> Int { var ncpu = UInt(0) var len: size_t = MemoryLayout.size(ofValue: ncpu) sysctlbyname("hw.ncpu", &ncpu, &len, nil, 0) return Int(ncpu) } static func setAudioSession() { #if os(macOS) // try? AVAudioSession.sharedInstance().setRouteSharingPolicy(.longFormAudio) #else var category = AVAudioSession.sharedInstance().category if category != .playAndRecord { category = .playback } #if os(tvOS) try? AVAudioSession.sharedInstance().setCategory(category, mode: .moviePlayback, policy: .longFormAudio) #else try? AVAudioSession.sharedInstance().setCategory(category, mode: .moviePlayback, policy: .longFormVideo) #endif try? AVAudioSession.sharedInstance().setActive(true) #endif } #if !os(macOS) static func isSpatialAudioEnabled(channelCount _: AVAudioChannelCount) -> Bool { if #available(tvOS 15.0, iOS 15.0, *) { let isSpatialAudioEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.isSpatialAudioEnabled } try? AVAudioSession.sharedInstance().setSupportsMultichannelContent(isSpatialAudioEnabled) return isSpatialAudioEnabled } else { return false } } static func outputNumberOfChannels(channelCount: AVAudioChannelCount) -> AVAudioChannelCount { let maximumOutputNumberOfChannels = AVAudioChannelCount(AVAudioSession.sharedInstance().maximumOutputNumberOfChannels) let preferredOutputNumberOfChannels = AVAudioChannelCount(AVAudioSession.sharedInstance().preferredOutputNumberOfChannels) let isSpatialAudioEnabled = isSpatialAudioEnabled(channelCount: channelCount) let isUseAudioRenderer = KSOptions.audioPlayerType == AudioRendererPlayer.self KSLog("[audio] maximumOutputNumberOfChannels: \(maximumOutputNumberOfChannels), preferredOutputNumberOfChannels: \(preferredOutputNumberOfChannels), isSpatialAudioEnabled: \(isSpatialAudioEnabled), isUseAudioRenderer: \(isUseAudioRenderer) ") let maxRouteChannelsCount = AVAudioSession.sharedInstance().currentRoute.outputs.compactMap { $0.channels?.count }.max() ?? 2 KSLog("[audio] currentRoute max channels: \(maxRouteChannelsCount)") var channelCount = channelCount if channelCount > 2 { let minChannels = min(maximumOutputNumberOfChannels, channelCount) #if os(tvOS) || targetEnvironment(simulator) if !(isUseAudioRenderer && isSpatialAudioEnabled) { // 不要用maxRouteChannelsCount来判断,有可能会不准。导致多音道设备也返回2(一开始播放一个2声道,就容易出现),也不能用outputNumberOfChannels来判断,有可能会返回2 // channelCount = AVAudioChannelCount(min(AVAudioSession.sharedInstance().outputNumberOfChannels, maxRouteChannelsCount)) channelCount = minChannels } #else // iOS 外放是会自动有空间音频功能,但是蓝牙耳机有可能没有空间音频功能或者把空间音频给关了,。所以还是需要处理。 if !isSpatialAudioEnabled { channelCount = minChannels } #endif } else { channelCount = 2 } // 不在这里设置setPreferredOutputNumberOfChannels,因为这个方法会在获取音轨信息的时候,进行调用。 KSLog("[audio] outputNumberOfChannels: \(AVAudioSession.sharedInstance().outputNumberOfChannels) output channelCount: \(channelCount)") return channelCount } #endif } public enum LogLevel: Int32, CustomStringConvertible { case panic = 0 case fatal = 8 case error = 16 case warning = 24 case info = 32 case verbose = 40 case debug = 48 case trace = 56 public var description: String { switch self { case .panic: return "panic" case .fatal: return "fault" case .error: return "error" case .warning: return "warning" case .info: return "info" case .verbose: return "verbose" case .debug: return "debug" case .trace: return "trace" } } } public extension LogLevel { var logType: OSLogType { switch self { case .panic, .fatal: return .fault case .error: return .error case .warning: return .debug case .info, .verbose, .debug: return .info case .trace: return .default } } } public protocol LogHandler { @inlinable func log(level: LogLevel, message: CustomStringConvertible, file: String, function: String, line: UInt) } public class OSLog: LogHandler { public let label: String public init(lable: String) { label = lable } @inlinable public func log(level: LogLevel, message: CustomStringConvertible, file: String, function: String, line: UInt) { os_log(level.logType, "%@ %@: %@:%d %@ | %@", level.description, label, file, line, function, message.description) } } public class FileLog: LogHandler { public let fileHandle: FileHandle public let formatter = DateFormatter() public init(fileHandle: FileHandle) { self.fileHandle = fileHandle formatter.dateFormat = "MM-dd HH:mm:ss.SSSSSS" } @inlinable public func log(level: LogLevel, message: CustomStringConvertible, file: String, function: String, line: UInt) { let string = String(format: "%@ %@ %@:%d %@ | %@\n", formatter.string(from: Date()), level.description, file, line, function, message.description) if let data = string.data(using: .utf8) { fileHandle.write(data) } } } @inlinable public func KSLog(_ error: @autoclosure () -> Error, file: String = #file, function: String = #function, line: UInt = #line) { KSLog(level: .error, error().localizedDescription, file: file, function: function, line: line) } @inlinable public func KSLog(level: LogLevel = .warning, _ message: @autoclosure () -> CustomStringConvertible, file: String = #file, function: String = #function, line: UInt = #line) { if level.rawValue <= KSOptions.logLevel.rawValue { let fileName = (file as NSString).lastPathComponent KSOptions.logger.log(level: level, message: message(), file: fileName, function: function, line: line) } } @inlinable public func KSLog(level: LogLevel = .warning, dso: UnsafeRawPointer = #dsohandle, _ message: StaticString, _ args: CVarArg...) { if level.rawValue <= KSOptions.logLevel.rawValue { os_log(level.logType, dso: dso, message, args) } } public extension Array { func toDictionary(with selectKey: (Element) -> Key) -> [Key: Element] { var dict = [Key: Element]() forEach { element in dict[selectKey(element)] = element } return dict } } public struct KSClock { public private(set) var lastMediaTime = CACurrentMediaTime() public internal(set) var position = Int64(0) public internal(set) var time = CMTime.zero { didSet { lastMediaTime = CACurrentMediaTime() } } func getTime() -> TimeInterval { time.seconds + CACurrentMediaTime() - lastMediaTime } }