MiniPlayer/Sources/LocalStreamServer.swift
yumoqing 80e332bda0 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)
2026-07-04 19:51:42 +08:00

163 lines
5.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
})
}
}