diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 6eab01f..72dfaca 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -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 } } diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index e282211..e7ea94c 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -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) diff --git a/Sources/Firelink/MediaInspectorCard.swift b/Sources/Firelink/MediaInspectorCard.swift deleted file mode 100644 index f1685c1..0000000 --- a/Sources/Firelink/MediaInspectorCard.swift +++ /dev/null @@ -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? - - 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) ?? "" - } -} diff --git a/Sources/Firelink/MediaInspectorInlineView.swift b/Sources/Firelink/MediaInspectorInlineView.swift new file mode 100644 index 0000000..0bf41cc --- /dev/null +++ b/Sources/Firelink/MediaInspectorInlineView.swift @@ -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? + + 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 } + } + } + } + } +}