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 isLoading = false
|
||||||
@State private var streamURL = ""
|
@State private var streamURL = ""
|
||||||
@State private var showFileImporter = false
|
@State private var showFileImporter = false
|
||||||
@State private var copyingFile = false
|
|
||||||
@State private var copyProgress = ""
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
@ -274,15 +272,6 @@ struct NetworkFileBrowserIOS: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(browseURL != nil ? "Browsing" : "Network Files")
|
.navigationTitle(browseURL != nil ? "Browsing" : "Network Files")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.overlay {
|
|
||||||
if copyingFile {
|
|
||||||
Color.black.opacity(0.6).ignoresSafeArea()
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
ProgressView()
|
|
||||||
Text(copyProgress).foregroundColor(.white)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if browseURL != nil {
|
if browseURL != nil {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
@ -430,9 +419,13 @@ struct NetworkFileBrowserIOS: View {
|
|||||||
private func handleSelect(_ item: NetworkFileItem) {
|
private func handleSelect(_ item: NetworkFileItem) {
|
||||||
if item.isDirectory { if let cur = browseURL { pathStack.append(cur) }; navigateTo(item.url) }
|
if item.isDirectory { if let cur = browseURL { pathStack.append(cur) }; navigateTo(item.url) }
|
||||||
else if item.isMediaFile {
|
else if item.isMediaFile {
|
||||||
// SMB 文件需要复制到本地,AVPlayer 不认 smb:// 协议
|
|
||||||
if item.url.scheme == "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 {
|
} else {
|
||||||
playMedia(item)
|
playMedia(item)
|
||||||
}
|
}
|
||||||
@ -445,45 +438,6 @@ struct NetworkFileBrowserIOS: View {
|
|||||||
isPresented = false
|
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 {
|
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 { 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))
|
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
|
/// 存储当前加载的外部字幕文件 URL
|
||||||
@Published var externalSubtitleURL: URL?
|
@Published var externalSubtitleURL: URL?
|
||||||
|
|
||||||
|
// SMB 流式加载器引用(保持存活)
|
||||||
|
private var smbLoader: SMBStreamingLoader?
|
||||||
|
|
||||||
func loadExternalSubtitle(url: URL) {
|
func loadExternalSubtitle(url: URL) {
|
||||||
externalSubtitleURL = url
|
externalSubtitleURL = url
|
||||||
diagLog("External subtitle loaded: \(url.lastPathComponent)")
|
diagLog("External subtitle loaded: \(url.lastPathComponent)")
|
||||||
showToast("📝 \(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: - 网络文件浏览
|
// MARK: - 网络文件浏览
|
||||||
func toggleNetworkBrowser() {
|
func toggleNetworkBrowser() {
|
||||||
showNetworkBrowser = true
|
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