Files
yattee/Yattee/Views/Video/DownloadQualitySheet.swift
Arkadiusz Fal 612dce6b9f Refactor views
2026-02-09 01:13:02 +01:00

989 lines
37 KiB
Swift

//
// DownloadQualitySheet.swift
// Yattee
//
// Sheet for selecting download quality, audio track, and subtitles before downloading a video.
//
import SwiftUI
#if !os(tvOS)
struct DownloadQualitySheet: View {
enum DownloadTab: String, CaseIterable {
case video
case audio
case subtitles
var label: String {
switch self {
case .video: String(localized: "player.quality.video")
case .audio: String(localized: "stream.audio")
case .subtitles: String(localized: "stream.subtitles")
}
}
}
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
let video: Video
var streams: [Stream] = []
var captions: [Caption] = []
var dislikeCount: Int?
@State private var selectedTab: DownloadTab = .video
@State private var selectedVideoStream: Stream?
@State private var selectedAudioStream: Stream?
@State private var selectedCaption: Caption?
@State private var isDownloading = false
@State private var errorMessage: String?
@State private var fetchedStreams: [Stream]?
@State private var fetchedCaptions: [Caption]?
@State private var fetchedStoryboards: [Storyboard]?
@State private var fetchedVideo: Video?
@State private var isLoadingStreams = false
private var availableStreams: [Stream] {
fetchedStreams ?? streams
}
private var availableCaptions: [Caption] {
fetchedCaptions ?? captions
}
/// Video to use for download - prefer fetched video with full author details
private var videoForDownload: Video {
fetchedVideo ?? video
}
/// Whether to show advanced stream details (codec, bitrate, size)
private var showAdvancedStreamDetails: Bool {
appEnvironment?.settingsManager.showAdvancedStreamDetails ?? false
}
/// The preferred audio language from settings
private var preferredAudioLanguage: String? {
appEnvironment?.settingsManager.preferredAudioLanguage
}
/// The preferred subtitles language from settings
private var preferredSubtitlesLanguage: String? {
appEnvironment?.settingsManager.preferredSubtitlesLanguage
}
/// The preferred video quality from settings
private var preferredQuality: VideoQuality {
appEnvironment?.settingsManager.preferredQuality ?? .auto
}
// MARK: - Stream Categories
/// Video streams (both muxed and video-only), sorted by quality
private var videoStreams: [Stream] {
let maxRes = preferredQuality.maxResolution
let allVideoStreams = availableStreams
.filter { !$0.isAudioOnly && $0.resolution != nil }
.filter {
let format = StreamFormat.detect(from: $0)
return format != .hls && format != .dash
}
.sorted { s1, s2 in
// Preferred quality first
let s1Preferred = s1.resolution == maxRes
let s2Preferred = s2.resolution == maxRes
if s1Preferred != s2Preferred {
return s1Preferred
}
// Then by resolution (higher first)
let res1 = s1.resolution ?? .p360
let res2 = s2.resolution ?? .p360
if res1 != res2 {
return res1 > res2
}
// Within same resolution, muxed streams first, then by codec quality
if s1.isMuxed != s2.isMuxed {
return s1.isMuxed
}
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 allVideoStreams {
let height = stream.resolution?.height ?? 0
if let existing = bestByResolution[height] {
// Prefer muxed, then better codec
if stream.isMuxed && !existing.isMuxed {
bestByResolution[height] = stream
} else if stream.isMuxed == existing.isMuxed &&
videoCodecPriority(stream.videoCodec) > videoCodecPriority(existing.videoCodec) {
bestByResolution[height] = stream
}
} else {
bestByResolution[height] = stream
}
}
return bestByResolution.values.sorted { s1, s2 in
let s1Preferred = s1.resolution == maxRes
let s2Preferred = s2.resolution == maxRes
if s1Preferred != s2Preferred {
return s1Preferred
}
return (s1.resolution ?? .p360) > (s2.resolution ?? .p360)
}
}
return allVideoStreams
}
/// Audio-only streams, deduplicated by language/codec
private var audioStreams: [Stream] {
let allAudio = availableStreams.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 = 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),
(stream.bitrate ?? 0) > (existing.bitrate ?? 0) {
bestByLanguage[lang] = stream
}
} else {
bestByLanguage[lang] = stream
}
}
return bestByLanguage.values.sorted { s1, s2 in
// Preferred language first
if let preferred = preferredAudioLanguage {
let s1Preferred = (s1.audioLanguage ?? "").hasPrefix(preferred)
let s2Preferred = (s2.audioLanguage ?? "").hasPrefix(preferred)
if s1Preferred != s2Preferred {
return s1Preferred
}
} else {
// Original audio first
if s1.isOriginalAudio != s2.isOriginalAudio {
return s1.isOriginalAudio
}
}
return (s1.audioLanguage ?? "") < (s2.audioLanguage ?? "")
}
}
// Full details: group by language + codec
var bestByKey: [String: Stream] = [:]
for stream in allAudio {
let lang = stream.audioLanguage ?? ""
let codec = stream.audioCodec ?? ""
let key = "\(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, s2 in
if let preferred = preferredAudioLanguage {
let s1Preferred = (s1.audioLanguage ?? "").hasPrefix(preferred)
let s2Preferred = (s2.audioLanguage ?? "").hasPrefix(preferred)
if s1Preferred != s2Preferred {
return s1Preferred
}
} else if s1.isOriginalAudio != s2.isOriginalAudio {
return s1.isOriginalAudio
}
let lang1 = s1.audioLanguage ?? ""
let lang2 = s2.audioLanguage ?? ""
if lang1 != lang2 { return lang1 < lang2 }
return audioCodecPriority(s1.audioCodec) > audioCodecPriority(s2.audioCodec)
}
}
/// Whether the selected video stream requires a separate audio track
private var requiresAudioTrack: Bool {
selectedVideoStream?.isVideoOnly == true
}
/// Whether we have any video-only streams that would need audio
private var hasVideoOnlyStreams: Bool {
videoStreams.contains { $0.isVideoOnly }
}
/// Best audio stream for auto-selection
private var defaultAudioStream: Stream? {
if let preferred = preferredAudioLanguage {
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
return match
}
}
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
return original
}
return audioStreams.first
}
/// Available tabs based on streams and captions
private var availableTabs: [DownloadTab] {
var tabs: [DownloadTab] = [.video]
if hasVideoOnlyStreams && !audioStreams.isEmpty {
tabs.append(.audio)
}
if !availableCaptions.isEmpty {
tabs.append(.subtitles)
}
return tabs
}
/// Whether the download button should be enabled
private var canDownload: Bool {
guard let video = selectedVideoStream else { return false }
if video.isVideoOnly && selectedAudioStream == nil {
return false
}
return !isDownloading
}
// MARK: - Codec Priority
private func videoCodecPriority(_ codec: String?) -> Int {
guard let codec = codec?.lowercased() else { return 0 }
if codec.contains("av1") || codec.contains("av01") { return 3 }
if codec.contains("vp9") || codec.contains("vp09") { return 2 }
if codec.contains("avc") || codec.contains("h264") { return 1 }
return 0
}
private func audioCodecPriority(_ codec: String?) -> Int {
guard let codec = codec?.lowercased() else { return 0 }
if codec.contains("opus") { return 2 }
if codec.contains("aac") || codec.contains("mp4a") { return 1 }
return 0
}
// MARK: - Body
var body: some View {
NavigationStack {
Group {
if isLoadingStreams {
loadingContent
} else {
mainContent
}
}
#if os(macOS)
.background(Color(nsColor: .windowBackgroundColor))
#else
.background(Color(.systemGroupedBackground))
#endif
.navigationTitle(String(localized: "download.selectQuality"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button {
startDownload()
} label: {
if isDownloading {
ProgressView()
.controlSize(.small)
} else {
Text(String(localized: "download.start"))
}
}
.disabled(!canDownload)
}
}
.onAppear {
// Pre-select streams
if selectedVideoStream == nil {
selectedVideoStream = videoStreams.first
}
if selectedAudioStream == nil {
selectedAudioStream = defaultAudioStream
}
// WebDAV/local folder videos use direct file URL, no API fetch needed
if video.isFromMediaSource {
// Always create a proper download stream for media sources
// The passed-in streams may have nil resolution and won't pass filters
Task {
await createStreamForMediaSource()
}
return
}
// For Yattee Server, always fetch proxy streams for faster LAN downloads
// even if playback streams were passed in (those point to YouTube CDN)
let isYatteeServer = appEnvironment?.instancesManager.instance(for: video)?.isYatteeServerInstance ?? false
if isYatteeServer || (streams.isEmpty && fetchedStreams == nil) {
Task {
await fetchStreamsAndCaptions()
}
} else if captions.isEmpty && fetchedCaptions == nil {
// Streams provided but no captions - fetch captions only
Task {
await fetchCaptionsOnly()
}
}
}
}
.presentationDetents([.medium, .large])
}
// MARK: - Main Content Views
@ViewBuilder
private var loadingContent: some View {
VStack(spacing: 16) {
Spacer()
ProgressView()
.controlSize(.large)
Text(String(localized: "download.selectQuality.loading"))
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var mainContent: some View {
ScrollView {
VStack(spacing: 16) {
if let error = errorMessage {
Text(error)
.foregroundStyle(.red)
.padding()
.background(Color.red.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// Tab picker
if availableTabs.count > 1 {
Picker("", selection: $selectedTab) {
ForEach(availableTabs, id: \.self) { tab in
Text(tab.label).tag(tab)
}
}
.pickerStyle(.segmented)
}
// Content based on selected tab
switch selectedTab {
case .video:
videoSection
case .audio:
audioSection
case .subtitles:
subtitlesSection
}
// Selection summary
if selectedVideoStream != nil {
selectionSummary
}
}
.padding()
}
}
// MARK: - Sections
@ViewBuilder
private var videoSection: some View {
if videoStreams.isEmpty {
ContentUnavailableView {
Label(String(localized: "download.noStreams.title"), systemImage: "exclamationmark.triangle")
} description: {
Text(String(localized: "download.noStreams.description"))
}
} else {
VStack(spacing: 0) {
ForEach(Array(videoStreams.enumerated()), id: \.element.url) { index, stream in
if index > 0 {
Divider()
}
videoStreamRow(stream)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
}
#if os(macOS)
.background(Color(nsColor: .controlBackgroundColor))
#else
.background(Color(.secondarySystemGroupedBackground))
#endif
.clipShape(RoundedRectangle(cornerRadius: 10))
if requiresAudioTrack {
Text(String(localized: "download.videoOnly.hint"))
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
}
}
@ViewBuilder
private var audioSection: some View {
if audioStreams.isEmpty {
ContentUnavailableView {
Label(String(localized: "download.noAudio.title"), systemImage: "speaker.slash")
}
} else {
VStack(spacing: 0) {
ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in
if index > 0 {
Divider()
}
audioStreamRow(stream)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
}
#if os(macOS)
.background(Color(nsColor: .controlBackgroundColor))
#else
.background(Color(.secondarySystemGroupedBackground))
#endif
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
@ViewBuilder
private var subtitlesSection: some View {
VStack(spacing: 0) {
// "None" option
captionRow(nil)
.padding(.vertical, 8)
.padding(.horizontal, 12)
ForEach(sortedCaptions) { caption in
Divider()
captionRow(caption)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
}
#if os(macOS)
.background(Color(nsColor: .controlBackgroundColor))
#else
.background(Color(.secondarySystemGroupedBackground))
#endif
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var sortedCaptions: [Caption] {
availableCaptions.sorted { c1, c2 in
if let preferred = preferredSubtitlesLanguage {
let c1Preferred = c1.baseLanguageCode == preferred || c1.languageCode.hasPrefix(preferred)
let c2Preferred = c2.baseLanguageCode == preferred || c2.languageCode.hasPrefix(preferred)
if c1Preferred != c2Preferred {
return c1Preferred
}
}
return c1.displayName.localizedCaseInsensitiveCompare(c2.displayName) == .orderedAscending
}
}
@ViewBuilder
private var selectionSummary: some View {
VStack(alignment: .leading, spacing: 8) {
Label(String(localized: "download.summary"), systemImage: "info.circle")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 4) {
if let video = selectedVideoStream {
HStack {
Text(String(localized: "download.summary.video"))
.foregroundStyle(.secondary)
Spacer()
Text("\(video.qualityLabel) \(video.isMuxed ? "(muxed)" : "")")
}
.font(.caption)
}
if requiresAudioTrack, let audio = selectedAudioStream {
HStack {
Text(String(localized: "download.summary.audio"))
.foregroundStyle(.secondary)
Spacer()
Text(formatAudioLabel(audio))
}
.font(.caption)
}
if let caption = selectedCaption {
HStack {
Text(String(localized: "download.summary.subtitles"))
.foregroundStyle(.secondary)
Spacer()
Text(caption.displayName)
}
.font(.caption)
}
}
.padding(12)
#if os(macOS)
.background(Color(nsColor: .unemphasizedSelectedContentBackgroundColor))
#else
.background(Color(.tertiarySystemGroupedBackground))
#endif
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Row Views
@ViewBuilder
private func videoStreamRow(_ stream: Stream) -> some View {
let isSelected = stream.url == selectedVideoStream?.url
let isPreferred = stream.resolution == preferredQuality.maxResolution
Button {
selectedVideoStream = stream
// If switching to muxed stream, clear audio selection requirement
// If switching to video-only, ensure audio is selected
if stream.isVideoOnly && selectedAudioStream == nil {
selectedAudioStream = defaultAudioStream
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if isPreferred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(stream.qualityLabel)
.font(.headline)
if showAdvancedStreamDetails {
if stream.isMuxed {
Text(String(localized: "stream.badge.muxed"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.green.opacity(0.2))
.foregroundStyle(.green)
.clipShape(Capsule())
} else if let codec = stream.videoCodec {
Text(formatCodec(codec))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(codecColor(codec).opacity(0.2))
.foregroundStyle(codecColor(codec))
.clipShape(Capsule())
}
}
}
if showAdvancedStreamDetails {
HStack(spacing: 4) {
if stream.isMuxed {
Image(systemName: "speaker.wave.2.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let size = stream.formattedFileSize {
Text(size)
.font(.caption)
.foregroundStyle(.secondary)
}
if let bitrate = stream.bitrate {
Text(formatBitrate(bitrate))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: showAdvancedStreamDetails ? nil : 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private func audioStreamRow(_ stream: Stream) -> some View {
let isSelected = stream.url == selectedAudioStream?.url
let isPreferred = preferredAudioLanguage.map { (stream.audioLanguage ?? "").hasPrefix($0) } ?? false
Button {
selectedAudioStream = stream
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if isPreferred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(formatAudioLabel(stream))
.font(.headline)
if showAdvancedStreamDetails, let codec = stream.audioCodec {
Text(codec.uppercased())
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.purple.opacity(0.2))
.foregroundStyle(.purple)
.clipShape(Capsule())
}
}
if showAdvancedStreamDetails {
HStack(spacing: 4) {
if stream.isOriginalAudio {
Text(String(localized: "stream.audio.original"))
.font(.caption2)
.foregroundStyle(.secondary)
}
if let bitrate = stream.bitrate {
Text(formatBitrate(bitrate))
.font(.caption)
.foregroundStyle(.secondary)
}
if let size = stream.formattedFileSize {
Text(size)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: showAdvancedStreamDetails ? nil : 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private func captionRow(_ caption: Caption?) -> some View {
let isSelected = caption?.id == selectedCaption?.id
let isPreferred = caption.map { cap in
preferredSubtitlesLanguage.map { cap.baseLanguageCode == $0 || cap.languageCode.hasPrefix($0) } ?? false
} ?? false
Button {
selectedCaption = caption
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if isPreferred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(caption?.displayName ?? String(localized: "stream.subtitles.none"))
.font(.headline)
if let caption, caption.isAutoGenerated {
Text(String(localized: "stream.subtitle.auto"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.2))
.foregroundStyle(.secondary)
.clipShape(Capsule())
}
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - Helpers
private func formatAudioLabel(_ stream: Stream) -> String {
if let lang = stream.audioLanguage {
return Locale.current.localizedString(forLanguageCode: lang) ?? lang.uppercased()
}
return stream.audioTrackName ?? String(localized: "stream.audio.default")
}
private func formatCodec(_ codec: String) -> String {
let lowercased = codec.lowercased()
if lowercased.contains("avc") || lowercased.contains("h264") { return "H.264" }
if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") { return "HEVC" }
if lowercased.contains("vp9") || lowercased.contains("vp09") { return "VP9" }
if lowercased.contains("av1") || lowercased.contains("av01") { return "AV1" }
return codec.uppercased()
}
private func codecColor(_ codec: String) -> Color {
let lowercased = codec.lowercased()
if lowercased.contains("av1") || lowercased.contains("av01") { return .blue }
if lowercased.contains("vp9") || lowercased.contains("vp09") { return .orange }
if lowercased.contains("avc") || lowercased.contains("h264") { return .red }
if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") { return .green }
return .gray
}
private func formatBitrate(_ bitrate: Int) -> String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
}
return "\(bitrate / 1000) kbps"
}
// MARK: - Actions
private func startDownload() {
guard let videoStream = selectedVideoStream,
let downloadManager = appEnvironment?.downloadManager else {
return
}
// Validate audio selection for video-only streams
if videoStream.isVideoOnly && selectedAudioStream == nil {
errorMessage = String(localized: "download.error.audioRequired")
return
}
isDownloading = true
errorMessage = nil
Task {
do {
let audioURL = videoStream.isVideoOnly ? selectedAudioStream?.url : nil
let audioLang = videoStream.isVideoOnly ? selectedAudioStream?.audioLanguage : nil
// For muxed streams, use the stream's audio codec; for video-only, use selected audio stream's codec
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : selectedAudioStream?.audioCodec
let audioBitrate = videoStream.isMuxed ? nil : selectedAudioStream?.bitrate
// Select highest quality storyboard for download
let preferredStoryboard = fetchedStoryboards?.highest()
LoggingService.shared.logDownload(
"[Downloads] Storyboard selection",
details: "fetched: \(fetchedStoryboards?.count ?? 0), preferred: \(preferredStoryboard?.width ?? 0)x\(preferredStoryboard?.height ?? 0), sheets: \(preferredStoryboard?.storyboardCount ?? 0)"
)
try await downloadManager.enqueue(
videoForDownload,
quality: videoStream.qualityLabel,
formatID: videoStream.format,
streamURL: videoStream.url,
audioStreamURL: audioURL,
captionURL: selectedCaption?.url,
audioLanguage: audioLang,
captionLanguage: selectedCaption?.languageCode,
httpHeaders: videoStream.httpHeaders,
storyboard: preferredStoryboard,
dislikeCount: dislikeCount,
videoCodec: videoStream.videoCodec,
audioCodec: audioCodec,
videoBitrate: videoStream.bitrate,
audioBitrate: audioBitrate
)
await MainActor.run {
dismiss()
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isDownloading = false
}
}
}
}
private func createStreamForMediaSource() async {
guard case .extracted(_, let originalURL) = video.id.source else { return }
var authHeaders: [String: String]?
// Get auth headers for WebDAV sources
if video.isFromWebDAV,
let sourceID = video.mediaSourceID,
let appEnvironment,
let source = appEnvironment.mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
let password = appEnvironment.mediaSourcesManager.password(for: source)
authHeaders = await appEnvironment.webDAVClient.authHeaders(for: source, password: password)
}
let fileExtension = originalURL.pathExtension.lowercased()
// Use a placeholder resolution so the stream passes the videoStreams filter
// Mark as muxed (has audio) since local files typically have both tracks
let stream = Stream(
url: originalURL,
resolution: .p1080, // Placeholder - actual resolution unknown
format: fileExtension.isEmpty ? "video" : fileExtension,
audioCodec: "aac", // Placeholder to mark as muxed
httpHeaders: authHeaders
)
await MainActor.run {
fetchedStreams = [stream]
selectedVideoStream = stream
}
}
private func fetchStreamsAndCaptions() async {
guard let appEnvironment,
let instance = appEnvironment.instancesManager.instance(for: video) else {
return
}
isLoadingStreams = true
errorMessage = nil
do {
// For Yattee Server, use proxy streams for faster LAN downloads
// Proxy URLs point to the server instead of YouTube CDN
let loadedVideo: Video
let loadedStreams: [Stream]
let loadedCaptions: [Caption]
let loadedStoryboards: [Storyboard]
if case .extracted(_, let originalURL) = video.id.source {
// Extracted videos need re-extraction via /api/v1/extract
let result = try await appEnvironment.contentService.extractURL(originalURL, instance: instance)
loadedVideo = result.video
loadedStreams = result.streams
loadedCaptions = result.captions
loadedStoryboards = []
} else {
let result = try await appEnvironment.contentService.videoWithProxyStreamsAndCaptionsAndStoryboards(
id: video.id.videoID,
instance: instance
)
loadedVideo = result.video
loadedStreams = result.streams
loadedCaptions = result.captions
loadedStoryboards = result.storyboards
}
await MainActor.run {
fetchedStreams = loadedStreams
fetchedVideo = loadedVideo
fetchedCaptions = loadedCaptions
fetchedStoryboards = loadedStoryboards
isLoadingStreams = false
// Pre-select streams after fetching
if selectedVideoStream == nil {
selectedVideoStream = videoStreams.first
}
if selectedAudioStream == nil {
selectedAudioStream = defaultAudioStream
}
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoadingStreams = false
}
}
}
private func fetchCaptionsOnly() async {
guard let appEnvironment,
let instance = appEnvironment.instancesManager.instance(for: video) else {
return
}
do {
let loadedCaptions = try await appEnvironment.contentService.captions(
videoID: video.id.videoID,
instance: instance
)
await MainActor.run {
fetchedCaptions = loadedCaptions
}
} catch {
// Silently fail - captions are optional
await MainActor.run {
fetchedCaptions = []
}
}
}
}
// MARK: - Preview
#Preview {
DownloadQualitySheet(
video: .preview,
streams: [
Stream(
url: URL(string: "https://example.com/video.mp4")!,
resolution: .p1080,
format: "mp4",
videoCodec: "avc1",
audioCodec: "mp4a",
fileSize: 500_000_000
),
Stream(
url: URL(string: "https://example.com/video_only.webm")!,
resolution: .p1080,
format: "webm",
videoCodec: "vp9",
fileSize: 400_000_000
),
Stream(
url: URL(string: "https://example.com/audio.m4a")!,
resolution: nil,
format: "m4a",
audioCodec: "mp4a",
fileSize: 50_000_000,
isAudioOnly: true,
audioLanguage: "en"
)
]
)
.appEnvironment(.preview)
}
#endif