// // 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