From af05180ee6415d21f53cc4ba7cba32b16eab021b Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:05:54 +0330 Subject: [PATCH] feat: implement smart progressive disclosure UI and media extraction engine --- Sources/Firelink/AddDownloadsView.swift | 70 +++++- Sources/Firelink/MediaDetector.swift | 28 +++ Sources/Firelink/MediaExtractionEngine.swift | 166 ++++++++++++++ Sources/Firelink/MediaInspectorCard.swift | 224 +++++++++++++++++++ 4 files changed, 480 insertions(+), 8 deletions(-) create mode 100644 Sources/Firelink/MediaDetector.swift create mode 100644 Sources/Firelink/MediaExtractionEngine.swift create mode 100644 Sources/Firelink/MediaInspectorCard.swift diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 9033f93..fc367c3 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -26,22 +26,65 @@ 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 { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 12) { linkSection - optionsSection - advancedTransferSection - summarySection - previewSection + + 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(url: mediaURL) { selectedFormat in + print("Selected format: \(selectedFormat.name) with selector: \(selectedFormat.formatSelector)") + // TODO: Add to queue using MediaDownloadEngine + dismiss() + } + .transition(.scale(scale: 0.95).combined(with: .opacity)) + } else { + optionsSection + advancedTransferSection + summarySection + previewSection + } } .padding(12) } - Divider() - actionBar - .padding(16) - .background(.background) + if !isMediaMode { + Divider() + actionBar + .padding(16) + .background(.background) + } } .frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500) .onChange(of: linkText) { _, newValue in @@ -448,6 +491,17 @@ struct AddDownloadsView: View { private func refreshMetadata(for text: String, isAutoFetch: Bool) { let urls = DownloadURLParser.parse(text) metadataTask?.cancel() + + if let first = urls.first, MediaDetector.isSupportedMedia(url: first) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + detectedMediaURL = first + } + } else { + withAnimation { + detectedMediaURL = nil + isMediaMode = false + } + } pendingDownloads = urls.map { url in let fileName = FileClassifier.fileName(from: url) diff --git a/Sources/Firelink/MediaDetector.swift b/Sources/Firelink/MediaDetector.swift new file mode 100644 index 0000000..d95bd65 --- /dev/null +++ b/Sources/Firelink/MediaDetector.swift @@ -0,0 +1,28 @@ +import Foundation + +enum MediaDetector { + private static let supportedDomains: Set = [ + "youtube.com", "youtu.be", + "twitter.com", "x.com", + "vimeo.com", + "twitch.tv", + "instagram.com", + "tiktok.com", + "facebook.com", "fb.watch", + "reddit.com", "v.redd.it", + "soundcloud.com" + ] + + static func isSupportedMedia(url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + + for domain in supportedDomains { + if host == domain || host.hasSuffix(".\(domain)") { + // Ignore raw files that happen to be hosted on these domains, if any, + // though usually these domains serve web pages for media. + return true + } + } + return false + } +} diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift new file mode 100644 index 0000000..a69a5d7 --- /dev/null +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -0,0 +1,166 @@ +import Foundation + +struct RawMediaFormat: Decodable, Sendable { + let format_id: String? + let ext: String? + let resolution: String? + let format_note: String? + let vcodec: String? + let acodec: String? + let height: Int? + let filesize: Int64? + let filesize_approx: Int64? +} + +struct MediaMetadata: Decodable, Sendable { + let id: String? + let title: String? + let uploader: String? + let channel: String? + let thumbnail: URL? + let duration: Double? + let formats: [RawMediaFormat]? + + var displayUploader: String? { + channel ?? uploader + } +} + +struct CleanFormatOption: Identifiable, Equatable, Sendable { + var id: String { name } + let name: String + let formatSelector: String + let isAudioOnly: Bool + let symbol: String +} + +enum MediaExtractionEngine { + enum ExtractionError: Error, LocalizedError { + case processFailed(String) + case invalidOutput + case parsingFailed(Error) + + var errorDescription: String? { + switch self { + case .processFailed(let msg): return "Extraction failed: \(msg)" + case .invalidOutput: return "Invalid output from media engine." + case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)" + } + } + } + + static func fetchMetadata(for url: URL) async throws -> (MediaMetadata, [CleanFormatOption]) { + let ytDlpPath = await MediaEngineManager.shared.binaryPath(for: .ytDlp).path + guard FileManager.default.fileExists(atPath: ytDlpPath) else { + throw ExtractionError.processFailed("yt-dlp binary not found.") + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: ytDlpPath) + process.arguments = ["-J", "--no-warnings", url.absoluteString] + + let pipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = pipe + process.standardError = errorPipe + + return try await withCheckedThrowingContinuation { continuation in + do { + try process.run() + } catch { + continuation.resume(throwing: ExtractionError.processFailed(error.localizedDescription)) + return + } + + process.terminationHandler = { p in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + + if p.terminationStatus != 0 { + let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + continuation.resume(throwing: ExtractionError.processFailed(errorString)) + return + } + + guard !data.isEmpty else { + continuation.resume(throwing: ExtractionError.invalidOutput) + return + } + + do { + let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data) + let options = extractOptions(from: metadata) + continuation.resume(returning: (metadata, options)) + } catch { + continuation.resume(throwing: ExtractionError.parsingFailed(error)) + } + } + } + } + + private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] { + var options: [CleanFormatOption] = [] + let rawFormats = metadata.formats ?? [] + + let heights = rawFormats.compactMap { $0.height }.filter { $0 > 0 } + let maxHeight = heights.max() ?? 0 + + let standardResolutions = [ + (2160, "4K"), + (1440, "1440p"), + (1080, "1080p"), + (720, "720p"), + (480, "480p"), + (360, "360p") + ] + + var addedResolutions = Set() + + for (res, name) in standardResolutions { + if maxHeight >= res - 100 && !addedResolutions.contains(res) { // -100 for some leeway (e.g., 1000 instead of 1080) + options.append(CleanFormatOption( + name: "Video \(name)", + formatSelector: "bestvideo[height<=\(res)]+bestaudio/best", + isAudioOnly: false, + symbol: "play.tv.fill" + )) + addedResolutions.insert(res) + } + } + + if options.isEmpty && maxHeight > 0 { + // Fallback if no standard resolution matched + options.append(CleanFormatOption( + name: "Best Video", + formatSelector: "bestvideo+bestaudio/best", + isAudioOnly: false, + symbol: "play.tv.fill" + )) + } else if options.isEmpty { + // If we really don't have height info, just offer best + options.append(CleanFormatOption( + name: "Default Video", + formatSelector: "best", + isAudioOnly: false, + symbol: "play.tv.fill" + )) + } + + // Add Audio options + options.append(CleanFormatOption( + name: "Audio MP3", + formatSelector: "bestaudio/best", // Actual extraction to MP3 needs ffmpeg, which we have. We will handle the conversion flags later in the download engine. + isAudioOnly: true, + symbol: "music.note" + )) + + options.append(CleanFormatOption( + name: "Audio M4A", + formatSelector: "bestaudio[ext=m4a]/bestaudio/best", + isAudioOnly: true, + symbol: "waveform" + )) + + return options + } +} diff --git a/Sources/Firelink/MediaInspectorCard.swift b/Sources/Firelink/MediaInspectorCard.swift new file mode 100644 index 0000000..42cceae --- /dev/null +++ b/Sources/Firelink/MediaInspectorCard.swift @@ -0,0 +1,224 @@ +import SwiftUI + +struct MediaInspectorCard: View { + let url: URL + let onDownload: (CleanFormatOption) -> Void + + @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? + + 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) + Text(statusText) + .font(.headline) + .foregroundStyle(.secondary) + } + .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() + } + } + + @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) + } + } + Spacer() + } + + Divider() + + // Format Picker + VStack(alignment: .leading, spacing: 12) { + Text("Select Format") + .font(.subheadline.weight(.semibold)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(options) { option in + formatCard(for: option) + } + } + .padding(.bottom, 4) // For shadow + } + } + + Spacer() + + // Action + Button { + if let selected = options.first(where: { $0.id == selectedOptionID }) { + onDownload(selected) + } + } label: { + Text("Download") + .font(.headline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(selectedOptionID == nil) + } + } + + @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: 24)) + .foregroundStyle(isSelected ? .white : .accentColor) + + Text(option.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(isSelected ? .white : .primary) + } + .frame(width: 100, height: 80) + .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() { + isLoading = true + errorMessage = nil + + Task { + do { + statusText = "Checking Media Engine..." + try await MediaEngineManager.shared.ensureInstalled() + + statusText = "Fetching Metadata..." + let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(for: url) + + await MainActor.run { + self.metadata = fetchedMetadata + self.options = fetchedOptions + self.selectedOptionID = fetchedOptions.first?.id + withAnimation { + self.isLoading = false + } + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + 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) ?? "" + } +}