fix: media downloads connections, progress parsing, file size, and selection highlight

- Removed hardcoded 1 connection limit for media downloads
- Added support for aria2c progress parsing in media downloads
- Fixed missing file size after download completion by reading from disk
- Fixed row selection highlight in DownloadTable by using onTapGesture instead of simultaneousGesture
This commit is contained in:
nimbold
2026-06-07 22:28:23 +03:30
parent c139ac50f2
commit 1a2c59d243
10 changed files with 417 additions and 123 deletions
+1
View File
@@ -22,3 +22,4 @@ sparkle_private_key*
SparklePrivateKey*
private-key*
private_key*
yt-dlp
+1 -1
View File
@@ -80,7 +80,7 @@ struct AddDownloadsView: View {
fileName: fileName,
category: category,
destinationDirectory: overrideDirectory ?? settings.destinationDirectory(for: category),
connectionsPerServer: 1,
connectionsPerServer: Int(connectionsPerServer),
credentials: explicitCredentials(for: [mediaURL]) ?? settings.credentials(for: mediaURL),
checksum: transferOptions.checksum,
requestHeaders: transferOptions.requestHeaders,
+20
View File
@@ -36,6 +36,26 @@ enum BrowserCookieSource: String, Codable, CaseIterable, Sendable {
rawValue.lowercased()
}
}
var statusTitle: String {
switch self {
case .none:
"Not using browser cookies"
case .safari, .chrome, .firefox, .edge, .brave:
"Using \(rawValue) cookies"
}
}
var statusDetail: String {
switch self {
case .none:
"Restricted media may fail if the site requires login."
case .safari:
"yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access."
case .chrome, .firefox, .edge, .brave:
"yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them."
}
}
}
enum ProxyType: String, Codable, CaseIterable, Sendable {
+15 -1
View File
@@ -210,7 +210,6 @@ final class DownloadController: ObservableObject {
var item = item
item.fileName = FileClassifier.sanitizedFileName(item.fileName)
item.category = FileClassifier.category(forFileName: item.fileName)
item.connectionsPerServer = 1
item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID)
@@ -531,6 +530,15 @@ final class DownloadController: ObservableObject {
if item.mediaFormatSelector != nil {
Task {
do {
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Checking media add-ons..."
}
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Starting yt-dlp..."
}
let handle = try await mediaEngine.start(
item: item,
cookieSource: settings.mediaCookieSource,
@@ -633,6 +641,12 @@ final class DownloadController: ObservableObject {
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath),
let size = attr[.size] as? Int64 {
$0.sizeBytes = size
$0.bytesText = ByteFormatter.string(size)
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: item.fileName)
+18 -4
View File
@@ -72,10 +72,10 @@ struct DownloadTable: View {
TableColumn("Status", value: \.status.rawValue) { item in
doubleClickableCell(for: item) {
Text(item.status.rawValue)
statusCell(for: item)
}
}
.width(min: 80, ideal: 105)
.width(min: 115, ideal: 170)
TableColumn("Speed", value: \.displaySpeedText) { item in
doubleClickableCell(for: item) {
@@ -164,9 +164,9 @@ struct DownloadTable: View {
content()
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture(count: 2).onEnded {
.onTapGesture(count: 2) {
performPrimaryAction(for: item)
})
}
}
private func performPrimaryAction(for item: DownloadItem) {
@@ -177,6 +177,20 @@ struct DownloadTable: View {
}
}
@ViewBuilder
private func statusCell(for item: DownloadItem) -> some View {
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
if item.status == .downloading, !message.isEmpty {
Text(message)
.lineLimit(1)
.truncationMode(.tail)
} else {
Text(item.status.rawValue)
.lineLimit(1)
.truncationMode(.tail)
}
}
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
+133 -34
View File
@@ -55,7 +55,8 @@ final class MediaDownloadEngine: @unchecked Sendable {
let audioFormat = item.fileName.fileExtension(defaultValue: "mp3")
arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"])
} else {
arguments.append(contentsOf: ["--merge-output-format", "mp4"])
let mergeFormat = item.fileName.fileExtension(defaultValue: "mp4")
arguments.append(contentsOf: ["--merge-output-format", mergeFormat])
}
}
@@ -73,7 +74,9 @@ final class MediaDownloadEngine: @unchecked Sendable {
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
}
appendParallelDownloadArguments(to: &arguments, connectionsPerServer: item.connectionsPerServer)
arguments.append(item.url.absoluteString)
process.arguments = arguments
@@ -81,23 +84,20 @@ final class MediaDownloadEngine: @unchecked Sendable {
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
let parser = YTDLPProgressParser()
let errorBuffer = LockedDataBuffer()
let completionGate = CompletionGate(completion)
let outputHandler = YTDLPOutputHandler(
parser: parser,
progress: progress,
messageUpdate: messageUpdate
)
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
for line in text.split(whereSeparator: \.isNewline) {
let stringLine = String(line)
if let update = parser.parse(stringLine) {
progress(update)
} else if stringLine.contains("[Merger]") || stringLine.contains("[ExtractAudio]") {
messageUpdate("Processing Media...")
}
}
outputHandler.handle(text)
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
@@ -105,12 +105,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
guard !data.isEmpty else { return }
errorBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
for line in text.split(whereSeparator: \.isNewline) {
let stringLine = String(line)
if stringLine.contains("[Merger]") || stringLine.contains("[ExtractAudio]") {
messageUpdate("Processing Media...")
}
}
outputHandler.handle(text)
}
}
@@ -122,11 +117,12 @@ final class MediaDownloadEngine: @unchecked Sendable {
completionGate.complete(.success(()))
} else {
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
completionGate.complete(.failure(EngineError.launchFailed(errorString.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : errorString)))
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
}
}
try process.run()
messageUpdate("Fetching YouTube data...")
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
@@ -136,6 +132,94 @@ final class MediaDownloadEngine: @unchecked Sendable {
}
})
}
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
guard !message.isEmpty else {
return "Exit code \(status)"
}
if message.localizedCaseInsensitiveContains("Sign in to confirm") ||
message.localizedCaseInsensitiveContains("not a bot") ||
message.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
return "YouTube requires browser cookies for this video. Choose a browser in Settings > Engine, then retry."
}
if message.localizedCaseInsensitiveContains("n challenge solving failed") ||
message.localizedCaseInsensitiveContains("supported JavaScript runtime") {
return "YouTube challenge solving failed. Install Deno or Node, then retry."
}
return message
}
private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) {
let connections = min(max(connectionsPerServer, 1), 16)
guard connections > 1 else { return }
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
guard let aria2URL = Aria2DownloadEngine.findExecutable() else {
return
}
arguments.append(contentsOf: [
"--downloader", aria2URL.path,
"--downloader-args", "aria2c:-x \(connections) -s \(connections) -k 1M --file-allocation=none"
])
}
}
final class YTDLPOutputHandler: @unchecked Sendable {
private let parser: YTDLPProgressParser
private let progress: @Sendable (DownloadProgress) -> Void
private let messageUpdate: @Sendable (String) -> Void
init(
parser: YTDLPProgressParser,
progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void
) {
self.parser = parser
self.progress = progress
self.messageUpdate = messageUpdate
}
func handle(_ text: String) {
for line in text.split(whereSeparator: \.isNewline) {
let stringLine = String(line)
if let update = parser.parse(stringLine) {
progress(update)
messageUpdate("Downloading Media")
} else if let message = statusMessage(for: stringLine) {
messageUpdate(message)
}
}
}
private func statusMessage(for line: String) -> String? {
if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") {
return "Processing Media..."
}
if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") {
return "Fetching YouTube data..."
}
if line.contains("[info]") && line.localizedCaseInsensitiveContains("Downloading") {
return "Preparing media stream..."
}
if line.localizedCaseInsensitiveContains("Sign in to confirm") ||
line.localizedCaseInsensitiveContains("not a bot") ||
line.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
return "YouTube requires browser cookies"
}
if line.localizedCaseInsensitiveContains("n challenge solving failed") ||
line.localizedCaseInsensitiveContains("supported JavaScript runtime") {
return "YouTube challenge solver unavailable"
}
if line.contains("Destination:") {
return "Starting media download..."
}
return nil
}
}
private extension String {
@@ -152,20 +236,35 @@ final class YTDLPProgressParser: @unchecked Sendable {
private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
func parse(_ line: String) -> DownloadProgress? {
guard line.contains("[download]") && line.contains("%") else { return nil }
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
return DownloadProgress(
fraction: min(max(fraction, 0), 1),
bytesText: size,
speedText: speed,
etaText: eta,
connectionCount: 1
)
if line.contains("[download]") && line.contains("%") {
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
return DownloadProgress(
fraction: min(max(fraction, 0), 1),
bytesText: size,
speedText: speed,
etaText: eta,
connectionCount: 1
)
} else if line.contains("[#") && line.contains("DL:") {
let fraction = (Double(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"\(([\d.]+)%\)"#)) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)) ?? "-"
let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-"
let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
return DownloadProgress(
fraction: min(max(fraction, 0), 1),
bytesText: size,
speedText: speed,
etaText: eta,
connectionCount: cn
)
}
return nil
}
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
+50 -18
View File
@@ -75,33 +75,65 @@ final class MediaEngineManager: ObservableObject {
return try JSONDecoder().decode(GatekeeperConfig.self, from: data)
}
func ensureInstalled() async throws {
// Simple helper for the "Extract" button flow
// Fetches config and installs if not installed or out of date
func ensureInstalled(addons requiredAddons: Set<AddonType> = Set(AddonType.allCases)) async throws {
let config = try await fetchLatestConfig()
// Use task group to download both if needed
try await withThrowingTaskGroup(of: Void.self) { group in
if case .notInstalled = ytDlpState {
group.addTask { try await self.install(addon: .ytDlp, from: config) }
} else if case .failed = ytDlpState {
group.addTask { try await self.install(addon: .ytDlp, from: config) }
} else if case let .installed(version) = ytDlpState, let configVersion = config.ytDlp?.version, version != configVersion {
group.addTask { try await self.install(addon: .ytDlp, from: config) }
}
if case .notInstalled = ffmpegState {
group.addTask { try await self.install(addon: .ffmpeg, from: config) }
} else if case .failed = ffmpegState {
group.addTask { try await self.install(addon: .ffmpeg, from: config) }
} else if case let .installed(version) = ffmpegState, let configVersion = config.ffmpeg?.version, version != configVersion {
group.addTask { try await self.install(addon: .ffmpeg, from: config) }
for addon in requiredAddons where shouldInstall(addon: addon, config: config) {
group.addTask {
try await self.install(addon: addon, from: config)
}
}
try await group.waitForAll()
}
}
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) {
case .installed, .downloading:
return false
case .notInstalled, .failed:
return true
}
}
guard !missingAddons.isEmpty else { return }
try await ensureInstalled(addons: missingAddons)
}
private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool {
let state: AddonState
let configVersion: String?
switch addon {
case .ytDlp:
state = ytDlpState
configVersion = config.ytDlp?.version
case .ffmpeg:
state = ffmpegState
configVersion = config.ffmpeg?.version
}
switch state {
case .notInstalled, .failed:
return true
case .downloading:
return false
case .installed(let version):
guard let configVersion else { return false }
return version != configVersion
}
}
private func state(for addon: AddonType) -> AddonState {
switch addon {
case .ytDlp: return ytDlpState
case .ffmpeg: return ffmpegState
}
}
func install(addon: AddonType, from config: GatekeeperConfig) async throws {
setState(for: addon, to: .downloading(progress: 0))
+90 -39
View File
@@ -33,6 +33,7 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
let isAudioOnly: Bool
let symbol: String
let outputExtension: String
let detail: String
}
enum MediaExtractionEngine {
@@ -97,6 +98,8 @@ enum MediaExtractionEngine {
args.append(contentsOf: ["--cookies-from-browser", browserName])
}
appendJavaScriptRuntimeArguments(to: &args)
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
args.append(contentsOf: ["--add-header", header.headerLine])
}
@@ -110,14 +113,47 @@ enum MediaExtractionEngine {
args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password])
}
}
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
if let denoPath = executablePath(named: "deno", candidates: [
"/opt/homebrew/bin/deno",
"/usr/local/bin/deno"
]) {
args.append(contentsOf: ["--js-runtimes", "deno:\(denoPath)"])
}
if let nodePath = executablePath(named: "node", candidates: [
"/opt/homebrew/bin/node",
"/usr/local/bin/node",
"/usr/bin/node"
]) {
args.append(contentsOf: ["--js-runtimes", "node:\(nodePath)"])
}
}
private static func executablePath(named name: String, candidates: [String]) -> String? {
if let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return path
}
let pathEnvironment = ProcessInfo.processInfo.environment["PATH"] ?? ""
for directory in pathEnvironment.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(name).path
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
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"),
@@ -126,61 +162,76 @@ enum MediaExtractionEngine {
(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)
let availableResolutions = standardResolutions.filter { resolution, _ in
maxHeight == 0 || maxHeight >= resolution - 100
}
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
let videoContainers = [
("mp4", "MP4"),
("mkv", "MKV"),
("webm", "WebM")
]
for (height, qualityName) in videoQualities {
for (container, containerName) in videoContainers {
options.append(CleanFormatOption(
name: "Video \(name)",
formatSelector: "bestvideo[height<=\(res)]+bestaudio/best",
name: "\(qualityName) \(containerName)",
formatSelector: videoSelector(height: height, container: container),
isAudioOnly: false,
symbol: "play.tv.fill",
outputExtension: "mp4"
outputExtension: container,
detail: height == nil ? "Best available video" : "Up to \(qualityName)"
))
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",
outputExtension: "mp4"
))
} 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",
outputExtension: "mp4"
))
}
// 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.
formatSelector: "bestaudio/best",
isAudioOnly: true,
symbol: "music.note",
outputExtension: "mp3"
outputExtension: "mp3",
detail: "Converted with ffmpeg"
))
options.append(CleanFormatOption(
name: "Audio M4A",
formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "m4a"
outputExtension: "m4a",
detail: "Prefer native M4A"
))
options.append(CleanFormatOption(
name: "Audio Opus",
formatSelector: "bestaudio[ext=webm]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "opus",
detail: "Efficient audio"
))
return options
}
private static func videoSelector(height: Int?, container: String) -> String {
let filter = heightFilter(height)
switch container {
case "mp4":
return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)"
case "webm":
return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)"
default:
return "bestvideo\(filter)+bestaudio/best\(filter)"
}
}
private static func heightFilter(_ height: Int?) -> String {
guard let height else { return "" }
return "[height<=\(height)]"
}
}
private final class YTDLPMetadataProcess: @unchecked Sendable {
+76 -24
View File
@@ -17,6 +17,14 @@ struct MediaInspectorCard: View {
@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
@@ -54,9 +62,12 @@ struct MediaInspectorCard: View {
.font(.headline)
.foregroundStyle(.secondary)
} else {
Text(statusText)
.font(.headline)
.foregroundStyle(.secondary)
VStack(spacing: 6) {
Text(statusText)
.font(.headline)
.foregroundStyle(.secondary)
cookieStatusLabel
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -135,26 +146,15 @@ struct MediaInspectorCard: View {
.font(.caption.monospacedDigit())
.foregroundStyle(.tertiary)
}
cookieStatusLabel
}
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
}
}
formatPickerSection
Spacer()
@@ -174,6 +174,50 @@ struct MediaInspectorCard: View {
}
}
@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
@@ -185,14 +229,22 @@ struct MediaInspectorCard: View {
} label: {
VStack(spacing: 8) {
Image(systemName: option.symbol)
.font(.system(size: 24))
.font(.system(size: 22))
.foregroundStyle(isSelected ? .white : .accentColor)
Text(option.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(isSelected ? .white : .primary)
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(width: 100, height: 80)
.frame(maxWidth: .infinity, minHeight: 82)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(isSelected ? Color.accentColor : Color(NSColor.controlBackgroundColor))
@@ -215,9 +267,9 @@ struct MediaInspectorCard: View {
loadTask = Task {
do {
await MainActor.run {
statusText = "Checking Media Engine..."
statusText = "Checking yt-dlp..."
}
try await MediaEngineManager.shared.ensureInstalled()
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
guard !Task.isCancelled else { return }
await MainActor.run {
@@ -84,12 +84,23 @@ struct EngineSettingsPane: View {
Spacer()
}
Picker("Extract Cookies from", selection: $settings.mediaCookieSource) {
Picker("Browser Cookies", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source)
}
}
Text("Allows downloading restricted media from sites requiring login. Requires Full Disk Access for Safari.")
LabeledContent("Cookie Status") {
if settings.mediaCookieSource == .none {
Label(settings.mediaCookieSource.statusTitle, systemImage: "circle")
.foregroundStyle(.secondary)
} else {
Label(settings.mediaCookieSource.statusTitle, systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
Text(settings.mediaCookieSource.statusDetail)
.font(.caption)
.foregroundStyle(.secondary)
}