import SwiftUI import AVFoundation #if os(macOS) import AppKit #else import UIKit #endif // MARK: - 共享类型 struct NetworkFileItem: Identifiable, Hashable { let id = UUID() let url: URL let name: String let isDirectory: Bool let fileSize: Int64? let hasMatchingASS: Bool var isMediaFile: Bool { ["mp4","mov","m4v","mkv","avi","mp3","wav","m4a","flac","aac","webm","ts","m3u8"].contains(url.pathExtension.lowercased()) } } struct NetworkFilePanel: View { @ObservedObject var bridge: PlayerBridge @Binding var isPresented: Bool var body: some View { #if os(macOS) NetworkFileBrowserMac(bridge: bridge, isPresented: $isPresented) #else NetworkFileBrowserIOS(bridge: bridge, isPresented: $isPresented) #endif } } // 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] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var connectURL = "" @State private var showConnectSheet = false private var rootItems: [NetworkFileItem] { (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) 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)) 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) }.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 func navigateTo(_ url: URL) { isLoading=true; errorMessage=nil DispatchQueue.global(qos:.userInitiated).async { do { 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() { 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 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 connectToShare() { 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 } } } } } 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))} } struct ConnectSheet: View { @Binding var connectURL: String; let onConnect: () -> Void @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") } } } #endif // MARK: - 共享函数 private func buildFileItems(_ contents: [URL], assNames: Set) -> [NetworkFileItem] { 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:服务器列表 → Files 认证 → 文件选择器播放 #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 streamURL = "" @State private var showFilePicker = false var body: some View { NavigationView { List { Section("Saved Servers") { if savedServers.isEmpty { Text("No saved servers").foregroundColor(.secondary) } ForEach(savedServers, id: \.self) { url in HStack { Image(systemName: "server.rack").foregroundColor(.accentColor) Text(url).foregroundColor(.primary).lineLimit(1).font(.system(size: 13)) Spacer() Button("Connect") { guard let u = URL(string: url) else { return } UIApplication.shared.open(u) }.buttonStyle(.bordered).controlSize(.small) Button("Browse") { showFilePicker = true } .buttonStyle(.borderedProminent).controlSize(.small) } } .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") } // 同时打开 Files 认证 if let u = URL(string: t) { UIApplication.shared.open(u) } smbURL = "" }.disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty) } } Section { Text("First time: Add server → login in Files → back to MiniPlayer → Browse. After that, just Browse.") .font(.caption).foregroundColor(.secondary) } 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) } } } } } #endif