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 {
// schemepath 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) {
//
}
}