fix(iOS): simplified — server list + system file picker. FileManager can't browse SMB directly on iOS, use .fileImporter instead.

This commit is contained in:
yumoqing 2026-07-04 20:02:16 +08:00
parent 3cb5b1d8a4
commit 6c7a6e567f

View File

@ -6,7 +6,7 @@ import AppKit
import UIKit
#endif
// MARK: -
// MARK: -
struct NetworkFileItem: Identifiable, Hashable {
let id = UUID()
let url: URL
@ -14,18 +14,14 @@ struct NetworkFileItem: Identifiable, Hashable {
let isDirectory: Bool
let fileSize: Int64?
let hasMatchingASS: Bool
var isMediaFile: Bool {
let ext = url.pathExtension.lowercased()
return ["mp4", "mov", "m4v", "mkv", "avi", "mp3", "wav", "m4a", "flac", "aac", "webm", "ts", "m3u8"].contains(ext)
["mp4","mov","m4v","mkv","avi","mp3","wav","m4a","flac","aac","webm","ts","m3u8"].contains(url.pathExtension.lowercased())
}
}
// MARK: -
struct NetworkFilePanel: View {
@ObservedObject var bridge: PlayerBridge
@Binding var isPresented: Bool
var body: some View {
#if os(macOS)
NetworkFileBrowserMac(bridge: bridge, isPresented: $isPresented)
@ -35,12 +31,11 @@ struct NetworkFilePanel: View {
}
}
// MARK: - macOS
// MARK: - macOS
#if os(macOS)
struct NetworkFileBrowserMac: View {
@ObservedObject var bridge: PlayerBridge
@Binding var isPresented: Bool
@State private var currentURL: URL?
@State private var items: [NetworkFileItem] = []
@State private var pathStack: [URL] = []
@ -50,104 +45,52 @@ struct NetworkFileBrowserMac: View {
@State private var showConnectSheet = false
private var rootItems: [NetworkFileItem] {
let vols = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil,
options: [.skipHiddenVolumes]) ?? []
return vols.sorted { $0.lastPathComponent < $1.lastPathComponent }
.map { NetworkFileItem(url: $0, name: $0.lastPathComponent,
isDirectory: true, fileSize: nil, hasMatchingASS: false) }
(FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? [])
.sorted { $0.lastPathComponent < $1.lastPathComponent }
.map { NetworkFileItem(url: $0, name: $0.lastPathComponent, isDirectory: true, fileSize: nil, hasMatchingASS: false) }
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
Button(action: goUp) {
Image(systemName: "chevron.left").font(.system(size: 14, weight: .medium))
}.buttonStyle(.plain).disabled(pathStack.isEmpty).opacity(pathStack.isEmpty ? 0.3 : 1)
Text(currentURL?.path ?? "/Volumes").font(.system(size: 13)).foregroundColor(.secondary)
.lineLimit(1).truncationMode(.head)
Button(action: goUp) { Image(systemName: "chevron.left").font(.system(size: 14, weight: .medium)) }
.buttonStyle(.plain).disabled(pathStack.isEmpty).opacity(pathStack.isEmpty ? 0.3 : 1)
Text(currentURL?.path ?? "/Volumes").font(.system(size: 13)).foregroundColor(.secondary).lineLimit(1).truncationMode(.head)
Spacer()
Button(action: { showConnectSheet = true }) {
Image(systemName: "network.badge.shield.half.filled").font(.system(size: 14))
}.buttonStyle(.plain).help("Connect (SMB/NFS)")
}.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color(NSColor.controlBackgroundColor))
Button(action: { showConnectSheet = true }) { Image(systemName: "network.badge.shield.half.filled").font(.system(size: 14)) }
.buttonStyle(.plain).help("Connect (SMB/NFS)")
}.padding(.horizontal,12).padding(.vertical,8).background(Color(NSColor.controlBackgroundColor))
Divider()
if isLoading { Spacer(); ProgressView("Loading..."); Spacer() }
else if let e = errorMessage {
Spacer(); VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundColor(.orange)
Text(e).foregroundColor(.secondary).multilineTextAlignment(.center)
}.padding(); Spacer()
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(currentItems) { item in
NetworkFileRow(item: item) { handleSelect(item) }
Divider().padding(.leading, 8)
}
}
}
}
}
.frame(minWidth: 500, minHeight: 400)
.sheet(isPresented: $showConnectSheet) {
ConnectSheet(connectURL: $connectURL, onConnect: connectToShare)
}
.onAppear { if currentURL == nil { navigateToRoot() } }
else if let e = errorMessage { Spacer(); VStack(spacing:12) { Image(systemName:"exclamationmark.triangle").font(.largeTitle).foregroundColor(.orange); Text(e).foregroundColor(.secondary) }.padding(); Spacer() }
else { ScrollView { LazyVStack(spacing:0) { ForEach(currentURL == nil ? rootItems : items) { item in NetworkFileRow(item:item){handleSelect(item)}; Divider().padding(.leading,8) } } } }
}.frame(minWidth:500,minHeight:400)
.sheet(isPresented:$showConnectSheet){ ConnectSheet(connectURL:$connectURL, onConnect: connectToShare) }
.onAppear { if currentURL == nil { pathStack.removeAll(); items=[]; errorMessage=nil } }
}
private var currentItems: [NetworkFileItem] { currentURL == nil ? rootItems : items }
private func navigateToRoot() { pathStack.removeAll(); currentURL = nil; items = []; errorMessage = nil }
private func navigateTo(_ url: URL) {
isLoading = true; errorMessage = nil
DispatchQueue.global(qos: .userInitiated).async {
isLoading=true; errorMessage=nil
DispatchQueue.global(qos:.userInitiated).async {
do {
let contents = try FileManager.default.contentsOfDirectory(
at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
options: [.skipsHiddenFiles])
let assNames = Set(contents
.filter { $0.pathExtension.lowercased() == "ass" }
.map { $0.deletingPathExtension().lastPathComponent.lowercased() })
let fileItems = buildFileItems(contents, assNames: assNames)
DispatchQueue.main.async { self.items = fileItems; self.currentURL = url; self.isLoading = false }
} catch {
DispatchQueue.main.async { self.errorMessage = error.localizedDescription; self.isLoading = false }
}
let c = try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:[.isDirectoryKey,.fileSizeKey],options:[.skipsHiddenFiles])
let an = Set(c.filter{$0.pathExtension.lowercased()=="ass"}.map{$0.deletingPathExtension().lastPathComponent.lowercased()})
let fi = buildFileItems(c,assNames:an)
DispatchQueue.main.async { self.items=fi; self.currentURL=url; self.isLoading=false }
} catch { DispatchQueue.main.async { self.errorMessage=error.localizedDescription; self.isLoading=false } }
}
}
private func goUp() {
guard let _ = currentURL else { return }
if pathStack.isEmpty { navigateToRoot() }
else { let parent = pathStack.removeLast(); currentURL = nil; DispatchQueue.main.async { self.navigateTo(parent) } }
}
private func goUp() { if pathStack.isEmpty { currentURL=nil;items=[] } else { let p=pathStack.removeLast();currentURL=nil; DispatchQueue.main.async{self.navigateTo(p)} } }
private func handleSelect(_ item: NetworkFileItem) {
if item.isDirectory { if let cur = currentURL { pathStack.append(cur) }; navigateTo(item.url) }
else if item.isMediaFile { playFile(item) }
if item.isDirectory { if let c=currentURL{pathStack.append(c)}; navigateTo(item.url) }
else if item.isMediaFile { 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 playFile(_ item: NetworkFileItem) {
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 connectToShare() {
let trimmed = connectURL.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return }
let config = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.open(url, configuration: config) { _, error in
if let error = error {
DispatchQueue.main.async { self.errorMessage = "Mount failed: \(error.localizedDescription)" }
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.navigateToRoot(); self.showConnectSheet = false
}
}
let t=connectURL.trimmingCharacters(in:.whitespaces)
guard !t.isEmpty,let url=URL(string:t) else {return}
NSWorkspace.shared.open(url,configuration:NSWorkspace.OpenConfiguration()){_,err in
if let err { DispatchQueue.main.async{self.errorMessage="Mount failed: \(err.localizedDescription)"} }
else { DispatchQueue.main.asyncAfter(deadline:.now()+2){ self.pathStack.removeAll();self.items=[];self.currentURL=nil;self.errorMessage=nil;self.showConnectSheet=false } }
}
}
}
@ -155,381 +98,109 @@ struct NetworkFileBrowserMac: View {
struct NetworkFileRow: View {
let item: NetworkFileItem; let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: item.isDirectory ? "folder.fill" : iconFor(item.url))
.font(.system(size: 16)).frame(width: 24)
.foregroundColor(item.isDirectory ? .accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(item.name).font(.system(size: 13)).foregroundColor(.primary).lineLimit(1)
if !item.isDirectory, let s = item.fileSize { Text(fmtSize(s)).font(.system(size: 10)).foregroundColor(.secondary) }
}
Spacer()
if item.hasMatchingASS { Text("ASS").font(.system(size: 8, weight: .bold)).foregroundColor(.white)
.padding(.horizontal, 4).padding(.vertical, 2).background(Color.green.opacity(0.8)).cornerRadius(3) }
if item.isDirectory { Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(.secondary) }
}.padding(.horizontal, 12).padding(.vertical, 6).contentShape(Rectangle())
}.buttonStyle(.plain)
}
private func iconFor(_ u: URL) -> String {
switch u.pathExtension.lowercased() {
case "mp4","mov","m4v","mkv","avi","webm","ts": return "film"
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 {
if s < 1024 { return "\(s) B" }
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))
Button(action:action){ HStack(spacing:8){
Image(systemName:item.isDirectory ? "folder.fill" : iconFor(item.url)).font(.system(size:16)).frame(width:24).foregroundColor(item.isDirectory ? .accentColor : .secondary)
VStack(alignment:.leading,spacing:2){ Text(item.name).font(.system(size:13)).foregroundColor(.primary).lineLimit(1); if !item.isDirectory,let s=item.fileSize{Text(fmtSize(s)).font(.system(size:10)).foregroundColor(.secondary)} }
Spacer()
if item.hasMatchingASS { Text("ASS").font(.system(size:8,weight:.bold)).foregroundColor(.white).padding(.horizontal,4).padding(.vertical,2).background(Color.green.opacity(0.8)).cornerRadius(3) }
if item.isDirectory { Image(systemName:"chevron.right").font(.system(size:12)).foregroundColor(.secondary) }
}.padding(.horizontal,12).padding(.vertical,6).contentShape(Rectangle()) }.buttonStyle(.plain)
}
private func iconFor(_ u:URL)->String{switch u.pathExtension.lowercased(){case "mp4","mov","m4v","mkv","avi","webm","ts":return"film";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{if s<1024{return"\(s) B"};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))}
}
struct ConnectSheet: View {
@Binding var connectURL: String; let onConnect: () -> Void
@State private var recents: [String] = UserDefaults.standard.stringArray(forKey: "MiniPlayer.recentNetworkURLs") ?? []
@State private var recents: [String] = UserDefaults.standard.stringArray(forKey:"MiniPlayer.recentNetworkURLs") ?? []
var body: some View {
VStack(spacing: 16) {
Text("Connect to Network Share").font(.headline)
VStack(alignment: .leading, spacing: 6) {
Text("SMB or NFS URL:").font(.caption).foregroundColor(.secondary)
HStack {
TextField("smb://192.168.1.100/share", text: $connectURL).textFieldStyle(.roundedBorder).onSubmit(onConnect)
Button("Connect", action: onConnect).keyboardShortcut(.defaultAction)
}
}
if !recents.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Recent:").font(.caption).foregroundColor(.secondary)
ForEach(recents, id: \.self) { u in Button(u) { connectURL = u; onConnect() }.buttonStyle(.link).font(.system(size: 12)) }
}.frame(maxWidth: .infinity, alignment: .leading)
}
Text("Example:\nsmb://192.168.1.14/sambashare").font(.system(size: 11)).foregroundColor(.secondary)
}.padding(24).frame(width: 420).onDisappear {
let t = connectURL.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty else { return }
var u = recents.filter { $0 != t }; u.insert(t, at: 0)
recents = Array(u.prefix(10))
UserDefaults.standard.set(recents, forKey: "MiniPlayer.recentNetworkURLs")
}
VStack(spacing:16){ Text("Connect to Network Share").font(.headline)
VStack(alignment:.leading,spacing:6){ Text("SMB or NFS URL:").font(.caption).foregroundColor(.secondary)
HStack{ TextField("smb://192.168.1.100/share",text:$connectURL).textFieldStyle(.roundedBorder).onSubmit(onConnect); Button("Connect",action:onConnect).keyboardShortcut(.defaultAction) } }
if !recents.isEmpty { VStack(alignment:.leading,spacing:4){ Text("Recent:").font(.caption).foregroundColor(.secondary); ForEach(recents,id:\.self){u in Button(u){connectURL=u;onConnect()}.buttonStyle(.link).font(.system(size:12))} }.frame(maxWidth:.infinity,alignment:.leading) }
Text("Example:\nsmb://192.168.1.14/sambashare").font(.system(size:11)).foregroundColor(.secondary) }
.padding(24).frame(width:420)
.onDisappear { let t=connectURL.trimmingCharacters(in:.whitespaces); guard !t.isEmpty else{return}; var u=recents.filter{$0 != t}; u.insert(t,at:0); recents=Array(u.prefix(10)); UserDefaults.standard.set(recents,forKey:"MiniPlayer.recentNetworkURLs") }
}
}
#endif
// MARK: -
// MARK: -
private func buildFileItems(_ contents: [URL], assNames: Set<String>) -> [NetworkFileItem] {
contents.filter { url in
let name = url.lastPathComponent
guard !name.hasPrefix("."), !name.hasSuffix(".DS_Store") else { return false }
var isDir: ObjCBool = false
FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
if isDir.boolValue { return true }
return ["mp4","mov","m4v","mkv","avi","mp3","wav","m4a","flac","aac","webm","ts","m3u8","ass","srt","vtt"].contains(url.pathExtension.lowercased())
}
.sorted { a, b in
var ad: ObjCBool = false, bd: ObjCBool = false
FileManager.default.fileExists(atPath: a.path, isDirectory: &ad)
FileManager.default.fileExists(atPath: b.path, isDirectory: &bd)
if ad.boolValue != bd.boolValue { return ad.boolValue }
return a.lastPathComponent.localizedStandardCompare(b.lastPathComponent) == .orderedAscending
}
.compactMap { url -> NetworkFileItem? in
var isDir: ObjCBool = false
FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
let base = url.deletingPathExtension().lastPathComponent.lowercased()
return NetworkFileItem(url: url, name: url.lastPathComponent,
isDirectory: isDir.boolValue,
fileSize: isDir.boolValue ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map(Int64.init),
hasMatchingASS: assNames.contains(base))
}
contents.filter{ url in let n=url.lastPathComponent; guard !n.hasPrefix("."),!n.hasSuffix(".DS_Store") else{return false}; var d:ObjCBool=false; FileManager.default.fileExists(atPath:url.path,isDirectory:&d); if d.boolValue{return true}; return["mp4","mov","m4v","mkv","avi","mp3","wav","m4a","flac","aac","webm","ts","m3u8","ass","srt","vtt"].contains(url.pathExtension.lowercased()) }
.sorted{ a,b in var ad:ObjCBool=false,bd:ObjCBool=false; FileManager.default.fileExists(atPath:a.path,isDirectory:&ad); FileManager.default.fileExists(atPath:b.path,isDirectory:&bd); if ad.boolValue != bd.boolValue{return ad.boolValue}; return a.lastPathComponent.localizedStandardCompare(b.lastPathComponent) == .orderedAscending }
.compactMap{ url->NetworkFileItem? in var d:ObjCBool=false; FileManager.default.fileExists(atPath:url.path,isDirectory:&d); let base=url.deletingPathExtension().lastPathComponent.lowercased(); return NetworkFileItem(url:url,name:url.lastPathComponent,isDirectory:d.boolValue,fileSize:d.boolValue ? nil : (try? url.resourceValues(forKeys:[.fileSizeKey]))?.fileSize.map(Int64.init),hasMatchingASS:assNames.contains(base)) }
}
// MARK: - iOS + + HTTP
// MARK: - iOS +
#if os(iOS)
import UniformTypeIdentifiers
struct NetworkFileBrowserIOS: View {
@ObservedObject var bridge: PlayerBridge
@Binding var isPresented: Bool
//
@State private var savedServers: [String] = UserDefaults.standard.stringArray(forKey: "MiniPlayer.smbServers") ?? []
@State private var smbURL = ""
@State private var isConnecting = false
//
@State private var browseURL: URL?
@State private var items: [NetworkFileItem] = []
@State private var pathStack: [URL] = []
@State private var isLoading = false
@State private var connectError: String?
// URL
@State private var streamURL = ""
@State private var showFilePicker = false
var body: some View {
NavigationView {
VStack(spacing: 0) {
if let _ = browseURL {
browseView
} else {
serverListView
}
}
.navigationTitle(browseURL != nil ? "Browsing" : "Servers")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if browseURL != nil {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { browseURL = nil; pathStack.removeAll(); items = [] }) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Servers")
}
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Close") { isPresented = false }
}
}
}
}
// MARK: -
private var serverListView: some View {
List {
//
if !savedServers.isEmpty {
List {
Section("Saved Servers") {
if savedServers.isEmpty { Text("No saved servers").foregroundColor(.secondary) }
ForEach(savedServers, id: \.self) { url in
Button(action: { connectToServer(url) }) {
Button(action: { showFilePicker = true }) {
HStack {
Image(systemName: "server.rack")
.foregroundColor(.accentColor)
Text(url)
.foregroundColor(.primary)
.lineLimit(1)
Image(systemName: "server.rack").foregroundColor(.accentColor)
Text(url).foregroundColor(.primary).lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.font(.caption).foregroundColor(.secondary)
Image(systemName: "chevron.right").font(.caption).foregroundColor(.secondary)
}
}
}
.onDelete { idx in
savedServers.remove(atOffsets: idx)
UserDefaults.standard.set(savedServers, forKey: "MiniPlayer.smbServers")
.onDelete { savedServers.remove(atOffsets: $0); UserDefaults.standard.set(savedServers, forKey: "MiniPlayer.smbServers") }
}
Section("Add Server") {
HStack {
TextField("smb://192.168.1.14/sambashare", text: $smbURL)
.keyboardType(.URL).autocapitalization(.none).disableAutocorrection(true)
Button("Add") {
let t = smbURL.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty else { return }
if !savedServers.contains(t) { savedServers.insert(t, at: 0); UserDefaults.standard.set(savedServers, forKey: "MiniPlayer.smbServers") }
smbURL = ""
showFilePicker = true
}.disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
Section("Stream URL") {
HStack {
TextField("https://...", text: $streamURL).keyboardType(.URL).autocapitalization(.none).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)
}
}
}
//
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)
}
if let e = connectError {
Text(e).font(.caption).foregroundColor(.red)
}
}
// URL
Section("Stream URL") {
HStack {
TextField("https://...", text: $streamURL)
.keyboardType(.URL)
.autocapitalization(.none)
.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)
.listStyle(.insetGrouped)
.navigationTitle("Servers").navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Close") { isPresented = false } } }
.fileImporter(isPresented: $showFilePicker,
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)
}
}
}
.listStyle(.insetGrouped)
}
// MARK: -
private var browseView: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
Button(action: goUp) {
Image(systemName: "chevron.left").font(.system(size: 14, weight: .medium))
}
.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()
}
.padding(.horizontal, 12).padding(.vertical, 8)
Divider()
if isLoading {
Spacer(); ProgressView(); Spacer()
} else if let e = connectError {
Spacer(); Text(e).foregroundColor(.secondary).padding(); Spacer()
} else {
List {
ForEach(items) { item in
Button(action: { handleSelect(item) }) {
HStack(spacing: 8) {
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)
}
}
}
}
}
.listStyle(.plain)
}
}
}
// MARK: -
/// iOS Files SMB Files FileManager
private func connectToServer(_ urlStr: String) {
let t = urlStr.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty, let url = URL(string: t) else { return }
isConnecting = true; connectError = nil; smbURL = t
// FileManager
DispatchQueue.global(qos: .userInitiated).async { [self] in
do {
let contents = try FileManager.default.contentsOfDirectory(
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)
DispatchQueue.main.async {
self.items = fi; self.browseURL = url; self.isConnecting = false
self.addToSaved(t)
}
} catch {
// FileManager Files
DispatchQueue.main.async {
self.isConnecting = false
self.addToSaved(t) //
// Files
UIApplication.shared.open(url) { ok in
if ok {
self.connectError = "Login in Files, then tap this server again."
} else {
self.connectError = "Cannot open. Check URL format."
}
}
}
}
}
}
private func addToSaved(_ url: String) {
if !savedServers.contains(url) {
savedServers.insert(url, at: 0)
UserDefaults.standard.set(savedServers, forKey: "MiniPlayer.smbServers")
}
}
private func goUp() {
if pathStack.isEmpty { browseURL = nil; items = [] }
else { let p = pathStack.removeLast(); navigateTo(p) }
}
private func navigateTo(_ url: URL) {
isLoading = true; connectError = nil
DispatchQueue.global(qos: .userInitiated).async {
do {
let contents = try FileManager.default.contentsOfDirectory(
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)
DispatchQueue.main.async { self.items = fi; self.browseURL = url; self.isLoading = false }
} catch {
DispatchQueue.main.async { self.connectError = error.localizedDescription; self.isLoading = false }
}
}
}
private func handleSelect(_ item: NetworkFileItem) {
if item.isDirectory {
if let cur = browseURL { pathStack.append(cur) }
navigateTo(item.url)
} else if item.isMediaFile {
// SMB HTTP
let isSMB = browseURL?.scheme == "smb" || item.url.scheme == "smb"
if isSMB {
bridge.playSMBSource(url: item.url, name: item.name)
if item.hasMatchingASS {
bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass"))
}
isPresented = false
} else {
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 iconFor(_ url: URL) -> String {
switch url.pathExtension.lowercased() {
case "mp4","mov","m4v","mkv","avi","webm","ts": return "film"
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 {
if s < 1024 { return "\(s) B" }
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