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:
yumoqing 2026-07-04 19:51:42 +08:00
parent b1a04526a2
commit 80e332bda0
4 changed files with 357 additions and 230 deletions

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

View File

@ -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

View File

@ -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)

View File

@ -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 {
// schemepath 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) {
//
}
}