mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Delete declarations that Periphery reports unused on every platform that compiles them (iOS, macOS, tvOS), including nine views used only by their own previews. Platform-gated code still used on another platform, device- only MPV render paths, migration models, CloudKit record names, App Intents metadata and unused parameters are kept.
353 lines
14 KiB
Swift
353 lines
14 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/media-source streams (always include them, shown
|
|
// first). Local files are file URLs; WebDAV/SMB media-source streams
|
|
// are remote URLs that carry no resolution metadata — nothing else
|
|
// produces a non-adaptive remote stream without resolution, so that
|
|
// combination identifies them.
|
|
let downloadedStreams: [Stream] = streams.filter { (stream: Stream) -> Bool in
|
|
guard !stream.isAudioOnly else { return false }
|
|
if stream.url.isFileURL { return true }
|
|
let format = StreamFormat.detect(from: stream)
|
|
return stream.resolution == nil && format != .hls && format != .dash
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
var recommendedVideoStreams: [Stream] {
|
|
let allowSoftware = allowSoftwareDecodedFormats
|
|
return videoStreams.filter { (stream: Stream) -> Bool in
|
|
if stream.url.isFileURL { return true }
|
|
// Media-source streams (no resolution metadata, nil codec) must
|
|
// not be misclassified as software-decoded and hidden
|
|
if stream.resolution == nil { return true }
|
|
if stream.isMuxed { return true }
|
|
if allowSoftware { return true }
|
|
return !requiresSoftwareDecode(stream.videoCodec)
|
|
}
|
|
}
|
|
|
|
var otherVideoStreams: [Stream] {
|
|
if allowSoftwareDecodedFormats { return [] }
|
|
return videoStreams.filter { (stream: Stream) -> Bool in
|
|
if stream.url.isFileURL { return false }
|
|
if stream.resolution == nil { 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]
|
|
}
|
|
}
|