diff --git a/Sources/NetworkFileBrowser.swift b/Sources/NetworkFileBrowser.swift index 5387799..2d41f1d 100644 --- a/Sources/NetworkFileBrowser.swift +++ b/Sources/NetworkFileBrowser.swift @@ -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) -> [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