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