mirror of
https://github.com/yattee/yattee.git
synced 2025-11-16 06:58:43 +00:00
Added comprehensive nil checks for stream resolution values across PlayerBackend, QualityProfile, and PlayerQueue to prevent crashes when streams have missing resolution metadata. Also added backend nil checks in PlayerQueue.
270 lines
11 KiB
Swift
270 lines
11 KiB
Swift
import CoreMedia
|
|
import Defaults
|
|
import Foundation
|
|
import Logging
|
|
#if !os(macOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
protocol PlayerBackend {
|
|
var suggestedPlaybackRates: [Double] { get }
|
|
var model: PlayerModel { get }
|
|
var controls: PlayerControlsModel { get }
|
|
var playerTime: PlayerTimeModel { get }
|
|
var networkState: NetworkStateModel { get }
|
|
|
|
var stream: Stream? { get set }
|
|
var video: Video? { get set }
|
|
var currentTime: CMTime? { get }
|
|
|
|
var loadedVideo: Bool { get }
|
|
var isLoadingVideo: Bool { get }
|
|
|
|
var hasStarted: Bool { get }
|
|
var isPaused: Bool { get }
|
|
var isPlaying: Bool { get }
|
|
var isSeeking: Bool { get }
|
|
var playerItemDuration: CMTime? { get }
|
|
|
|
var aspectRatio: Double { get }
|
|
var controlsUpdates: Bool { get }
|
|
|
|
var videoWidth: Double? { get }
|
|
var videoHeight: Double? { get }
|
|
|
|
func canPlay(_ stream: Stream) -> Bool
|
|
func canPlayAtRate(_ rate: Double) -> Bool
|
|
|
|
func playStream(
|
|
_ stream: Stream,
|
|
of video: Video,
|
|
preservingTime: Bool,
|
|
upgrading: Bool
|
|
)
|
|
|
|
func play()
|
|
func pause()
|
|
func togglePlay()
|
|
|
|
func stop()
|
|
|
|
func seek(to time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)?)
|
|
func seek(to seconds: Double, seekType: SeekType, completionHandler: ((Bool) -> Void)?)
|
|
|
|
func setRate(_ rate: Double)
|
|
|
|
func closeItem()
|
|
|
|
func closePiP()
|
|
|
|
func startMusicMode()
|
|
func stopMusicMode()
|
|
|
|
func getTimeUpdates()
|
|
func updateControls(completionHandler: (() -> Void)?)
|
|
func startControlsUpdates()
|
|
func stopControlsUpdates()
|
|
|
|
func didChangeTo()
|
|
|
|
func setNeedsNetworkStateUpdates(_ needsUpdates: Bool)
|
|
|
|
func setNeedsDrawing(_ needsDrawing: Bool)
|
|
func setSize(_ width: Double, _ height: Double)
|
|
|
|
func cancelLoads()
|
|
}
|
|
|
|
extension PlayerBackend {
|
|
var logger: Logger {
|
|
return Logger(label: "stream.yattee.player.backend")
|
|
}
|
|
|
|
func seek(to time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
|
model.seek.registerSeek(at: time, type: seekType, restore: currentTime)
|
|
seek(to: time, seekType: seekType, completionHandler: completionHandler)
|
|
}
|
|
|
|
func seek(to seconds: Double, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
|
let seconds = CMTime.secondsInDefaultTimescale(seconds)
|
|
model.seek.registerSeek(at: seconds, type: seekType, restore: currentTime)
|
|
seek(to: seconds, seekType: seekType, completionHandler: completionHandler)
|
|
}
|
|
|
|
func seek(relative time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
|
if let currentTime, let duration = playerItemDuration {
|
|
let seekTime = min(max(0, currentTime.seconds + time.seconds), duration.seconds)
|
|
model.seek.registerSeek(at: .secondsInDefaultTimescale(seekTime), type: seekType, restore: currentTime)
|
|
seek(to: seekTime, seekType: seekType, completionHandler: completionHandler)
|
|
}
|
|
}
|
|
|
|
func eofPlaybackModeAction() {
|
|
let loopAction = {
|
|
model.backend.seek(to: .zero, seekType: .loopRestart) { _ in
|
|
self.model.play()
|
|
}
|
|
}
|
|
|
|
guard model.playbackMode != .loopOne else {
|
|
loopAction()
|
|
return
|
|
}
|
|
|
|
switch model.playbackMode {
|
|
case .queue, .shuffle:
|
|
model.prepareCurrentItemForHistory(finished: true)
|
|
|
|
if model.queue.isEmpty {
|
|
#if os(tvOS)
|
|
if Defaults[.closeVideoOnEOF] {
|
|
if model.activeBackend == .appleAVPlayer {
|
|
model.avPlayerBackend.controller?.dismiss(animated: false)
|
|
}
|
|
model.resetQueue()
|
|
model.hide()
|
|
}
|
|
#else
|
|
if Defaults[.closeVideoOnEOF] {
|
|
model.resetQueue()
|
|
model.hide()
|
|
} else if Defaults[.exitFullscreenOnEOF], model.playingFullScreen {
|
|
model.exitFullScreen()
|
|
}
|
|
#endif
|
|
} else {
|
|
model.advanceToNextItem()
|
|
}
|
|
case .loopOne:
|
|
loopAction()
|
|
case .related:
|
|
guard let item = model.autoplayItem else { return }
|
|
model.resetAutoplay()
|
|
model.advanceToItem(item)
|
|
}
|
|
}
|
|
|
|
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
|
logger.info("Starting bestPlayable function")
|
|
logger.info("Total streams received: \(streams.count)")
|
|
logger.info("Max resolution allowed: \(String(describing: maxResolution.value))")
|
|
logger.info("Format order: \(formatOrder)")
|
|
|
|
// Filter out non-HLS streams and streams with resolution more than maxResolution
|
|
let nonHLSStreams = streams.filter {
|
|
let isHLS = $0.kind == .hls
|
|
// Check if the stream's resolution is within the maximum allowed resolution
|
|
// Safety: Ensure resolution exists before comparing
|
|
guard let streamResolution = $0.resolution else {
|
|
logger.info("Stream ID: \($0.id) has nil resolution, skipping")
|
|
return false
|
|
}
|
|
let isWithinResolution = streamResolution <= maxResolution.value
|
|
|
|
logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: streamResolution)) - Bitrate: \($0.bitrate ?? 0)")
|
|
logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)")
|
|
|
|
logger.info("video url: \($0.videoAsset?.url.absoluteString ?? "nil"), audio url: \($0.audioAsset?.url.absoluteString ?? "nil")")
|
|
return !isHLS && isWithinResolution
|
|
}
|
|
logger.info("Non-HLS streams after filtering: \(nonHLSStreams.count)")
|
|
|
|
// Find max resolution and bitrate from non-HLS streams
|
|
let bestResolutionStream = nonHLSStreams.max { $0.resolution < $1.resolution }
|
|
let bestBitrateStream = nonHLSStreams.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
|
|
|
logger.info("Best resolution stream: \(String(describing: bestResolutionStream?.id)) with resolution: \(String(describing: bestResolutionStream?.resolution))")
|
|
logger.info("Best bitrate stream: \(String(describing: bestBitrateStream?.id)) with bitrate: \(String(describing: bestBitrateStream?.bitrate))")
|
|
|
|
let bestResolution = bestResolutionStream?.resolution ?? maxResolution.value
|
|
let bestBitrate = bestBitrateStream?.bitrate ?? bestResolutionStream?.resolution.bitrate ?? maxResolution.value.bitrate
|
|
|
|
logger.info("Final best resolution selected: \(String(describing: bestResolution))")
|
|
logger.info("Final best bitrate selected: \(bestBitrate)")
|
|
|
|
let adjustedStreams = streams.map { stream in
|
|
if stream.kind == .hls {
|
|
logger.info("Adjusting HLS stream ID: \(stream.id)")
|
|
stream.resolution = bestResolution
|
|
stream.bitrate = bestBitrate
|
|
stream.format = .hls
|
|
} else if stream.kind == .stream {
|
|
logger.info("Adjusting non-HLS stream ID: \(stream.id)")
|
|
stream.format = .stream
|
|
}
|
|
return stream
|
|
}
|
|
|
|
let filteredStreams = adjustedStreams.filter { stream in
|
|
// Safety check: Ensure stream has a resolution
|
|
guard let streamResolution = stream.resolution else {
|
|
logger.info("Filtered stream ID: \(stream.id) has nil resolution, excluding")
|
|
return false
|
|
}
|
|
// Check if the stream's resolution is within the maximum allowed resolution
|
|
let isWithinResolution = streamResolution <= maxResolution.value
|
|
logger.info("Filtered stream ID: \(stream.id) - Is within max resolution: \(isWithinResolution)")
|
|
return isWithinResolution
|
|
}
|
|
|
|
logger.info("Filtered streams count after adjustments: \(filteredStreams.count)")
|
|
|
|
let bestStream = filteredStreams.max { lhs, rhs in
|
|
// Safety check: Ensure both streams have resolutions
|
|
guard let lhsResolution = lhs.resolution, let rhsResolution = rhs.resolution else {
|
|
logger.info("One or both streams missing resolution - LHS: \(lhs.id), RHS: \(rhs.id)")
|
|
// If lhs has no resolution, it's "less than" rhs (prefer rhs)
|
|
// If rhs has no resolution, it's "less than" lhs (prefer lhs)
|
|
return lhs.resolution == nil
|
|
}
|
|
|
|
if lhsResolution == rhsResolution {
|
|
guard let lhsFormat = QualityProfile.Format(rawValue: lhs.format.rawValue),
|
|
let rhsFormat = QualityProfile.Format(rawValue: rhs.format.rawValue)
|
|
else {
|
|
logger.info("Failed to extract lhsFormat or rhsFormat for streams \(lhs.id) and \(rhs.id)")
|
|
return false
|
|
}
|
|
|
|
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
|
|
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
|
|
|
|
logger.info("Comparing formats for streams \(lhs.id) and \(rhs.id) - LHS Format Index: \(lhsFormatIndex), RHS Format Index: \(rhsFormatIndex)")
|
|
|
|
return lhsFormatIndex > rhsFormatIndex
|
|
}
|
|
|
|
logger.info("Comparing resolutions for streams \(lhs.id) and \(rhs.id) - LHS Resolution: \(String(describing: lhsResolution)), RHS Resolution: \(String(describing: rhsResolution))")
|
|
|
|
return lhsResolution < rhsResolution
|
|
}
|
|
|
|
logger.info("Best stream selected: \(String(describing: bestStream?.id)) with resolution: \(String(describing: bestStream?.resolution)) and format: \(String(describing: bestStream?.format))")
|
|
|
|
return bestStream
|
|
}
|
|
|
|
func updateControls(completionHandler: (() -> Void)? = nil) {
|
|
logger.info("updating controls")
|
|
|
|
guard model.presentingPlayer, !model.controls.presentingOverlays else {
|
|
logger.info("ignored controls update")
|
|
completionHandler?()
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async(qos: .userInteractive) {
|
|
#if !os(macOS)
|
|
guard UIApplication.shared.applicationState != .background else {
|
|
logger.info("not performing controls updates in background")
|
|
completionHandler?()
|
|
return
|
|
}
|
|
#endif
|
|
PlayerTimeModel.shared.currentTime = self.currentTime ?? .zero
|
|
PlayerTimeModel.shared.duration = self.playerItemDuration ?? .zero
|
|
completionHandler?()
|
|
}
|
|
}
|
|
}
|