feat: iOS — saved server list, built-in file browser, HTTP stream server
- 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)
This commit is contained in:
parent
b1a04526a2
commit
80e332bda0
162
Sources/LocalStreamServer.swift
Normal file
162
Sources/LocalStreamServer.swift
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -247,7 +247,7 @@ private func buildFileItems(_ contents: [URL], assNames: Set<String>) -> [Networ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iOS 版本
|
// MARK: - iOS 版本:连接历史 + 内建文件浏览 + HTTP 流播放
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@ -255,140 +255,204 @@ struct NetworkFileBrowserIOS: View {
|
|||||||
@ObservedObject var bridge: PlayerBridge
|
@ObservedObject var bridge: PlayerBridge
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
// 连接历史
|
||||||
|
@State private var savedServers: [String] = UserDefaults.standard.stringArray(forKey: "MiniPlayer.smbServers") ?? []
|
||||||
@State private var smbURL = ""
|
@State private var smbURL = ""
|
||||||
@State private var isConnecting = false
|
@State private var isConnecting = false
|
||||||
@State private var connectError: String?
|
|
||||||
|
// 文件浏览
|
||||||
@State private var browseURL: URL?
|
@State private var browseURL: URL?
|
||||||
@State private var items: [NetworkFileItem] = []
|
@State private var items: [NetworkFileItem] = []
|
||||||
@State private var pathStack: [URL] = []
|
@State private var pathStack: [URL] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
@State private var connectError: String?
|
||||||
|
|
||||||
|
// 流 URL
|
||||||
@State private var streamURL = ""
|
@State private var streamURL = ""
|
||||||
@State private var showFileImporter = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(spacing: 0) {
|
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)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if browseURL != nil {
|
if browseURL != nil {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
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) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Close") { isPresented = false }
|
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 {
|
// MARK: - 服务器列表
|
||||||
ScrollView {
|
private var serverListView: some View {
|
||||||
VStack(spacing: 20) {
|
List {
|
||||||
Spacer().frame(height: 20)
|
// 已保存的服务器
|
||||||
VStack(spacing: 10) {
|
if !savedServers.isEmpty {
|
||||||
Image(systemName: "network").font(.system(size: 40)).foregroundColor(.accentColor)
|
Section("Saved Servers") {
|
||||||
Text("Connect to Server").font(.title3).fontWeight(.medium)
|
ForEach(savedServers, id: \.self) { url in
|
||||||
VStack(spacing: 8) {
|
Button(action: { connectToServer(url) }) {
|
||||||
TextField("smb://192.168.1.14/sambashare", text: $smbURL)
|
HStack {
|
||||||
.textFieldStyle(.roundedBorder).keyboardType(.URL)
|
Image(systemName: "server.rack")
|
||||||
.autocapitalization(.none).disableAutocorrection(true).padding(.horizontal, 20)
|
.foregroundColor(.accentColor)
|
||||||
Button(action: connectToSMB) {
|
Text(url)
|
||||||
HStack { if isConnecting { ProgressView().scaleEffect(0.8) }; Text("Connect") }.frame(maxWidth: 280)
|
.foregroundColor(.primary)
|
||||||
}.buttonStyle(.borderedProminent).disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting)
|
.lineLimit(1)
|
||||||
if let e = connectError { Text(e).font(.caption).foregroundColor(.red).multilineTextAlignment(.center).padding(.horizontal) }
|
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)
|
if let e = connectError {
|
||||||
VStack(spacing: 10) {
|
Text(e).font(.caption).foregroundColor(.red)
|
||||||
Text("Play stream URL").font(.subheadline).foregroundColor(.secondary)
|
}
|
||||||
HStack {
|
}
|
||||||
TextField("https://...", text: $streamURL).textFieldStyle(.roundedBorder)
|
|
||||||
.keyboardType(.URL).autocapitalization(.none).disableAutocorrection(true)
|
// 直接流 URL
|
||||||
Button("Open") {
|
Section("Stream URL") {
|
||||||
let t = streamURL.trimmingCharacters(in: .whitespaces)
|
HStack {
|
||||||
guard !t.isEmpty, URL(string: t) != nil else { return }
|
TextField("https://...", text: $streamURL)
|
||||||
bridge.addURL(t); isPresented = false
|
.keyboardType(.URL)
|
||||||
}.buttonStyle(.borderedProminent).disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty)
|
.autocapitalization(.none)
|
||||||
}.padding(.horizontal, 20)
|
.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 {
|
private var browseView: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button(action: goUp) { Image(systemName: "chevron.left").font(.system(size: 14, weight: .medium)) }
|
Button(action: goUp) {
|
||||||
.disabled(pathStack.isEmpty).opacity(pathStack.isEmpty ? 0.3 : 1)
|
Image(systemName: "chevron.left").font(.system(size: 14, weight: .medium))
|
||||||
Text(browseURL?.path ?? "").font(.system(size: 13)).foregroundColor(.secondary).lineLimit(1).truncationMode(.head)
|
}
|
||||||
|
.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()
|
Spacer()
|
||||||
}.padding(.horizontal, 12).padding(.vertical, 8)
|
}
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
Divider()
|
Divider()
|
||||||
if isLoading { Spacer(); ProgressView(); Spacer() }
|
|
||||||
else if let e = connectError { Spacer(); Text(e).foregroundColor(.secondary).padding(); Spacer() }
|
if isLoading {
|
||||||
else {
|
Spacer(); ProgressView(); Spacer()
|
||||||
List { ForEach(items) { item in
|
} else if let e = connectError {
|
||||||
Button(action: { handleSelect(item) }) {
|
Spacer(); Text(e).foregroundColor(.secondary).padding(); Spacer()
|
||||||
HStack(spacing: 8) {
|
} else {
|
||||||
Image(systemName: item.isDirectory ? "folder.fill" : (["mp4","mov","m4v","mkv","avi","webm","ts"].contains(item.url.pathExtension.lowercased()) ? "film" : "doc"))
|
List {
|
||||||
.foregroundColor(item.isDirectory ? .accentColor : .secondary)
|
ForEach(items) { item in
|
||||||
VStack(alignment: .leading) {
|
Button(action: { handleSelect(item) }) {
|
||||||
Text(item.name).foregroundColor(.primary)
|
HStack(spacing: 8) {
|
||||||
if !item.isDirectory, let s = item.fileSize { Text(fmtSize(s)).font(.caption).foregroundColor(.secondary) }
|
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() {
|
// MARK: - 连接逻辑
|
||||||
let t = smbURL.trimmingCharacters(in: .whitespaces)
|
private func connectToServer(_ urlStr: String) {
|
||||||
|
let t = urlStr.trimmingCharacters(in: .whitespaces)
|
||||||
guard !t.isEmpty, let url = URL(string: t) else { return }
|
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 {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
do {
|
do {
|
||||||
let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles])
|
let contents = try FileManager.default.contentsOfDirectory(
|
||||||
let assNames = Set(contents.filter { $0.pathExtension.lowercased() == "ass" }.map { $0.deletingPathExtension().lastPathComponent.lowercased() })
|
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)
|
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 {
|
} catch {
|
||||||
DispatchQueue.main.async {
|
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) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
UIApplication.shared.open(url) { ok in
|
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() {
|
private func goUp() {
|
||||||
guard let cur = browseURL else { return }
|
|
||||||
if pathStack.isEmpty { browseURL = nil; items = [] }
|
if pathStack.isEmpty { browseURL = nil; items = [] }
|
||||||
else { let p = pathStack.removeLast(); navigateTo(p) }
|
else { let p = pathStack.removeLast(); navigateTo(p) }
|
||||||
}
|
}
|
||||||
@ -406,8 +469,12 @@ struct NetworkFileBrowserIOS: View {
|
|||||||
isLoading = true; connectError = nil
|
isLoading = true; connectError = nil
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
do {
|
do {
|
||||||
let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles])
|
let contents = try FileManager.default.contentsOfDirectory(
|
||||||
let assNames = Set(contents.filter { $0.pathExtension.lowercased() == "ass" }.map { $0.deletingPathExtension().lastPathComponent.lowercased() })
|
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)
|
let fi = buildFileItems(contents, assNames: assNames)
|
||||||
DispatchQueue.main.async { self.items = fi; self.browseURL = url; self.isLoading = false }
|
DispatchQueue.main.async { self.items = fi; self.browseURL = url; self.isLoading = false }
|
||||||
} catch {
|
} catch {
|
||||||
@ -417,30 +484,42 @@ 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 {
|
||||||
else if item.isMediaFile {
|
if let cur = browseURL { pathStack.append(cur) }
|
||||||
|
navigateTo(item.url)
|
||||||
|
} else if item.isMediaFile {
|
||||||
if item.url.scheme == "smb" {
|
if item.url.scheme == "smb" {
|
||||||
|
// HTTP 流式播放
|
||||||
bridge.playSMBSource(url: item.url, name: item.name)
|
bridge.playSMBSource(url: item.url, name: item.name)
|
||||||
// 同名 ASS
|
|
||||||
if item.hasMatchingASS {
|
if item.hasMatchingASS {
|
||||||
bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass"))
|
bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass"))
|
||||||
}
|
}
|
||||||
isPresented = false
|
isPresented = false
|
||||||
} else {
|
} 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) {
|
private func iconFor(_ url: URL) -> String {
|
||||||
bridge.addItem(url: item.url, name: item.name, type: "network")
|
switch url.pathExtension.lowercased() {
|
||||||
if item.hasMatchingASS { bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass")) }
|
case "mp4","mov","m4v","mkv","avi","webm","ts": return "film"
|
||||||
isPresented = false
|
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 {
|
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*1024 { return String(format: "%.1f MB", Double(s)/(1024*1024)) }; return String(format: "%.1f GB", Double(s)/(1024*1024*1024))
|
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
|
#endif
|
||||||
|
|||||||
@ -524,7 +524,7 @@ final class PlayerBridge: ObservableObject {
|
|||||||
@Published var externalSubtitleURL: URL?
|
@Published var externalSubtitleURL: URL?
|
||||||
|
|
||||||
// SMB 流式加载器引用(保持存活)
|
// SMB 流式加载器引用(保持存活)
|
||||||
private var smbLoader: SMBStreamingLoader?
|
private var streamServer: LocalStreamServer?
|
||||||
|
|
||||||
func loadExternalSubtitle(url: URL) {
|
func loadExternalSubtitle(url: URL) {
|
||||||
externalSubtitleURL = url
|
externalSubtitleURL = url
|
||||||
@ -532,16 +532,29 @@ final class PlayerBridge: ObservableObject {
|
|||||||
showToast("📝 \(url.lastPathComponent)")
|
showToast("📝 \(url.lastPathComponent)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SMB 文件流式播放 — 通过 AVAssetResourceLoader 逐块读取,不复制整个文件
|
/// SMB 文件流式播放 — 启动本地 HTTP 服务器,AVPlayer 从 localhost 播放
|
||||||
func playSMBSource(url smbURL: URL, name: String) {
|
func playSMBSource(url smbURL: URL, name: String) {
|
||||||
let loader = SMBStreamingLoader(smbURL: smbURL)
|
// 停止旧服务器
|
||||||
self.smbLoader = loader // 保持引用防止被释放
|
streamServer?.stop()
|
||||||
|
|
||||||
let wrappedURL = loader.wrappedURL()
|
let server = LocalStreamServer(smbURL: smbURL)
|
||||||
let asset = AVURLAsset(url: wrappedURL)
|
do {
|
||||||
asset.resourceLoader.setDelegate(loader, queue: .global(qos: .userInitiated))
|
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)
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
player.replaceCurrentItem(with: playerItem)
|
player.replaceCurrentItem(with: playerItem)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|||||||
@ -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) {
|
|
||||||
// 取消请求,无需额外处理
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user