From 80e332bda0b7e7d2c7105076a66d5fcdcd041e29 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 4 Jul 2026 19:51:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20=E2=80=94=20saved=20server=20list?= =?UTF-8?q?,=20built-in=20file=20browser,=20HTTP=20stream=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalStreamServer: NWListener-based HTTP proxy, streams SMB via localhost - Saved server list with delete, one-tap reconnect - Removed file importer — all browsing in MiniPlayer - Removed SMBStreamingLoader (custom scheme approach didn't work) --- Sources/LocalStreamServer.swift | 162 +++++++++++++++++++ Sources/NetworkFileBrowser.swift | 269 ++++++++++++++++++++----------- Sources/PlayerBridge.swift | 29 +++- Sources/SMBStreamingLoader.swift | 127 --------------- 4 files changed, 357 insertions(+), 230 deletions(-) create mode 100644 Sources/LocalStreamServer.swift delete mode 100644 Sources/SMBStreamingLoader.swift diff --git a/Sources/LocalStreamServer.swift b/Sources/LocalStreamServer.swift new file mode 100644 index 0000000..fcb7fb6 --- /dev/null +++ b/Sources/LocalStreamServer.swift @@ -0,0 +1,162 @@ +import Foundation +import Network + +// MARK: - 本地 HTTP 流服务器 +/// 将 SMB 文件通过本地 HTTP 端口流式传输给 AVPlayer +/// 每播放一个文件创建一个临时服务器,播放结束后销毁 + +final class LocalStreamServer { + private var listener: NWListener? + private let smbURL: URL + private var fileHandle: FileHandle? + private var fileSize: Int64 = 0 + private var contentType: String = "video/mp4" + + private(set) var port: UInt16 = 0 + var localURL: URL? { + port > 0 ? URL(string: "http://127.0.0.1:\(port)/stream") : nil + } + + init(smbURL: URL) { + self.smbURL = smbURL + 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 "webm": contentType = "video/webm" + case "ts": contentType = "video/mp2t" + case "mp3": contentType = "audio/mpeg" + case "wav": contentType = "audio/wav" + case "m4a": contentType = "audio/mp4" + default: contentType = "video/mp4" + } + } + + func start() throws { + // 打开 SMB 文件获取大小 + fileHandle = try FileHandle(forReadingFrom: smbURL) + fileSize = Int64(try fileHandle!.seekToEnd()) + fileHandle!.seek(toFileOffset: 0) + + // 随机端口 + let params = NWParameters.tcp + params.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: 0) + + listener = try NWListener(using: params) + listener?.newConnectionHandler = { [weak self] conn in + self?.handleConnection(conn) + } + listener?.start(queue: .global(qos: .userInitiated)) + + // 获取实际分配的端口 + if let endpoint = listener?.port { + port = endpoint.rawValue + } + } + + func stop() { + listener?.cancel() + listener = nil + try? fileHandle?.close() + fileHandle = nil + } + + private func handleConnection(_ conn: NWConnection) { + conn.stateUpdateHandler = { state in + if case .failed = state { conn.cancel() } + } + conn.start(queue: .global(qos: .userInitiated)) + receiveRequest(conn, buffer: Data()) + } + + private func receiveRequest(_ conn: NWConnection, buffer: Data) { + conn.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in + guard let self else { conn.cancel(); return } + + var buf = buffer + if let data { buf.append(data) } + + // 检查是否收到完整的 HTTP 请求头(以 \r\n\r\n 结束) + if buf.range(of: Data("\r\n\r\n".utf8)) != nil { + self.handleHTTPRequest(conn, raw: buf) + } else if isComplete || error != nil { + conn.cancel() + } else { + self.receiveRequest(conn, buffer: buf) + } + } + } + + private func handleHTTPRequest(_ conn: NWConnection, raw: Data) { + guard let request = String(data: raw, encoding: .utf8) else { + send404(conn); return + } + + let lines = request.components(separatedBy: "\r\n") + guard let first = lines.first, first.hasPrefix("GET") else { + send404(conn); return + } + + // 解析 Range 头 + var rangeStart: Int64 = 0 + var rangeEnd: Int64 = fileSize - 1 + var isPartial = false + + for line in lines { + if line.lowercased().hasPrefix("range: bytes=") { + let val = line.replacingOccurrences(of: "Range: ", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "range: ", with: "", options: .caseInsensitive) + let parts = val.dropFirst(6).split(separator: "-") + if let start = Int64(parts[0]) { + rangeStart = start + isPartial = true + } + if parts.count > 1, let end = Int64(parts[1]) { + rangeEnd = min(end, fileSize - 1) + } + break + } + } + + let chunkSize = rangeEnd - rangeStart + 1 + + // 构建响应头 + var headers = "" + if isPartial { + headers += "HTTP/1.1 206 Partial Content\r\n" + headers += "Content-Range: bytes \(rangeStart)-\(rangeEnd)/\(fileSize)\r\n" + } else { + headers += "HTTP/1.1 200 OK\r\n" + } + headers += "Content-Type: \(contentType)\r\n" + headers += "Content-Length: \(chunkSize)\r\n" + headers += "Accept-Ranges: bytes\r\n" + headers += "Connection: close\r\n" + headers += "Access-Control-Allow-Origin: *\r\n" + headers += "\r\n" + + var responseData = Data(headers.utf8) + + // 读取文件数据 + do { + try fileHandle?.seek(toOffset: UInt64(rangeStart)) + let fileData = fileHandle?.readData(ofLength: Int(chunkSize)) ?? Data() + responseData.append(fileData) + } catch { + send404(conn); return + } + + conn.send(content: responseData, completion: .contentProcessed { _ in + conn.cancel() + }) + } + + private func send404(_ conn: NWConnection) { + let body = "Not Found" + let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)" + conn.send(content: Data(resp.utf8), completion: .contentProcessed { _ in + conn.cancel() + }) + } +} diff --git a/Sources/NetworkFileBrowser.swift b/Sources/NetworkFileBrowser.swift index 6b5841c..01a6888 100644 --- a/Sources/NetworkFileBrowser.swift +++ b/Sources/NetworkFileBrowser.swift @@ -247,7 +247,7 @@ private func buildFileItems(_ contents: [URL], assNames: Set) -> [Networ } } -// MARK: - iOS 版本 +// MARK: - iOS 版本:连接历史 + 内建文件浏览 + HTTP 流播放 #if os(iOS) import UniformTypeIdentifiers @@ -255,140 +255,204 @@ struct NetworkFileBrowserIOS: View { @ObservedObject var bridge: PlayerBridge @Binding var isPresented: Bool + // 连接历史 + @State private var savedServers: [String] = UserDefaults.standard.stringArray(forKey: "MiniPlayer.smbServers") ?? [] @State private var smbURL = "" @State private var isConnecting = false - @State private var connectError: String? + + // 文件浏览 @State private var browseURL: URL? @State private var items: [NetworkFileItem] = [] @State private var pathStack: [URL] = [] @State private var isLoading = false + @State private var connectError: String? + + // 流 URL @State private var streamURL = "" - @State private var showFileImporter = false var body: some View { NavigationView { VStack(spacing: 0) { - if browseURL != nil { browseView } else { connectView } + if let _ = browseURL { + browseView + } else { + serverListView + } } - .navigationTitle(browseURL != nil ? "Browsing" : "Network Files") + .navigationTitle(browseURL != nil ? "Browsing" : "Servers") .navigationBarTitleDisplayMode(.inline) .toolbar { if browseURL != nil { ToolbarItem(placement: .navigationBarLeading) { - Button("Back") { browseURL = nil; pathStack.removeAll(); items = [] } + Button(action: { browseURL = nil; pathStack.removeAll(); items = [] }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Servers") + } + } } } ToolbarItem(placement: .navigationBarTrailing) { Button("Close") { isPresented = false } } } - .fileImporter(isPresented: $showFileImporter, - allowedContentTypes: [.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3, .wav, .mpeg4Audio], - allowsMultipleSelection: false) { result in - switch result { - case .success(let urls): - guard let url = urls.first else { return } - let didStart = url.startAccessingSecurityScopedResource() - bridge.addItem(url: url, name: url.lastPathComponent, type: "network") - let ass = url.deletingPathExtension().appendingPathExtension("ass") - if FileManager.default.fileExists(atPath: ass.path) { bridge.loadExternalSubtitle(url: ass) } - if didStart { url.stopAccessingSecurityScopedResource() } - isPresented = false - case .failure(let e): bridge.showToastMsg(e.localizedDescription) - } - } } } - private var connectView: some View { - ScrollView { - VStack(spacing: 20) { - Spacer().frame(height: 20) - VStack(spacing: 10) { - Image(systemName: "network").font(.system(size: 40)).foregroundColor(.accentColor) - Text("Connect to Server").font(.title3).fontWeight(.medium) - VStack(spacing: 8) { - TextField("smb://192.168.1.14/sambashare", text: $smbURL) - .textFieldStyle(.roundedBorder).keyboardType(.URL) - .autocapitalization(.none).disableAutocorrection(true).padding(.horizontal, 20) - Button(action: connectToSMB) { - HStack { if isConnecting { ProgressView().scaleEffect(0.8) }; Text("Connect") }.frame(maxWidth: 280) - }.buttonStyle(.borderedProminent).disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting) - if let e = connectError { Text(e).font(.caption).foregroundColor(.red).multilineTextAlignment(.center).padding(.horizontal) } + // MARK: - 服务器列表 + private var serverListView: some View { + List { + // 已保存的服务器 + if !savedServers.isEmpty { + Section("Saved Servers") { + ForEach(savedServers, id: \.self) { url in + Button(action: { connectToServer(url) }) { + HStack { + Image(systemName: "server.rack") + .foregroundColor(.accentColor) + Text(url) + .foregroundColor(.primary) + .lineLimit(1) + Spacer() + Image(systemName: "chevron.right") + .font(.caption).foregroundColor(.secondary) + } + } + } + .onDelete { idx in + savedServers.remove(atOffsets: idx) + UserDefaults.standard.set(savedServers, forKey: "MiniPlayer.smbServers") } } - Divider().padding(.horizontal, 40) - VStack(spacing: 10) { - Text("Or browse connected servers").font(.subheadline).foregroundColor(.secondary) - Button(action: { showFileImporter = true }) { Label("Browse Files", systemImage: "folder").frame(maxWidth: 280) }.buttonStyle(.bordered) + } + + // 新增连接 + Section("Connect to Server") { + HStack { + TextField("smb://192.168.1.14/sambashare", text: $smbURL) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + Button(action: { connectToServer(smbURL) }) { + if isConnecting { + ProgressView().scaleEffect(0.8) + } else { + Text("Connect") + } + } + .disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting) } - Divider().padding(.horizontal, 40) - VStack(spacing: 10) { - Text("Play stream URL").font(.subheadline).foregroundColor(.secondary) - HStack { - TextField("https://...", text: $streamURL).textFieldStyle(.roundedBorder) - .keyboardType(.URL).autocapitalization(.none).disableAutocorrection(true) - Button("Open") { - let t = streamURL.trimmingCharacters(in: .whitespaces) - guard !t.isEmpty, URL(string: t) != nil else { return } - bridge.addURL(t); isPresented = false - }.buttonStyle(.borderedProminent).disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty) - }.padding(.horizontal, 20) + if let e = connectError { + Text(e).font(.caption).foregroundColor(.red) + } + } + + // 直接流 URL + Section("Stream URL") { + HStack { + TextField("https://...", text: $streamURL) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + Button("Open") { + let t = streamURL.trimmingCharacters(in: .whitespaces) + guard !t.isEmpty else { return } + bridge.addURL(t); isPresented = false + } + .disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty) } - Spacer().frame(height: 20) } } - .onAppear { if let last = UserDefaults.standard.string(forKey: "MiniPlayer.lastSMBURL") { smbURL = last } } + .listStyle(.insetGrouped) } + // MARK: - 文件浏览 private var browseView: some View { VStack(spacing: 0) { HStack(spacing: 8) { - Button(action: goUp) { Image(systemName: "chevron.left").font(.system(size: 14, weight: .medium)) } - .disabled(pathStack.isEmpty).opacity(pathStack.isEmpty ? 0.3 : 1) - Text(browseURL?.path ?? "").font(.system(size: 13)).foregroundColor(.secondary).lineLimit(1).truncationMode(.head) + Button(action: goUp) { + Image(systemName: "chevron.left").font(.system(size: 14, weight: .medium)) + } + .disabled(pathStack.isEmpty).opacity(pathStack.isEmpty ? 0.3 : 1) + + Text(browseURL?.lastPathComponent ?? "") + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + Text(browseURL?.path ?? "") + .font(.system(size: 11)).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.head) Spacer() - }.padding(.horizontal, 12).padding(.vertical, 8) + } + .padding(.horizontal, 12).padding(.vertical, 8) Divider() - if isLoading { Spacer(); ProgressView(); Spacer() } - else if let e = connectError { Spacer(); Text(e).foregroundColor(.secondary).padding(); Spacer() } - else { - List { ForEach(items) { item in - Button(action: { handleSelect(item) }) { - HStack(spacing: 8) { - Image(systemName: item.isDirectory ? "folder.fill" : (["mp4","mov","m4v","mkv","avi","webm","ts"].contains(item.url.pathExtension.lowercased()) ? "film" : "doc")) - .foregroundColor(item.isDirectory ? .accentColor : .secondary) - VStack(alignment: .leading) { - Text(item.name).foregroundColor(.primary) - if !item.isDirectory, let s = item.fileSize { Text(fmtSize(s)).font(.caption).foregroundColor(.secondary) } + + if isLoading { + Spacer(); ProgressView(); Spacer() + } else if let e = connectError { + Spacer(); Text(e).foregroundColor(.secondary).padding(); Spacer() + } else { + List { + ForEach(items) { item in + Button(action: { handleSelect(item) }) { + HStack(spacing: 8) { + Image(systemName: item.isDirectory ? "folder.fill" : iconFor(item.url)) + .foregroundColor(item.isDirectory ? .accentColor : .secondary) + VStack(alignment: .leading) { + Text(item.name).foregroundColor(.primary) + if !item.isDirectory, let s = item.fileSize { + Text(fmtSize(s)).font(.caption).foregroundColor(.secondary) + } + } + Spacer() + if item.hasMatchingASS { + Text("ASS").font(.caption2.bold()).foregroundColor(.white) + .padding(.horizontal, 4).padding(.vertical, 2) + .background(Color.green).cornerRadius(3) + } + if item.isDirectory { + Image(systemName: "chevron.right").font(.caption).foregroundColor(.secondary) + } } - Spacer() - if item.hasMatchingASS { Text("ASS").font(.caption2.bold()).foregroundColor(.white) - .padding(.horizontal, 4).padding(.vertical, 2).background(Color.green).cornerRadius(3) } - if item.isDirectory { Image(systemName: "chevron.right").font(.caption).foregroundColor(.secondary) } } } - }}.listStyle(.plain) + } + .listStyle(.plain) } } } - private func connectToSMB() { - let t = smbURL.trimmingCharacters(in: .whitespaces) + // MARK: - 连接逻辑 + private func connectToServer(_ urlStr: String) { + let t = urlStr.trimmingCharacters(in: .whitespaces) guard !t.isEmpty, let url = URL(string: t) else { return } - isConnecting = true; connectError = nil + + isConnecting = true; connectError = nil; smbURL = t + DispatchQueue.global(qos: .userInitiated).async { do { - let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) - let assNames = Set(contents.filter { $0.pathExtension.lowercased() == "ass" }.map { $0.deletingPathExtension().lastPathComponent.lowercased() }) + let contents = try FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], + options: [.skipsHiddenFiles]) + let assNames = Set(contents + .filter { $0.pathExtension.lowercased() == "ass" } + .map { $0.deletingPathExtension().lastPathComponent.lowercased() }) let fi = buildFileItems(contents, assNames: assNames) - DispatchQueue.main.async { self.items = fi; self.browseURL = url; self.isConnecting = false; UserDefaults.standard.set(t, forKey: "MiniPlayer.lastSMBURL") } + + DispatchQueue.main.async { + self.items = fi; self.browseURL = url; self.isConnecting = false + // 保存到历史 + if !self.savedServers.contains(t) { + self.savedServers.insert(t, at: 0) + UserDefaults.standard.set(self.savedServers, forKey: "MiniPlayer.smbServers") + } + } } catch { DispatchQueue.main.async { - self.isConnecting = false; self.connectError = "Cannot connect directly. Opening Files app..." + self.isConnecting = false + self.connectError = "Connection failed. Opening Files app..." DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { UIApplication.shared.open(url) { ok in - self.connectError = ok ? "Login in Files app, then come back → Browse Files" : "Failed to open Files app.\nCheck: smb://server/share" + self.connectError = ok ? "Login in Files, then come back." : "Invalid URL" } } } @@ -397,7 +461,6 @@ struct NetworkFileBrowserIOS: View { } private func goUp() { - guard let cur = browseURL else { return } if pathStack.isEmpty { browseURL = nil; items = [] } else { let p = pathStack.removeLast(); navigateTo(p) } } @@ -406,8 +469,12 @@ struct NetworkFileBrowserIOS: View { isLoading = true; connectError = nil DispatchQueue.global(qos: .userInitiated).async { do { - let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) - let assNames = Set(contents.filter { $0.pathExtension.lowercased() == "ass" }.map { $0.deletingPathExtension().lastPathComponent.lowercased() }) + let contents = try FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], + options: [.skipsHiddenFiles]) + let assNames = Set(contents + .filter { $0.pathExtension.lowercased() == "ass" } + .map { $0.deletingPathExtension().lastPathComponent.lowercased() }) let fi = buildFileItems(contents, assNames: assNames) DispatchQueue.main.async { self.items = fi; self.browseURL = url; self.isLoading = false } } catch { @@ -417,30 +484,42 @@ 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 { + if item.isDirectory { + if let cur = browseURL { pathStack.append(cur) } + navigateTo(item.url) + } else if item.isMediaFile { if item.url.scheme == "smb" { + // HTTP 流式播放 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) + bridge.addItem(url: item.url, name: item.name, type: "network") + if item.hasMatchingASS { + bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass")) + } + isPresented = false } } } - private func playMedia(_ item: NetworkFileItem) { - bridge.addItem(url: item.url, name: item.name, type: "network") - if item.hasMatchingASS { bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass")) } - isPresented = false + private func iconFor(_ url: URL) -> String { + switch url.pathExtension.lowercased() { + case "mp4","mov","m4v","mkv","avi","webm","ts": return "film" + case "mp3","wav","m4a","flac","aac": return "music.note" + case "m3u8": return "antenna.radiowaves.left.and.right" + case "ass","srt","vtt": return "captions.bubble" + default: return "doc" + } } 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)) + 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)) } } #endif diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 2d816cd..97b30b7 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -524,7 +524,7 @@ final class PlayerBridge: ObservableObject { @Published var externalSubtitleURL: URL? // SMB 流式加载器引用(保持存活) - private var smbLoader: SMBStreamingLoader? + private var streamServer: LocalStreamServer? func loadExternalSubtitle(url: URL) { externalSubtitleURL = url @@ -532,16 +532,29 @@ final class PlayerBridge: ObservableObject { showToast("📝 \(url.lastPathComponent)") } - /// SMB 文件流式播放 — 通过 AVAssetResourceLoader 逐块读取,不复制整个文件 + /// SMB 文件流式播放 — 启动本地 HTTP 服务器,AVPlayer 从 localhost 播放 func playSMBSource(url smbURL: URL, name: String) { - let loader = SMBStreamingLoader(smbURL: smbURL) - self.smbLoader = loader // 保持引用防止被释放 + // 停止旧服务器 + streamServer?.stop() - let wrappedURL = loader.wrappedURL() - let asset = AVURLAsset(url: wrappedURL) - asset.resourceLoader.setDelegate(loader, queue: .global(qos: .userInitiated)) + let server = LocalStreamServer(smbURL: smbURL) + do { + try server.start() + } catch { + diagLog("Stream server start failed: \(error)") + showToast("Failed: \(error.localizedDescription)") + return + } + self.streamServer = server - // 直接用 asset 创建 player item,绕过 addItem + guard let localURL = server.localURL else { + showToast("Failed to get stream URL") + return + } + + diagLog("Streaming \(name) via \(localURL.absoluteString)") + + let asset = AVURLAsset(url: localURL) let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) #if os(iOS) diff --git a/Sources/SMBStreamingLoader.swift b/Sources/SMBStreamingLoader.swift deleted file mode 100644 index 0bbe7ea..0000000 --- a/Sources/SMBStreamingLoader.swift +++ /dev/null @@ -1,127 +0,0 @@ -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) { - // 取消请求,无需额外处理 - } -}