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:
parent
b9e65f306a
commit
b1a04526a2
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
127
Sources/SMBStreamingLoader.swift
Normal file
127
Sources/SMBStreamingLoader.swift
Normal 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) {
|
||||
// 取消请求,无需额外处理
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user