fix(iOS): fix recording, playback compatibility, and media library UI

Recording:
- stopRecording() now only sets stopRequested=true, no longer cancels task
- Task exits polling loop naturally → merges TS segments → exports MP4
- System share sheet + Photos save shown on completion
- Added HEAD request to detect HLS Content-Type for non-.m3u8 URLs
- Faster exit when stopRequested && segments exist

Playback:
- Copy imported files to app Documents sandbox (security-scoped URLs expire after defer)
- Remove Referer header that blocks some streams; use iPhone UA only
- AVURLAsset options only for http/https schemes

Media Library UI (iOS):
- Replace NavigationSplitView with VStack + horizontal category chips
- Search bar at top, swipe actions for queue/delete
- scrollDismissesKeyboard(.interactively) on list
- Tap gesture sends resignFirstResponder to dismiss keyboard
- Compact rows with context menu, inline play button
- Close button in navigation bar
- presentationDetents(.medium) for tag/playlist sheets
This commit is contained in:
yumoqing 2026-07-02 07:44:09 +08:00
parent 88f1ecbe9c
commit ce5878957e
3 changed files with 511 additions and 96 deletions

View File

@ -58,23 +58,32 @@ final class HLSRecorder: ObservableObject {
}
func stopRecording() {
// cancel stopRequested 退
// MP4
stopRequested = true
recordTask?.cancel()
recordTask = nil
isRecording = false
timer?.invalidate()
}
// MARK: -
private func recordStream(url: URL) async throws -> URL {
// HLS
let isHLS = url.pathExtension.lowercased() == "m3u8"
// HLSm3u8 URL m3u8
let ext = url.pathExtension.lowercased()
let isHLS = ext == "m3u8"
|| url.absoluteString.contains(".m3u8")
|| ext == "m3u"
if isHLS {
return try await recordHLS(url: url)
} else {
// HEAD Content-Type
var req = URLRequest(url: url)
req.httpMethod = "HEAD"
if let (_, resp) = try? await URLSession.shared.data(for: req),
let httpResp = resp as? HTTPURLResponse,
let ct = httpResp.allHeaderFields["Content-Type"] as? String,
ct.contains("mpegurl") || ct.contains("m3u8") || ct.contains("apple.mpegurl") {
return try await recordHLS(url: url)
}
return try await downloadFile(url: url)
}
}
@ -167,14 +176,20 @@ final class HLSRecorder: ObservableObject {
}
}
// VOD stop
// VOD stop
if !isLive && newSegments.isEmpty {
break
}
if stopRequested && !downloadedSegments.isEmpty {
break
}
// 2 HLS segment duration 2-10s
if !stopRequested {
try await Task.sleep(nanoseconds: 2_000_000_000)
} else {
break
}
}

View File

@ -5,6 +5,7 @@ import SwiftUI
struct MediaLibraryView: View {
@ObservedObject var library: MediaLibrary
@ObservedObject var bridge: PlayerBridge
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var selectedSidebar: SidebarItem = .all
@ -14,15 +15,14 @@ struct MediaLibraryView: View {
@State private var tagInputItemID: String?
@State private var tagInputText = ""
@State private var newPlaylistName = ""
@State private var selectedItems: Set<String> = []
@State private var displayLimit: Int = 100 // 100
@State private var showUnavailable: Bool = false //
@State private var displayLimit: Int = 100
@State private var showUnavailable: Bool = false
enum SidebarItem: Hashable {
case all
case liked
case playlist(String) // playlist ID
case tag(String) // tag name
case playlist(String)
case tag(String)
}
// +
@ -38,7 +38,6 @@ struct MediaLibraryView: View {
case .tag(let tag):
result = library.search("", tag: tag)
}
//
if !showUnavailable {
result = result.filter { $0.availability != .forbidden && $0.availability != .unavailable }
}
@ -54,16 +53,373 @@ struct MediaLibraryView: View {
return result
}
//
private var displayedItems: [LibraryItem] {
Array(allFilteredItems.prefix(displayLimit))
}
var body: some View {
#if os(iOS)
iOSBody
#else
macOSBody
#endif
}
// MARK: - iOS
@ViewBuilder
private var iOSBody: some View {
NavigationStack {
VStack(spacing: 0) {
//
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
CategoryChip(label: L.allMedia, icon: "square.grid.2x2",
isSelected: selectedSidebar == .all) {
selectedSidebar = .all
}
CategoryChip(label: L.liked, icon: "heart.fill",
isSelected: selectedSidebar == .liked) {
selectedSidebar = .liked
}
ForEach(library.playlists) { pl in
CategoryChip(label: pl.name, icon: "music.note.list",
isSelected: selectedSidebar == .playlist(pl.id)) {
selectedSidebar = .playlist(pl.id)
}
}
ForEach(library.allTags, id: \.self) { tag in
CategoryChip(label: tag, icon: "tag",
isSelected: selectedSidebar == .tag(tag)) {
selectedSidebar = .tag(tag)
}
}
}
.padding(.horizontal, 12)
}
.padding(.vertical, 8)
//
HStack(spacing: 8) {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 14))
TextField(L.searchChannels, text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 14))
.submitLabel(.search)
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10).padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.12)))
//
Button {
showImportSheet = true
} label: {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 14))
}
.buttonStyle(.bordered)
.controlSize(.small)
Button {
bridge.importLocalToLibrary()
} label: {
Image(systemName: "folder.badge.plus")
.font(.system(size: 14))
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 12)
//
if library.isChecking {
VStack(spacing: 2) {
ProgressView(value: library.checkProgress)
.progressViewStyle(.linear)
HStack {
Text(library.checkStatusText)
.font(.caption2).foregroundColor(.secondary)
Spacer()
Button(L.cancelCheck) { library.cancelCheck() }
.font(.caption2)
}
}
.padding(.horizontal, 12).padding(.vertical, 4)
}
Divider()
//
if displayedItems.isEmpty {
VStack { Spacer()
Image(systemName: "tray")
.font(.system(size: 36))
.foregroundColor(.secondary.opacity(0.5))
Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult)
.foregroundColor(.secondary)
.padding(.top, 4)
Spacer() }
} else {
List {
ForEach(displayedItems) { item in
iOSLibraryRow(item: item)
.listRowSeparator(.visible)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
library.removeItem(item.id)
} label: {
Label(L.delete, systemImage: "trash")
}
Button {
if let url = URL(string: item.url) {
bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id)
}
} label: {
Label(L.addToQueue, systemImage: "text.badge.plus")
}
.tint(.blue)
}
.onAppear {
if item == displayedItems.last, allFilteredItems.count > displayLimit {
displayLimit += 100
}
}
}
}
.listStyle(.plain)
.scrollDismissesKeyboard(.interactively)
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
//
HStack(spacing: 8) {
Text("\(allFilteredItems.count) / \(library.count)")
.font(.caption).foregroundColor(.secondary)
Spacer()
if library.isChecking {
Button { } label: {
ProgressView().controlSize(.mini)
}
} else {
Button {
library.checkAllStreams()
} label: {
Image(systemName: "network")
.font(.caption)
}
.buttonStyle(.plain)
}
Button {
showUnavailable.toggle()
} label: {
Image(systemName: showUnavailable ? "eye.slash" : "eye")
.font(.caption)
.foregroundColor(showUnavailable ? .orange : .secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12).padding(.vertical, 6)
.background(.thinMaterial)
}
.navigationTitle(L.mediaLibrary)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(L.close) { dismiss() }
}
}
#endif
}
.sheet(isPresented: $showImportSheet) {
ImportM3UView(library: library, bridge: bridge)
}
.sheet(isPresented: $showNewPlaylistSheet) {
NavigationStack {
VStack(spacing: 16) {
TextField(L.playlistName, text: $newPlaylistName)
.textFieldStyle(.roundedBorder)
Spacer()
}
.padding(20)
.navigationTitle(L.newPlaylist)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L.cancel) { showNewPlaylistSheet = false; newPlaylistName = "" }
}
ToolbarItem(placement: .confirmationAction) {
Button(L.create) {
if !newPlaylistName.isEmpty {
_ = library.createPlaylist(name: newPlaylistName)
newPlaylistName = ""
showNewPlaylistSheet = false
}
}
.disabled(newPlaylistName.isEmpty)
}
}
}
.presentationDetents([.medium])
}
.sheet(isPresented: $showTagInput) {
NavigationStack {
VStack(spacing: 16) {
TextField(L.tagName, text: $tagInputText)
.textFieldStyle(.roundedBorder)
Spacer()
}
.padding(20)
.navigationTitle(L.addTag)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L.cancel) { showTagInput = false; tagInputText = "" }
}
ToolbarItem(placement: .confirmationAction) {
Button(L.add) {
if let id = tagInputItemID, !tagInputText.isEmpty {
for tag in tagInputText.split(separator: ",") {
library.addTag(String(tag.trimmingCharacters(in: .whitespaces)), to: id)
}
tagInputText = ""
showTagInput = false
}
}
.disabled(tagInputText.isEmpty)
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - iOS
private func iOSLibraryRow(item: LibraryItem) -> some View {
HStack(spacing: 10) {
// +
ZStack(alignment: .topTrailing) {
Image(systemName: iconForType(item.type))
.foregroundColor(.secondary)
.font(.system(size: 16))
.frame(width: 24, height: 24)
switch item.availability {
case .available:
Image(systemName: "circle.fill")
.foregroundColor(.green)
.font(.system(size: 6))
.offset(x: 2, y: -2)
case .forbidden:
Image(systemName: "lock.fill")
.foregroundColor(.red)
.font(.system(size: 8))
.offset(x: 4, y: -2)
case .unavailable:
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.system(size: 8))
.offset(x: 4, y: -2)
case .unknown:
EmptyView()
}
}
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.system(size: 14))
.lineLimit(1)
HStack(spacing: 4) {
if let group = item.group, !group.isEmpty {
Text(group)
.font(.caption2)
.padding(.horizontal, 4).padding(.vertical, 1)
.background(Capsule().fill(Color.blue.opacity(0.1)))
.foregroundColor(.blue)
}
ForEach(item.tags.prefix(3), id: \.self) { tag in
Text(tag)
.font(.system(size: 10))
.padding(.horizontal, 4).padding(.vertical, 1)
.background(Capsule().fill(Color.accentColor.opacity(0.1)))
}
}
}
Spacer()
//
Button {
library.toggleLike(item.id)
} label: {
Image(systemName: item.liked ? "heart.fill" : "heart")
.foregroundColor(item.liked ? .red : .secondary)
.font(.system(size: 14))
}
.buttonStyle(.plain)
//
Button {
bridge.playFromLibrary(item)
dismiss()
} label: {
Image(systemName: "play.circle.fill")
.foregroundColor(.accentColor)
.font(.system(size: 22))
}
.buttonStyle(.plain)
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.contextMenu {
Button(L.playNow) { bridge.playFromLibrary(item); dismiss() }
Button(L.addToQueue) {
if let url = URL(string: item.url) {
bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id)
}
}
Divider()
Button(L.addTag) {
tagInputItemID = item.id
showTagInput = true
}
if !item.tags.isEmpty {
Menu(L.removeTag) {
ForEach(item.tags, id: \.self) { tag in
Button(tag) { library.removeTag(tag, from: item.id) }
}
}
}
Divider()
Menu(L.addToPlaylist) {
ForEach(library.playlists) { pl in
Button(pl.name) { library.addToPlaylist(pl.id, itemID: item.id) }
}
}
Divider()
Button(L.delete, role: .destructive) { library.removeItem(item.id) }
}
}
// MARK: - macOS
@ViewBuilder
private var macOSBody: some View {
NavigationSplitView {
sidebarView
} detail: {
contentView
macOSContentView
}
.frame(minWidth: 650, minHeight: 450)
.onChange(of: selectedSidebar) { _ in displayLimit = 100 }
@ -103,7 +459,6 @@ struct MediaLibraryView: View {
Spacer()
Button(L.add) {
if let id = tagInputItemID, !tagInputText.isEmpty {
//
for tag in tagInputText.split(separator: ",") {
library.addTag(String(tag.trimmingCharacters(in: .whitespaces)), to: id)
}
@ -120,7 +475,7 @@ struct MediaLibraryView: View {
}
}
// MARK: -
// MARK: - macOS
private var sidebarView: some View {
List {
@ -174,13 +529,11 @@ struct MediaLibraryView: View {
.frame(minWidth: 180)
}
// MARK: -
// MARK: - macOS
private var contentView: some View {
private var macOSContentView: some View {
VStack(spacing: 0) {
//
HStack(spacing: 8) {
//
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
@ -200,7 +553,6 @@ struct MediaLibraryView: View {
Spacer()
// M3U
Button {
showImportSheet = true
} label: {
@ -209,7 +561,6 @@ struct MediaLibraryView: View {
.buttonStyle(.bordered)
.controlSize(.small)
//
Button {
bridge.importLocalToLibrary()
} label: {
@ -218,7 +569,6 @@ struct MediaLibraryView: View {
.buttonStyle(.bordered)
.controlSize(.small)
//
if !displayedItems.isEmpty {
Button {
addDisplayedToQueue()
@ -229,7 +579,6 @@ struct MediaLibraryView: View {
.controlSize(.small)
}
//
if library.isChecking {
Button {
library.cancelCheck()
@ -251,7 +600,6 @@ struct MediaLibraryView: View {
.controlSize(.small)
}
// /
Button {
showUnavailable.toggle()
} label: {
@ -265,7 +613,6 @@ struct MediaLibraryView: View {
}
.padding(.horizontal, 12).padding(.vertical, 8)
//
if library.isChecking {
VStack(spacing: 2) {
ProgressView(value: library.checkProgress)
@ -278,7 +625,6 @@ struct MediaLibraryView: View {
Divider()
//
if displayedItems.isEmpty {
VStack { Spacer()
Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult)
@ -291,7 +637,6 @@ struct MediaLibraryView: View {
showTagInput: $showTagInput,
tagInputItemID: $tagInputItemID)
.onAppear {
//
if item == displayedItems.last, allFilteredItems.count > displayLimit {
displayLimit += 100
}
@ -311,7 +656,6 @@ struct MediaLibraryView: View {
.listStyle(.inset)
}
//
HStack {
Text("\(displayedItems.count) \(L.itemsCount)")
.font(.caption).foregroundColor(.secondary)
@ -340,9 +684,41 @@ struct MediaLibraryView: View {
}
bridge.showToastMsg("\(displayedItems.count) \(L.addedToQueue)")
}
private func iconForType(_ type: String) -> String {
switch type {
case "stream": return "antenna.radiowaves.left.and.right"
case "file", "local": return "doc.fill"
default: return "link"
}
}
}
// MARK: -
// MARK: - iOS
struct CategoryChip: View {
let label: String
let icon: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
Image(systemName: icon).font(.system(size: 11))
Text(label).font(.system(size: 13))
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(
Capsule().fill(isSelected ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.08))
)
.foregroundColor(isSelected ? .accentColor : .primary)
}
.buttonStyle(.plain)
}
}
// MARK: - macOS
struct LibraryItemRow: View {
let item: LibraryItem
@ -353,7 +729,6 @@ struct LibraryItemRow: View {
var body: some View {
HStack(spacing: 10) {
//
Image(systemName: iconForType(item.type))
.foregroundColor(.secondary)
.frame(width: 20)
@ -382,7 +757,6 @@ struct LibraryItemRow: View {
Spacer()
//
switch item.availability {
case .available:
Image(systemName: "checkmark.circle.fill")
@ -403,7 +777,6 @@ struct LibraryItemRow: View {
EmptyView()
}
//
Button {
library.toggleLike(item.id)
} label: {
@ -412,7 +785,6 @@ struct LibraryItemRow: View {
}
.buttonStyle(.plain)
//
Button {
library.toggleDislike(item.id)
} label: {
@ -421,7 +793,6 @@ struct LibraryItemRow: View {
}
.buttonStyle(.plain)
//
Button {
bridge.playFromLibrary(item)
} label: {
@ -485,51 +856,54 @@ struct ImportM3UView: View {
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 16) {
Text(L.importM3U).font(.headline)
// URL
HStack {
TextField("https://example.com/playlist.m3u", text: $m3uURL)
.textFieldStyle(.roundedBorder)
Button(L.fetch) { fetchM3U() }
.disabled(m3uURL.isEmpty || isLoading)
}
if isLoading {
ProgressView(L.fetching)
}
if let error = errorMessage {
Text(error).foregroundColor(.red).font(.caption)
}
if !statusMessage.isEmpty {
Text(statusMessage).font(.caption).foregroundColor(.secondary)
}
//
if !parsedChannels.isEmpty {
NavigationStack {
VStack(spacing: 12) {
// URL
HStack {
Text("\(parsedChannels.count) \(L.channelsFound)")
.font(.caption).foregroundColor(.secondary)
Spacer()
Button(L.selectAll) {
selectedChannels = Set(parsedChannels.map { $0.url })
}
.font(.caption)
Button(L.deselectAll) { selectedChannels = [] }
.font(.caption)
TextField("https://example.com/playlist.m3u", text: $m3uURL)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
#endif
Button(L.fetch) { fetchM3U() }
.disabled(m3uURL.isEmpty || isLoading)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
if isLoading {
ProgressView(L.fetching)
}
if let error = errorMessage {
Text(error).foregroundColor(.red).font(.caption)
}
if !statusMessage.isEmpty {
Text(statusMessage).font(.caption).foregroundColor(.secondary)
}
//
if !parsedChannels.isEmpty {
HStack {
Text("\(parsedChannels.count) \(L.channelsFound)")
.font(.caption).foregroundColor(.secondary)
Spacer()
Button(L.selectAll) {
selectedChannels = Set(parsedChannels.map { $0.url })
}
.font(.caption)
Button(L.deselectAll) { selectedChannels = [] }
.font(.caption)
}
List {
ForEach(parsedChannels, id: \.url) { ch in
HStack {
Image(systemName: selectedChannels.contains(ch.url) ? "checkmark.square.fill" : "square")
.foregroundColor(selectedChannels.contains(ch.url) ? .accentColor : .secondary)
VStack(alignment: .leading) {
Text(ch.name).font(.system(size: 12)).lineLimit(1)
Text(ch.name).font(.system(size: 13)).lineLimit(1)
Text(ch.url).font(.system(size: 10)).foregroundColor(.secondary).lineLimit(1)
}
Spacer()
@ -537,7 +911,6 @@ struct ImportM3UView: View {
Text(group).font(.system(size: 10)).foregroundColor(.blue.opacity(0.7))
}
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.onTapGesture {
if selectedChannels.contains(ch.url) {
@ -548,28 +921,32 @@ struct ImportM3UView: View {
}
}
}
.listStyle(.plain)
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
.frame(maxHeight: 300)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.08)))
HStack {
Spacer()
}
.padding(16)
.navigationTitle(L.importM3U)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L.cancel) { dismiss() }
Spacer()
Button("\(L.importSelected) (\(selectedChannels.count))") {
importSelected()
}
.keyboardShortcut(.defaultAction)
.disabled(selectedChannels.isEmpty)
}
} else {
HStack {
Spacer()
Button(L.close) { dismiss() }
if !selectedChannels.isEmpty {
ToolbarItem(placement: .confirmationAction) {
Button("\(L.importSelected) (\(selectedChannels.count))") {
importSelected()
}
}
}
}
}
.padding(20)
.frame(width: 550, height: 500)
}
private func fetchM3U() {

View File

@ -224,11 +224,14 @@ final class PlayerBridge: ObservableObject {
itemStatusObserver = nil
// AVURLAsset HTTP headers HLS Referer/UA
let headers: [String: String] = [
"Referer": item.url.deletingLastPathComponent().absoluteString,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
]
let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
var assetOptions: [String: Any] = [:]
if item.url.scheme == "http" || item.url.scheme == "https" {
let headers: [String: String] = [
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
]
assetOptions["AVURLAssetHTTPHeaderFieldsKey"] = headers
}
let asset = AVURLAsset(url: item.url, options: assetOptions.isEmpty ? nil : assetOptions)
let playerItem = AVPlayerItem(asset: asset)
player.replaceCurrentItem(with: playerItem)
@ -681,12 +684,32 @@ final class PlayerBridge: ObservableObject {
/// iOS
func handleFileImport(_ urls: [URL]) {
for url in urls {
// iOS: startAccessingSecurityScopedResource 访
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
}
addItem(url: url, name: url.lastPathComponent, type: "file")
// app Documents security-scoped URL defer
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let localURL = docsDir.appendingPathComponent(url.lastPathComponent)
do {
//
if FileManager.default.fileExists(atPath: localURL.path) {
let ts = Int(Date().timeIntervalSince1970)
let nameWithoutExt = url.deletingPathExtension().lastPathComponent
let ext = url.pathExtension
let newURL = docsDir.appendingPathComponent("\(nameWithoutExt)_\(ts).\(ext)")
try FileManager.default.copyItem(at: url, to: newURL)
addItem(url: newURL, name: url.lastPathComponent, type: "file")
} else {
try FileManager.default.copyItem(at: url, to: localURL)
addItem(url: localURL, name: url.lastPathComponent, type: "file")
}
} catch {
// URL iCloud
addItem(url: url, name: url.lastPathComponent, type: "file")
}
}
}