Files
yattee/Yattee/Views/Player/QualitySelectorView+StreamHelpers.swift
2026-02-08 18:33:56 +01:00

412 lines
15 KiB
Swift

//
// QualitySelectorView+StreamHelpers.swift
// Yattee
//
// Stream filtering, sorting, and helper methods for QualitySelectorView.
//
import SwiftUI
extension QualitySelectorView {
// MARK: - Stream Categories
/// Adaptive streams (HLS, DASH) - auto quality selection.
/// Deduplicated by URL to avoid showing identical entries.
var adaptiveStreams: [Stream] {
let adaptive = streams.filter { (stream: Stream) -> Bool in
let format = StreamFormat.detect(from: stream)
return format == .hls || format == .dash
}
// Deduplicate by URL - keep first occurrence
var seenURLs: Set<URL> = []
return adaptive.filter { stream in
if seenURLs.contains(stream.url) {
return false
}
seenURLs.insert(stream.url)
return true
}
}
/// Video streams (both muxed and video-only), sorted with preferred quality first.
/// When showAdvancedStreamDetails is false, filters to best stream per resolution.
/// Downloaded streams (local files) are always included and shown first.
var videoStreams: [Stream] {
let maxRes: StreamResolution? = preferredQuality.maxResolution
// Separate downloaded streams (always include them, shown first)
let downloadedStreams: [Stream] = streams.filter { $0.url.isFileURL && !$0.isAudioOnly }
// Online streams need resolution to be shown
let onlineVideoStreams: [Stream] = streams
.filter { (stream: Stream) -> Bool in
!stream.url.isFileURL && !stream.isAudioOnly && stream.resolution != nil
}
.filter { (stream: Stream) -> Bool in
let format = StreamFormat.detect(from: stream)
return format != .hls && format != .dash
}
.sorted { (s1: Stream, s2: Stream) -> Bool in
// Preferred quality first
let s1Preferred: Bool = s1.resolution == maxRes
let s2Preferred: Bool = s2.resolution == maxRes
if s1Preferred != s2Preferred {
return s1Preferred
}
// Then by resolution (higher first)
let res1: StreamResolution = s1.resolution ?? .p360
let res2: StreamResolution = s2.resolution ?? .p360
if res1 != res2 {
return res1 > res2
}
// Within same resolution, better codec ranks higher
return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec)
}
// When advanced details are hidden, show only best stream per resolution
if !showAdvancedStreamDetails {
var bestByResolution: [Int: Stream] = [:]
for stream in onlineVideoStreams {
let height: Int = stream.resolution?.height ?? 0
if let existing = bestByResolution[height] {
if videoCodecPriority(stream.videoCodec) > videoCodecPriority(existing.videoCodec) {
bestByResolution[height] = stream
}
} else {
bestByResolution[height] = stream
}
}
let sortedOnlineStreams: [Stream] = bestByResolution.values.sorted { (s1: Stream, s2: Stream) -> Bool in
let s1Preferred: Bool = s1.resolution == maxRes
let s2Preferred: Bool = s2.resolution == maxRes
if s1Preferred != s2Preferred {
return s1Preferred
}
return (s1.resolution ?? .p360) > (s2.resolution ?? .p360)
}
return downloadedStreams + sortedOnlineStreams
}
return downloadedStreams + onlineVideoStreams
}
/// Recommended video streams (hardware-decodable codecs).
var recommendedVideoStreams: [Stream] {
videoStreams.filter { (stream: Stream) -> Bool in
if stream.url.isFileURL { return true }
if stream.isMuxed { return true }
return !requiresSoftwareDecode(stream.videoCodec)
}
}
/// Other video streams (software decode required).
var otherVideoStreams: [Stream] {
videoStreams.filter { (stream: Stream) -> Bool in
if stream.url.isFileURL { return false }
if stream.isMuxed { return false }
return requiresSoftwareDecode(stream.videoCodec)
}
}
/// Audio-only streams, deduplicated and sorted with preferred language first.
var audioStreams: [Stream] {
let allAudio: [Stream] = streams.filter { $0.isAudioOnly }
// When advanced details are hidden, show only best stream per language
if !showAdvancedStreamDetails {
var bestByLanguage: [String: Stream] = [:]
for stream in allAudio {
let lang: String = stream.audioLanguage ?? ""
if let existing = bestByLanguage[lang] {
if audioCodecPriority(stream.audioCodec) > audioCodecPriority(existing.audioCodec) {
bestByLanguage[lang] = stream
} else if audioCodecPriority(stream.audioCodec) == audioCodecPriority(existing.audioCodec) {
if (stream.bitrate ?? 0) > (existing.bitrate ?? 0) {
bestByLanguage[lang] = stream
}
}
} else {
bestByLanguage[lang] = stream
}
}
return bestByLanguage.values.sorted { (s1: Stream, s2: Stream) -> Bool in
if let preferred = preferredAudioLanguage {
let lang1: String = s1.audioLanguage ?? ""
let lang2: String = s2.audioLanguage ?? ""
let s1Preferred: Bool = lang1.hasPrefix(preferred)
let s2Preferred: Bool = lang2.hasPrefix(preferred)
if s1Preferred != s2Preferred {
return s1Preferred
}
} else {
if s1.isOriginalAudio != s2.isOriginalAudio {
return s1.isOriginalAudio
}
}
return (s1.audioLanguage ?? "") < (s2.audioLanguage ?? "")
}
}
// Full details mode: Group by language + codec
var bestByKey: [String: Stream] = [:]
for stream in allAudio {
let lang: String = stream.audioLanguage ?? ""
let codec: String = stream.audioCodec ?? ""
let key: String = "\(lang)|\(codec)"
if let existing = bestByKey[key] {
if (stream.bitrate ?? 0) > (existing.bitrate ?? 0) {
bestByKey[key] = stream
}
} else {
bestByKey[key] = stream
}
}
return bestByKey.values.sorted { (s1: Stream, s2: Stream) -> Bool in
// Preferred language or original audio first
if let preferred = preferredAudioLanguage {
let lang1: String = s1.audioLanguage ?? ""
let lang2: String = s2.audioLanguage ?? ""
let s1Preferred: Bool = lang1.hasPrefix(preferred)
let s2Preferred: Bool = lang2.hasPrefix(preferred)
if s1Preferred != s2Preferred {
return s1Preferred
}
} else {
if s1.isOriginalAudio != s2.isOriginalAudio {
return s1.isOriginalAudio
}
}
// Then by language alphabetically
let lang1: String = s1.audioLanguage ?? ""
let lang2: String = s2.audioLanguage ?? ""
if lang1 != lang2 {
return lang1 < lang2
}
// Better codec ranks higher
let codec1: Int = audioCodecPriority(s1.audioCodec)
let codec2: Int = audioCodecPriority(s2.audioCodec)
if codec1 != codec2 {
return codec1 > codec2
}
// Higher bitrate first
return (s1.bitrate ?? 0) > (s2.bitrate ?? 0)
}
}
/// Whether we need to show audio selection (video-only streams exist).
var hasVideoOnlyStreams: Bool {
videoStreams.contains { $0.isVideoOnly }
}
/// Best audio stream for auto-selection based on preferred language setting.
var defaultAudioStream: Stream? {
if let preferred = preferredAudioLanguage {
if let preferredStream = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
return preferredStream
}
}
if let originalStream = audioStreams.first(where: { $0.isOriginalAudio }) {
return originalStream
}
return audioStreams.first
}
/// Captions sorted with preferred language first.
var sortedCaptions: [Caption] {
captions.sorted { (c1: Caption, c2: Caption) -> Bool in
if let preferred = preferredSubtitlesLanguage {
let c1Preferred: Bool = c1.baseLanguageCode == preferred || c1.languageCode.hasPrefix(preferred)
let c2Preferred: Bool = c2.baseLanguageCode == preferred || c2.languageCode.hasPrefix(preferred)
if c1Preferred != c2Preferred {
return c1Preferred
}
}
return c1.displayName.localizedCaseInsensitiveCompare(c2.displayName) == .orderedAscending
}
}
// MARK: - Codec Helpers
/// Returns codec priority for sorting (higher = better).
func videoCodecPriority(_ codec: String?) -> Int {
HardwareCapabilities.shared.codecPriority(for: codec)
}
/// Returns audio codec priority for sorting.
func audioCodecPriority(_ codec: String?) -> Int {
guard let codec = codec?.lowercased() else { return 0 }
if codec.contains("opus") || codec.contains("aac") || codec.contains("mp4a") {
return 1
}
return 0
}
/// Whether a codec requires software decoding.
func requiresSoftwareDecode(_ codec: String?) -> Bool {
HardwareCapabilities.shared.codecPriority(for: codec) == 0
}
// MARK: - Audio Track Parsing
/// Parses audio track information from a stream.
func parseAudioTrackName(_ stream: Stream) -> AudioTrackInfo {
let isAutoDubbed: Bool = stream.audioTrackName?.contains("Auto-dubbed") == true
let isOriginal: Bool = stream.audioTrackName?.contains("Original") == true
let trackType: String?
if isAutoDubbed {
trackType = "AD"
} else if isOriginal {
trackType = "ORIGINAL"
} else {
trackType = nil
}
let language: String
if let lang = stream.audioLanguage {
let fullName: String = Locale.current.localizedString(forLanguageCode: lang) ?? lang.uppercased()
language = shortenRegionName(fullName)
} else if let name = stream.audioTrackName {
let cleaned: String = name
.replacingOccurrences(of: "(Auto-dubbed)", with: "")
.replacingOccurrences(of: "(Original)", with: "")
.trimmingCharacters(in: .whitespaces)
language = shortenRegionName(cleaned)
} else {
language = String(localized: "stream.audio.default")
}
return AudioTrackInfo(language: language, trackType: trackType)
}
/// Shortens region names in language strings.
private func shortenRegionName(_ name: String) -> String {
let regionMappings: [String: String] = [
"(United States)": "(US)",
"(United Kingdom)": "(UK)",
"(Germany)": "",
"(France)": "",
"(Spain)": "",
"(Italy)": "",
"(Japan)": "",
"(China)": "",
"(Brazil)": "",
"(Portugal)": "",
"(Russia)": "",
"(Korea)": ""
]
var shortened: String = name
for (full, short) in regionMappings {
shortened = shortened.replacingOccurrences(of: full, with: short)
}
return shortened.trimmingCharacters(in: .whitespaces)
}
// MARK: - Rate Helpers
/// Returns the previous playback rate, or nil if at minimum.
func previousRate() -> PlaybackRate? {
let allRates: [PlaybackRate] = PlaybackRate.allCases
guard let currentIndex = allRates.firstIndex(of: currentRate),
currentIndex > 0 else {
return nil
}
return allRates[currentIndex - 1]
}
/// Returns the next playback rate, or nil if at maximum.
func nextRate() -> PlaybackRate? {
let allRates: [PlaybackRate] = PlaybackRate.allCases
guard let currentIndex = allRates.firstIndex(of: currentRate),
currentIndex < allRates.count - 1 else {
return nil
}
return allRates[currentIndex + 1]
}
// MARK: - Formatting Helpers
/// Formats a file size from bytes.
func formatFileSize(_ bytes: Int64) -> String {
ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
/// Formats stream details (bitrate and file size).
func formatStreamDetails(bitrate: Int?, fileSize: String?) -> String {
var parts: [String] = []
if let bitrate {
parts.append(formatBitrate(bitrate))
}
if let fileSize {
parts.append(fileSize)
}
return parts.joined(separator: " · ")
}
/// Formats audio details (track type, bitrate, file size).
func formatAudioDetails(trackType: String?, bitrate: Int?, fileSize: String?) -> String {
var parts: [String] = []
if let trackType {
parts.append(trackType)
}
if let bitrate {
parts.append(formatBitrate(bitrate))
}
if let fileSize {
parts.append(fileSize)
}
return parts.joined(separator: " · ")
}
/// Formats a codec string for display.
func formatCodec(_ codec: String) -> String {
let lowercased: String = codec.lowercased()
if lowercased.contains("avc") || lowercased.contains("h264") {
return "H.264"
} else if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") {
return "HEVC"
} else if lowercased.contains("vp9") || lowercased.contains("vp09") {
return "VP9"
} else if lowercased.contains("av1") || lowercased.contains("av01") {
return "AV1"
}
return codec.uppercased()
}
/// Returns a color for a codec badge.
func codecColor(_ codec: String) -> Color {
let lowercased: String = codec.lowercased()
if lowercased.contains("av1") || lowercased.contains("av01") {
return .blue
} else if lowercased.contains("vp9") || lowercased.contains("vp09") {
return .orange
} else if lowercased.contains("avc") || lowercased.contains("h264") {
return .red
} else if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") {
return .green
}
return .gray
}
/// Formats a bitrate for display.
func formatBitrate(_ bitrate: Int) -> String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
} else {
return "\(bitrate / 1000) kbps"
}
}
}