207 lines
13 KiB
Swift
207 lines
13 KiB
Swift
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<String>) -> [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:服务器列表 + 系统文件选择器
|
||
#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
|
||
Button(action: { showFilePicker = true }) {
|
||
HStack {
|
||
Image(systemName: "server.rack").foregroundColor(.accentColor)
|
||
Text(url).foregroundColor(.primary).lineLimit(1)
|
||
Spacer()
|
||
Image(systemName: "chevron.right").font(.caption).foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
}
|
||
}
|
||
.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
|