- 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)
163 lines
5.6 KiB
Swift
163 lines
5.6 KiB
Swift
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()
|
||
})
|
||
}
|
||
}
|