From b1a04526a23e6c142df76a17e4df83d0be686b4d Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 4 Jul 2026 18:24:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SMB=20streaming=20via=20AVAssetResource?= =?UTF-8?q?LoaderDelegate=20=E2=80=94=20no=20file=20copy,=20reads=20on-dem?= =?UTF-8?q?and.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SMBStreamingLoader: wraps smb:// as miniplayer-smb:// custom scheme - PlayerBridge.playSMBSource(): creates AVURLAsset with resource loader - iOS NetworkFileBrowser: tap SMB media → stream directly - Removed old FileManager.copyItem approach --- Sources/NetworkFileBrowser.swift | 58 ++------------ Sources/PlayerBridge.swift | 55 +++++++++++++ Sources/SMBStreamingLoader.swift | 127 +++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 52 deletions(-) create mode 100644 Sources/SMBStreamingLoader.swift diff --git a/Sources/NetworkFileBrowser.swift b/Sources/NetworkFileBrowser.swift index 24ad0be..6b5841c 100644 --- a/Sources/NetworkFileBrowser.swift +++ b/Sources/NetworkFileBrowser.swift @@ -264,8 +264,6 @@ struct NetworkFileBrowserIOS: View { @State private var isLoading = false @State private var streamURL = "" @State private var showFileImporter = false - @State private var copyingFile = false - @State private var copyProgress = "" var body: some View { NavigationView { @@ -274,15 +272,6 @@ struct NetworkFileBrowserIOS: View { } .navigationTitle(browseURL != nil ? "Browsing" : "Network Files") .navigationBarTitleDisplayMode(.inline) - .overlay { - if copyingFile { - Color.black.opacity(0.6).ignoresSafeArea() - VStack(spacing: 12) { - ProgressView() - Text(copyProgress).foregroundColor(.white) - } - } - } .toolbar { if browseURL != nil { ToolbarItem(placement: .navigationBarLeading) { @@ -430,9 +419,13 @@ struct NetworkFileBrowserIOS: View { private func handleSelect(_ item: NetworkFileItem) { if item.isDirectory { if let cur = browseURL { pathStack.append(cur) }; navigateTo(item.url) } else if item.isMediaFile { - // SMB 文件需要复制到本地,AVPlayer 不认 smb:// 协议 if item.url.scheme == "smb" { - copyFromSMBAndPlay(item) + bridge.playSMBSource(url: item.url, name: item.name) + // 同名 ASS + if item.hasMatchingASS { + bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass")) + } + isPresented = false } else { playMedia(item) } @@ -445,45 +438,6 @@ struct NetworkFileBrowserIOS: View { isPresented = false } - private func copyFromSMBAndPlay(_ item: NetworkFileItem) { - copyingFile = true - copyProgress = "Copying \(item.name)..." - - DispatchQueue.global(qos: .userInitiated).async { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let localURL = docs.appendingPathComponent(item.name) - - // 删除旧副本 - try? FileManager.default.removeItem(at: localURL) - - do { - try FileManager.default.copyItem(at: item.url, to: localURL) - - // 同时复制同名 ASS - var assLocal: URL? - if item.hasMatchingASS { - let assURL = item.url.deletingPathExtension().appendingPathExtension("ass") - let assDest = docs.appendingPathComponent(assURL.lastPathComponent) - if (try? FileManager.default.copyItem(at: assURL, to: assDest)) != nil { - assLocal = assDest - } - } - - DispatchQueue.main.async { - self.copyingFile = false - bridge.addItem(url: localURL, name: item.name, type: "network") - if let al = assLocal { bridge.loadExternalSubtitle(url: al) } - isPresented = false - } - } catch { - DispatchQueue.main.async { - self.copyingFile = false - self.connectError = "Copy failed: \(error.localizedDescription)" - } - } - } - } - private func fmtSize(_ s: Int64) -> String { if s < 1024 { return "\(s) B" }; if s < 1024*1024 { return String(format: "%.1f KB", Double(s)/1024) } if s < 1024*1024*1024 { return String(format: "%.1f MB", Double(s)/(1024*1024)) }; return String(format: "%.1f GB", Double(s)/(1024*1024*1024)) diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index f407eaf..2d816cd 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -523,12 +523,67 @@ final class PlayerBridge: ObservableObject { /// 存储当前加载的外部字幕文件 URL @Published var externalSubtitleURL: URL? + // SMB 流式加载器引用(保持存活) + private var smbLoader: SMBStreamingLoader? + func loadExternalSubtitle(url: URL) { externalSubtitleURL = url diagLog("External subtitle loaded: \(url.lastPathComponent)") showToast("📝 \(url.lastPathComponent)") } + /// SMB 文件流式播放 — 通过 AVAssetResourceLoader 逐块读取,不复制整个文件 + func playSMBSource(url smbURL: URL, name: String) { + let loader = SMBStreamingLoader(smbURL: smbURL) + self.smbLoader = loader // 保持引用防止被释放 + + let wrappedURL = loader.wrappedURL() + let asset = AVURLAsset(url: wrappedURL) + asset.resourceLoader.setDelegate(loader, queue: .global(qos: .userInitiated)) + + // 直接用 asset 创建 player item,绕过 addItem + let playerItem = AVPlayerItem(asset: asset) + player.replaceCurrentItem(with: playerItem) + #if os(iOS) + ensureAudioSession() + #endif + player.play() + isPlaying = true + + cachedDuration = 0; progressRatio = 0 + currentTimeText = "00:00"; totalTimeText = "00:00" + playerStatus = .loading; loadingElapsed = 0 + + loadingTimer?.invalidate() + loadingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + if case .loading = self.playerStatus { self.loadingElapsed += 1 } + else { self.loadingTimer?.invalidate(); self.loadingTimer = nil } + } + } + + itemStatusObserver?.invalidate() + itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in + DispatchQueue.main.async { + switch pi.status { + case .readyToPlay: + self?.playerStatus = .playing + self?.loadingTimer?.invalidate(); self?.loadingTimer = nil + self?.loadDuration(pi) + case .failed: + let err = pi.error; let msg = err?.localizedDescription ?? "Unknown" + self?.playerStatus = .error(msg) + self?.loadingTimer?.invalidate(); self?.loadingTimer = nil + self?.isPlaying = false + default: break + } + } + } + + showToast(L.nowPlaying(name)) + } + // MARK: - 网络文件浏览 func toggleNetworkBrowser() { showNetworkBrowser = true diff --git a/Sources/SMBStreamingLoader.swift b/Sources/SMBStreamingLoader.swift new file mode 100644 index 0000000..0bbe7ea --- /dev/null +++ b/Sources/SMBStreamingLoader.swift @@ -0,0 +1,127 @@ +import Foundation +import AVFoundation + +// MARK: - SMB 流式加载器 +/// 将 smb:// URL 包装为 miniplayer-smb:// 自定义协议, +/// AVPlayer 请求数据时逐块从 SMB 读取,无需复制整个文件。 + +final class SMBStreamingLoader: NSObject, AVAssetResourceLoaderDelegate { + + private var smbURL: URL + private var fileHandle: FileHandle? + private var contentLength: Int64 = 0 + private var contentType: String = "video/mp4" + private let readChunkSize = 256 * 1024 // 256KB chunks + + init(smbURL: URL) { + self.smbURL = smbURL + super.init() + + // 根据扩展名推断 content type + let ext = smbURL.pathExtension.lowercased() + switch ext { + case "mp4", "m4v": contentType = "video/mp4" + case "mov": contentType = "video/quicktime" + case "mkv": contentType = "video/x-matroska" + case "avi": contentType = "video/x-msvideo" + case "webm": contentType = "video/webm" + case "ts": contentType = "video/mp2t" + case "m3u8": contentType = "application/vnd.apple.mpegurl" + case "mp3", "mpeg": contentType = "audio/mpeg" + case "wav": contentType = "audio/wav" + case "m4a": contentType = "audio/mp4" + case "aac": contentType = "audio/aac" + default: contentType = "video/mp4" + } + } + + deinit { + try? fileHandle?.close() + } + + // 创建包装后的本地 URL + func wrappedURL() -> URL { + // 用自定义 scheme,path 部分编码原始 smb URL + let encoded = smbURL.absoluteString.data(using: .utf8)!.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return URL(string: "miniplayer-smb://stream/\(encoded)")! + } + + // 从 wrapped URL 解码原始 smb URL + static func decodeSMBURL(from wrapped: URL) -> URL? { + guard wrapped.scheme == "miniplayer-smb" else { return nil } + var encoded = wrapped.path.replacingOccurrences(of: "/stream/", with: "") + // 还原 base64 填充 + encoded = encoded + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - encoded.count % 4) % 4 + encoded += String(repeating: "=", count: padding) + guard let data = Data(base64Encoded: encoded), + let str = String(data: data, encoding: .utf8), + let url = URL(string: str) else { return nil } + return url + } + + // MARK: - AVAssetResourceLoaderDelegate + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + + // 延迟打开 FileHandle,避免阻塞 + if fileHandle == nil { + do { + fileHandle = try FileHandle(forReadingFrom: smbURL) + contentLength = Int64(try fileHandle!.seekToEnd()) + fileHandle!.seek(toFileOffset: 0) + } catch { + loadingRequest.finishLoading(with: error) + return true + } + } + + // 处理 content information request + if let infoRequest = loadingRequest.contentInformationRequest { + infoRequest.contentType = contentType + infoRequest.contentLength = contentLength + infoRequest.isByteRangeAccessSupported = true + } + + // 处理 data request + if let dataRequest = loadingRequest.dataRequest { + let offset = dataRequest.currentOffset > 0 + ? dataRequest.currentOffset + : (dataRequest.requestedOffset > 0 ? dataRequest.requestedOffset : 0) + let length = dataRequest.requestedLength > 0 + ? dataRequest.requestedLength + : readChunkSize + + do { + try fileHandle?.seek(toOffset: UInt64(offset)) + let data = fileHandle?.readData(ofLength: length) ?? Data() + + if !data.isEmpty { + dataRequest.respond(with: data) + loadingRequest.finishLoading() + } else { + // 读到末尾 + loadingRequest.finishLoading() + } + } catch { + loadingRequest.finishLoading(with: error) + } + } else { + // 没有 data request 只有 info request(预检) + loadingRequest.finishLoading() + } + + return true + } + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, + didCancel loadingRequest: AVAssetResourceLoadingRequest) { + // 取消请求,无需额外处理 + } +}