From ce5878957eda7a2da57ae168dce358e3b8ca62a4 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 2 Jul 2026 07:44:09 +0800 Subject: [PATCH] fix(iOS): fix recording, playback compatibility, and media library UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/HLSRecorder.swift | 29 +- Sources/MediaLibraryView.swift | 541 ++++++++++++++++++++++++++++----- Sources/PlayerBridge.swift | 37 ++- 3 files changed, 511 insertions(+), 96 deletions(-) diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift index 5bb7c06..71eddb1 100644 --- a/Sources/HLSRecorder.swift +++ b/Sources/HLSRecorder.swift @@ -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" + // 判断是否为 HLS(m3u8 扩展名或 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 } } diff --git a/Sources/MediaLibraryView.swift b/Sources/MediaLibraryView.swift index 7459838..5cb9c47 100644 --- a/Sources/MediaLibraryView.swift +++ b/Sources/MediaLibraryView.swift @@ -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 = [] - @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() { diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 3e81f28..21d4312 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -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") + } } }