MiniPlayer/Sources/NetworkFileBrowser.swift

215 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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