feat: implement smart progressive disclosure UI and media extraction engine

This commit is contained in:
nimbold
2026-06-07 10:05:54 +03:30
parent 0799b08000
commit af05180ee6
4 changed files with 480 additions and 8 deletions
+62 -8
View File
@@ -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)
+28
View File
@@ -0,0 +1,28 @@
import Foundation
enum MediaDetector {
private static let supportedDomains: Set<String> = [
"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
}
}
@@ -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<Int>()
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
}
}
+224
View File
@@ -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) ?? ""
}
}