mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-26 10:37:14 +00:00
fix: media download UX and table row selection
- Fix: Remove aria2c from yt-dlp to allow native concurrent downloader to print progress - Fix: Use generic 'Fetching media data...' message instead of hardcoding YouTube - Fix: Remove onTapGesture from Table cells to restore native macOS SwiftUI row selection and use simultaneousGesture on the Table itself for double clicks
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Remove doubleClickableCell block
|
||||||
|
content = re.sub(r' private func doubleClickableCell.*? }\n\n', '', content, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# Remove doubleClickableCell wrappers from TableColumn
|
||||||
|
content = re.sub(r'doubleClickableCell\(for: item\) \{\n\s*(.*?)\n\s*\}', r'\1', content, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# Add simultaneousGesture to Table
|
||||||
|
table_end = content.find(' .environment(\\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)')
|
||||||
|
gesture = ''' .simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||||
|
if let id = selection.first, let item = controller.downloads.first(where: { $0.id == id }) {
|
||||||
|
performPrimaryAction(for: item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
'''
|
||||||
|
content = content[:table_end] + gesture + content[table_end:]
|
||||||
|
|
||||||
|
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
|
||||||
|
f.write(content)
|
||||||
@@ -39,7 +39,8 @@ struct AddDownloadsView: View {
|
|||||||
url: mediaURL,
|
url: mediaURL,
|
||||||
cookieSource: settings.mediaCookieSource,
|
cookieSource: settings.mediaCookieSource,
|
||||||
credentials: metadataCredentials(for: mediaURL),
|
credentials: metadataCredentials(for: mediaURL),
|
||||||
transferOptions: transferOptions
|
transferOptions: transferOptions,
|
||||||
|
onCancel: { dismiss() }
|
||||||
) { selectedFormat, metadata in
|
) { selectedFormat, metadata in
|
||||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||||
let ext = selectedFormat.outputExtension
|
let ext = selectedFormat.outputExtension
|
||||||
@@ -79,10 +80,12 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
Divider()
|
if detectedMediaURL == nil {
|
||||||
actionBar
|
Divider()
|
||||||
.padding(16)
|
actionBar
|
||||||
.background(.background)
|
.padding(16)
|
||||||
|
.background(.background)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
||||||
.onChange(of: linkText) { _, newValue in
|
.onChange(of: linkText) { _, newValue in
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ struct DownloadTable: View {
|
|||||||
|
|
||||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||||
TableColumn("File Name", value: \.fileName) { item in
|
TableColumn("File Name", value: \.fileName) { item in
|
||||||
doubleClickableCell(for: item) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
Image(systemName: item.category.symbolName)
|
Image(systemName: item.category.symbolName)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(categoryColor(for: item.category))
|
.foregroundStyle(categoryColor(for: item.category))
|
||||||
@@ -47,63 +46,55 @@ struct DownloadTable: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
|
||||||
.draggable(item.id.uuidString)
|
.draggable(item.id.uuidString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.width(min: 200, ideal: 340)
|
.width(min: 200, ideal: 340)
|
||||||
|
|
||||||
TableColumn("Size", value: \.sortableSize) { item in
|
TableColumn("Size", value: \.sortableSize) { item in
|
||||||
doubleClickableCell(for: item) {
|
Text(ByteFormatter.string(item.sizeBytes))
|
||||||
Text(ByteFormatter.string(item.sizeBytes))
|
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width(min: 80, ideal: 100)
|
.width(min: 80, ideal: 100)
|
||||||
|
|
||||||
TableColumn("Progress", value: \.progress) { item in
|
TableColumn("Progress", value: \.progress) { item in
|
||||||
doubleClickableCell(for: item) {
|
progressBarCell(for: item)
|
||||||
progressBarCell(for: item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width(min: 100, ideal: 115)
|
.width(min: 100, ideal: 115)
|
||||||
|
|
||||||
TableColumn("Status", value: \.status.rawValue) { item in
|
TableColumn("Status", value: \.status.rawValue) { item in
|
||||||
doubleClickableCell(for: item) {
|
statusCell(for: item)
|
||||||
statusCell(for: item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width(min: 115, ideal: 170)
|
.width(min: 115, ideal: 170)
|
||||||
|
|
||||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||||
doubleClickableCell(for: item) {
|
Text(item.displaySpeedText)
|
||||||
Text(item.displaySpeedText)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width(min: 70, ideal: 90)
|
.width(min: 70, ideal: 90)
|
||||||
|
|
||||||
TableColumn("ETA", value: \.displayETAText) { item in
|
TableColumn("ETA", value: \.displayETAText) { item in
|
||||||
doubleClickableCell(for: item) {
|
Text(item.displayETAText)
|
||||||
Text(item.displayETAText)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width(min: 70, ideal: 90)
|
.width(min: 70, ideal: 90)
|
||||||
|
|
||||||
TableColumn("Date Added", value: \.createdAt) { item in
|
TableColumn("Date Added", value: \.createdAt) { item in
|
||||||
doubleClickableCell(for: item) {
|
Text(formatted(item.createdAt))
|
||||||
Text(formatted(item.createdAt))
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width(min: 100, ideal: 155)
|
.width(min: 100, ideal: 155)
|
||||||
}
|
}
|
||||||
|
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||||
|
if let id = selection.first, let item = controller.downloads.first(where: { $0.id == id }) {
|
||||||
|
performPrimaryAction(for: item)
|
||||||
|
}
|
||||||
|
})
|
||||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||||
rowContextMenu(for: itemIDs)
|
rowContextMenu(for: itemIDs)
|
||||||
@@ -157,18 +148,6 @@ struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func doubleClickableCell<Content: View>(
|
|
||||||
for item: DownloadItem,
|
|
||||||
@ViewBuilder content: () -> Content
|
|
||||||
) -> some View {
|
|
||||||
content()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
performPrimaryAction(for: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performPrimaryAction(for item: DownloadItem) {
|
private func performPrimaryAction(for item: DownloadItem) {
|
||||||
if item.status == .completed {
|
if item.status == .completed {
|
||||||
openFile(item)
|
openFile(item)
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try process.run()
|
try process.run()
|
||||||
messageUpdate("Fetching YouTube data...")
|
messageUpdate("Fetching media data...")
|
||||||
outputPipe.fileHandleForWriting.closeFile()
|
outputPipe.fileHandleForWriting.closeFile()
|
||||||
errorPipe.fileHandleForWriting.closeFile()
|
errorPipe.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
@@ -157,15 +157,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
guard connections > 1 else { return }
|
guard connections > 1 else { return }
|
||||||
|
|
||||||
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
|
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
|
||||||
|
// Use yt-dlp's native concurrent downloader instead of aria2c to ensure progress parsing works via stdout
|
||||||
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"
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ enum MediaExtractionEngine {
|
|||||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||||
}
|
}
|
||||||
|
|
||||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--extractor-args", "youtube:player_client=ios,android"]
|
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--extractor-args", "youtube:player_client=ios,tv"]
|
||||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||||
args.append(url.absoluteString)
|
args.append(url.absoluteString)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct MediaInspectorInlineView: View {
|
|||||||
let cookieSource: BrowserCookieSource
|
let cookieSource: BrowserCookieSource
|
||||||
let credentials: DownloadCredentials?
|
let credentials: DownloadCredentials?
|
||||||
let transferOptions: DownloadTransferOptions
|
let transferOptions: DownloadTransferOptions
|
||||||
|
let onCancel: () -> Void
|
||||||
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
|
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
|
||||||
|
|
||||||
@ObservedObject private var engineManager = MediaEngineManager.shared
|
@ObservedObject private var engineManager = MediaEngineManager.shared
|
||||||
@@ -120,6 +121,11 @@ struct MediaInspectorInlineView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 16)
|
Spacer(minLength: 16)
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
|
||||||
Button("Extract") {
|
Button("Extract") {
|
||||||
if let selected = resolveSelectedOption() {
|
if let selected = resolveSelectedOption() {
|
||||||
onDownload(selected, metadata)
|
onDownload(selected, metadata)
|
||||||
|
|||||||
Reference in New Issue
Block a user