mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
feat: improve yt-dlp fetching speed and redesign media detection UI
- Bypass YouTube web bot detection by using iOS/Android player clients in yt-dlp - Redesign media extraction UX to use a clean inline layout instead of replacing the entire Add window - Use dropdown Pickers for Type, Quality, and Format selection - Remove the obsolete MediaInspectorCard
This commit is contained in:
@@ -26,7 +26,6 @@ struct AddDownloadsView: View {
|
||||
@State private var authPassword = ""
|
||||
@State private var saveLogin = false
|
||||
|
||||
@State private var isMediaMode = false
|
||||
@State private var detectedMediaURL: URL? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -35,36 +34,8 @@ struct AddDownloadsView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
linkSection
|
||||
|
||||
if detectedMediaURL != nil, !isMediaMode {
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
||||
isMediaMode = true
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.yellow)
|
||||
Text("Media Detected: Extract Video / Audio")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.15))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
if isMediaMode, let mediaURL = detectedMediaURL {
|
||||
MediaInspectorCard(
|
||||
if let mediaURL = detectedMediaURL {
|
||||
MediaInspectorInlineView(
|
||||
url: mediaURL,
|
||||
cookieSource: settings.mediaCookieSource,
|
||||
credentials: metadataCredentials(for: mediaURL),
|
||||
@@ -94,43 +65,24 @@ struct AddDownloadsView: View {
|
||||
)
|
||||
|
||||
controller.addMediaDownload(item, startImmediately: true)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
.transition(.scale(scale: 0.95).combined(with: .opacity))
|
||||
} else {
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
}
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
|
||||
if detectedMediaURL != nil {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "sparkles.tv")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Media link detected. Click 'Extract Video / Audio' above to fetch available formats, or proceed to download the raw file.")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 160)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.quaternary.opacity(0.35))
|
||||
)
|
||||
} else {
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
if detectedMediaURL == nil {
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
if !isMediaMode {
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
||||
.onChange(of: linkText) { _, newValue in
|
||||
@@ -545,7 +497,6 @@ struct AddDownloadsView: View {
|
||||
} else {
|
||||
withAnimation {
|
||||
detectedMediaURL = nil
|
||||
isMediaMode = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ enum MediaExtractionEngine {
|
||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||
}
|
||||
|
||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist"]
|
||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--extractor-args", "youtube:player_client=ios,android"]
|
||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
args.append(url.absoluteString)
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MediaInspectorCard: View {
|
||||
let url: URL
|
||||
let cookieSource: BrowserCookieSource
|
||||
let credentials: DownloadCredentials?
|
||||
let transferOptions: DownloadTransferOptions
|
||||
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
|
||||
|
||||
@ObservedObject private var engineManager = MediaEngineManager.shared
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var statusText = "Checking Media Engine..."
|
||||
@State private var metadata: MediaMetadata?
|
||||
@State private var options: [CleanFormatOption] = []
|
||||
@State private var selectedOptionID: String?
|
||||
@State private var errorMessage: String?
|
||||
@State private var loadTask: Task<Void, Never>?
|
||||
|
||||
private var videoOptions: [CleanFormatOption] {
|
||||
options.filter { !$0.isAudioOnly }
|
||||
}
|
||||
|
||||
private var audioOptions: [CleanFormatOption] {
|
||||
options.filter(\.isAudioOnly)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Blurred Background
|
||||
if let thumbnail = metadata?.thumbnail {
|
||||
AsyncImage(url: thumbnail) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.blur(radius: 40)
|
||||
.scaleEffect(1.2)
|
||||
.opacity(0.4)
|
||||
} placeholder: {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
|
||||
VStack(spacing: 24) {
|
||||
if isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
|
||||
let ytState = engineManager.ytDlpState
|
||||
let ffState = engineManager.ffmpegState
|
||||
|
||||
if case let .downloading(p) = ytState, p > 0 {
|
||||
Text("Downloading yt-dlp: \(Int(p * 100))%")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case let .downloading(p) = ffState, p > 0 {
|
||||
Text("Downloading ffmpeg: \(Int(p * 100))%")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
Text(statusText)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
cookieStatusLabel
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Extraction Failed")
|
||||
.font(.headline)
|
||||
Text(errorMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Button("Retry") {
|
||||
loadMetadata()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let metadata {
|
||||
contentView(metadata: metadata)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
)
|
||||
.onAppear {
|
||||
loadMetadata()
|
||||
}
|
||||
.onDisappear {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contentView(metadata: MediaMetadata) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
// Header Info
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
if let thumbnail = metadata.thumbnail {
|
||||
AsyncImage(url: thumbnail) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 140, height: 90)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.2), radius: 4, y: 2)
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.quaternary)
|
||||
.frame(width: 140, height: 90)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(metadata.title ?? "Unknown Title")
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
|
||||
if let uploader = metadata.displayUploader {
|
||||
Text(uploader)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let duration = metadata.duration {
|
||||
Text(formatDuration(duration))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
cookieStatusLabel
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
formatPickerSection
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action
|
||||
Button {
|
||||
if let selected = options.first(where: { $0.id == selectedOptionID }) {
|
||||
onDownload(selected, metadata)
|
||||
}
|
||||
} label: {
|
||||
Text("Download")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(selectedOptionID == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cookieStatusLabel: some View {
|
||||
if let browserName = cookieSource.ytDlpBrowserName {
|
||||
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Browser cookies off", systemImage: "circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var formatPickerSection: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if !videoOptions.isEmpty {
|
||||
formatGroup(title: "Video Quality and Container", options: videoOptions)
|
||||
}
|
||||
|
||||
if !audioOptions.isEmpty {
|
||||
formatGroup(title: "Audio", options: audioOptions)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formatGroup(title: String, options: [CleanFormatOption]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 128), spacing: 10)], alignment: .leading, spacing: 10) {
|
||||
ForEach(options) { option in
|
||||
formatCard(for: option)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formatCard(for option: CleanFormatOption) -> some View {
|
||||
let isSelected = selectedOptionID == option.id
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
selectedOptionID = option.id
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: option.symbol)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(isSelected ? .white : .accentColor)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text(option.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(option.detail)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isSelected ? .white.opacity(0.8) : .secondary)
|
||||
}
|
||||
.foregroundStyle(isSelected ? .white : .primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 82)
|
||||
.padding(.horizontal, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(isSelected ? Color.accentColor : Color(NSColor.controlBackgroundColor))
|
||||
.shadow(color: isSelected ? .accentColor.opacity(0.3) : .black.opacity(0.1), radius: isSelected ? 8 : 2, y: 2)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.clear) : AnyShapeStyle(.quaternary), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scaleEffect(isSelected ? 1.05 : 1.0)
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
loadTask?.cancel()
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
await MainActor.run {
|
||||
statusText = "Checking yt-dlp..."
|
||||
}
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
statusText = "Fetching Metadata..."
|
||||
}
|
||||
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
|
||||
for: url,
|
||||
cookieSource: cookieSource,
|
||||
credentials: credentials,
|
||||
transferOptions: transferOptions
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.metadata = fetchedMetadata
|
||||
self.options = fetchedOptions
|
||||
self.selectedOptionID = fetchedOptions.first?.id
|
||||
self.loadTask = nil
|
||||
withAnimation {
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.loadTask = nil
|
||||
withAnimation {
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(_ duration: Double) -> String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = duration > 3600 ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.unitsStyle = .positional
|
||||
formatter.zeroFormattingBehavior = .pad
|
||||
return formatter.string(from: duration) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MediaInspectorInlineView: View {
|
||||
let url: URL
|
||||
let cookieSource: BrowserCookieSource
|
||||
let credentials: DownloadCredentials?
|
||||
let transferOptions: DownloadTransferOptions
|
||||
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
|
||||
|
||||
@ObservedObject private var engineManager = MediaEngineManager.shared
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var statusText = "Checking Media Engine..."
|
||||
@State private var metadata: MediaMetadata?
|
||||
@State private var options: [CleanFormatOption] = []
|
||||
@State private var errorMessage: String?
|
||||
@State private var loadTask: Task<Void, Never>?
|
||||
|
||||
enum MediaType: String, CaseIterable, Identifiable {
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@State private var selectedType: MediaType = .video
|
||||
@State private var selectedVideoQuality: String = "Best"
|
||||
@State private var selectedVideoFormat: String = "MP4"
|
||||
@State private var selectedAudioFormat: String = "MP3"
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
|
||||
let ytState = engineManager.ytDlpState
|
||||
let ffState = engineManager.ffmpegState
|
||||
|
||||
if case let .downloading(p) = ytState, p > 0 {
|
||||
Text("Downloading yt-dlp: \(Int(p * 100))%")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case let .downloading(p) = ffState, p > 0 {
|
||||
Text("Downloading ffmpeg: \(Int(p * 100))%")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(statusText)
|
||||
.foregroundStyle(.secondary)
|
||||
cookieStatusLabel
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
} else if let errorMessage {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button("Retry") {
|
||||
loadMetadata()
|
||||
}
|
||||
} else if let metadata {
|
||||
if let thumbnail = metadata.thumbnail {
|
||||
AsyncImage(url: thumbnail) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.quaternary)
|
||||
.frame(width: 80, height: 50)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(metadata.title ?? "Unknown Title")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Picker("Type", selection: $selectedType) {
|
||||
ForEach(availableTypes) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 80)
|
||||
|
||||
if selectedType == .video {
|
||||
Picker("Quality", selection: $selectedVideoQuality) {
|
||||
ForEach(availableVideoQualities, id: \.self) { q in
|
||||
Text(q).tag(q)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
|
||||
Picker("Format", selection: $selectedVideoFormat) {
|
||||
ForEach(availableVideoFormats, id: \.self) { f in
|
||||
Text(f).tag(f)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 80)
|
||||
} else {
|
||||
Picker("Format", selection: $selectedAudioFormat) {
|
||||
ForEach(availableAudioFormats, id: \.self) { f in
|
||||
Text(f).tag(f)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
Button("Extract") {
|
||||
if let selected = resolveSelectedOption() {
|
||||
onDownload(selected, metadata)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(resolveSelectedOption() == nil)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(.quaternary.opacity(0.35))
|
||||
)
|
||||
.onAppear {
|
||||
loadMetadata()
|
||||
}
|
||||
.onDisappear {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
}
|
||||
.onChange(of: selectedType) { _, _ in ensureValidSelection() }
|
||||
.onChange(of: options) { _, _ in ensureValidSelection() }
|
||||
}
|
||||
|
||||
private var availableTypes: [MediaType] {
|
||||
var types: [MediaType] = []
|
||||
if options.contains(where: { !$0.isAudioOnly }) { types.append(.video) }
|
||||
if options.contains(where: { $0.isAudioOnly }) { types.append(.audio) }
|
||||
return types
|
||||
}
|
||||
|
||||
private var availableVideoQualities: [String] {
|
||||
let qualities = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").first ?? "" }
|
||||
return NSOrderedSet(array: qualities).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private var availableVideoFormats: [String] {
|
||||
let formats = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").last ?? "" }
|
||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private var availableAudioFormats: [String] {
|
||||
let formats = options.filter { $0.isAudioOnly }.map { $0.name.replacingOccurrences(of: "Audio ", with: "") }
|
||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private func ensureValidSelection() {
|
||||
if !availableTypes.contains(selectedType), let first = availableTypes.first {
|
||||
selectedType = first
|
||||
}
|
||||
if selectedType == .video {
|
||||
if !availableVideoQualities.contains(selectedVideoQuality), let first = availableVideoQualities.first {
|
||||
selectedVideoQuality = first
|
||||
}
|
||||
if !availableVideoFormats.contains(selectedVideoFormat), let first = availableVideoFormats.first {
|
||||
selectedVideoFormat = first
|
||||
}
|
||||
} else {
|
||||
if !availableAudioFormats.contains(selectedAudioFormat), let first = availableAudioFormats.first {
|
||||
selectedAudioFormat = first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSelectedOption() -> CleanFormatOption? {
|
||||
if selectedType == .video {
|
||||
return options.first { !$0.isAudioOnly && $0.name == "\(selectedVideoQuality) \(selectedVideoFormat)" }
|
||||
} else {
|
||||
return options.first { $0.isAudioOnly && $0.name == "Audio \(selectedAudioFormat)" }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cookieStatusLabel: some View {
|
||||
if let browserName = cookieSource.ytDlpBrowserName {
|
||||
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Browser cookies off", systemImage: "circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
loadTask?.cancel()
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
await MainActor.run { statusText = "Checking yt-dlp..." }
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run { statusText = "Fetching Metadata..." }
|
||||
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
|
||||
for: url,
|
||||
cookieSource: cookieSource,
|
||||
credentials: credentials,
|
||||
transferOptions: transferOptions
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.metadata = fetchedMetadata
|
||||
self.options = fetchedOptions
|
||||
self.ensureValidSelection()
|
||||
self.loadTask = nil
|
||||
withAnimation { self.isLoading = false }
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.loadTask = nil
|
||||
withAnimation { self.isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user