mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
feat: implement smart progressive disclosure UI and media extraction engine
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) ?? ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user