feat: SMB streaming via AVAssetResourceLoaderDelegate — no file copy, reads on-demand.

- 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
This commit is contained in:
yumoqing 2026-07-04 18:24:10 +08:00
parent b9e65f306a
commit b1a04526a2
3 changed files with 188 additions and 52 deletions

View File

@ -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))

View File

@ -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

View File

@ -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) {
// 取消请求,无需额外处理
}
}