mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
412 lines
15 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|