- 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
128 lines
4.8 KiB
Swift
128 lines
4.8 KiB
Swift
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) {
|
||
// 取消请求,无需额外处理
|
||
}
|
||
}
|