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 { let ext = url.pathExtension.lowercased() return ["mp4", "mov", "m4v", "mkv", "avi", "mp3", "wav", "m4a", "flac", "aac", "webm", "ts", "m3u8"].contains(ext) } } // MARK: - 跨平台入口 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] { 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) } } 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).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() } } } 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 { 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 } } } } 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 handleSelect(_ item: NetworkFileItem) { if item.isDirectory { if let cur = currentURL { pathStack.append(cur) }; navigateTo(item.url) } else if item.isMediaFile { playFile(item) } } 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 } } } } } 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 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)) } } // MARK: - iOS 版本 #if os(iOS) import UniformTypeIdentifiers struct NetworkFileBrowserIOS: View { @ObservedObject var bridge: PlayerBridge @Binding var isPresented: Bool @State private var smbURL = "" @State private var isConnecting = false @State private var connectError: String? @State private var browseURL: URL? @State private var items: [NetworkFileItem] = [] @State private var pathStack: [URL] = [] @State private var isLoading = false @State private var streamURL = "" @State private var showFileImporter = false @State private var copyingFile = false @State private var copyProgress = "" var body: some View { NavigationView { VStack(spacing: 0) { if browseURL != nil { browseView } else { connectView } } .navigationTitle(browseURL != nil ? "Browsing" : "Network Files") .navigationBarTitleDisplayMode(.inline) .overlay { if copyingFile { Color.black.opacity(0.6).ignoresSafeArea() VStack(spacing: 12) { ProgressView() Text(copyProgress).foregroundColor(.white) } } } .toolbar { if browseURL != nil { ToolbarItem(placement: .navigationBarLeading) { Button("Back") { browseURL = nil; pathStack.removeAll(); items = [] } } } ToolbarItem(placement: .navigationBarTrailing) { Button("Close") { isPresented = false } } } .fileImporter(isPresented: $showFileImporter, 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) } } } } private var connectView: some View { ScrollView { VStack(spacing: 20) { Spacer().frame(height: 20) VStack(spacing: 10) { Image(systemName: "network").font(.system(size: 40)).foregroundColor(.accentColor) Text("Connect to Server").font(.title3).fontWeight(.medium) VStack(spacing: 8) { TextField("smb://192.168.1.14/sambashare", text: $smbURL) .textFieldStyle(.roundedBorder).keyboardType(.URL) .autocapitalization(.none).disableAutocorrection(true).padding(.horizontal, 20) Button(action: connectToSMB) { HStack { if isConnecting { ProgressView().scaleEffect(0.8) }; Text("Connect") }.frame(maxWidth: 280) }.buttonStyle(.borderedProminent).disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting) if let e = connectError { Text(e).font(.caption).foregroundColor(.red).multilineTextAlignment(.center).padding(.horizontal) } } } Divider().padding(.horizontal, 40) VStack(spacing: 10) { Text("Or browse connected servers").font(.subheadline).foregroundColor(.secondary) Button(action: { showFileImporter = true }) { Label("Browse Files", systemImage: "folder").frame(maxWidth: 280) }.buttonStyle(.bordered) } Divider().padding(.horizontal, 40) VStack(spacing: 10) { Text("Play stream URL").font(.subheadline).foregroundColor(.secondary) HStack { TextField("https://...", text: $streamURL).textFieldStyle(.roundedBorder) .keyboardType(.URL).autocapitalization(.none).disableAutocorrection(true) Button("Open") { let t = streamURL.trimmingCharacters(in: .whitespaces) guard !t.isEmpty, URL(string: t) != nil else { return } bridge.addURL(t); isPresented = false }.buttonStyle(.borderedProminent).disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty) }.padding(.horizontal, 20) } Spacer().frame(height: 20) } } .onAppear { if let last = UserDefaults.standard.string(forKey: "MiniPlayer.lastSMBURL") { smbURL = last } } } 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?.path ?? "").font(.system(size: 13)).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" : (["mp4","mov","m4v","mkv","avi","webm","ts"].contains(item.url.pathExtension.lowercased()) ? "film" : "doc")) .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) } } } private func connectToSMB() { let t = smbURL.trimmingCharacters(in: .whitespaces) guard !t.isEmpty, let url = URL(string: t) else { return } isConnecting = 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.isConnecting = false; UserDefaults.standard.set(t, forKey: "MiniPlayer.lastSMBURL") } } catch { DispatchQueue.main.async { self.isConnecting = false; self.connectError = "Cannot connect directly. Opening Files app..." DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { UIApplication.shared.open(url) { ok in self.connectError = ok ? "Login in Files app, then come back → Browse Files" : "Failed to open Files app.\nCheck: smb://server/share" } } } } } } private func goUp() { guard let cur = browseURL else { return } 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 文件需要复制到本地,AVPlayer 不认 smb:// 协议 if item.url.scheme == "smb" { copyFromSMBAndPlay(item) } else { playMedia(item) } } } private func playMedia(_ 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 copyFromSMBAndPlay(_ item: NetworkFileItem) { copyingFile = true copyProgress = "Copying \(item.name)..." DispatchQueue.global(qos: .userInitiated).async { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let localURL = docs.appendingPathComponent(item.name) // 删除旧副本 try? FileManager.default.removeItem(at: localURL) do { try FileManager.default.copyItem(at: item.url, to: localURL) // 同时复制同名 ASS var assLocal: URL? if item.hasMatchingASS { let assURL = item.url.deletingPathExtension().appendingPathExtension("ass") let assDest = docs.appendingPathComponent(assURL.lastPathComponent) if (try? FileManager.default.copyItem(at: assURL, to: assDest)) != nil { assLocal = assDest } } DispatchQueue.main.async { self.copyingFile = false bridge.addItem(url: localURL, name: item.name, type: "network") if let al = assLocal { bridge.loadExternalSubtitle(url: al) } isPresented = false } } catch { DispatchQueue.main.async { self.copyingFile = false self.connectError = "Copy failed: \(error.localizedDescription)" } } } } 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