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 版本:连接历史 + 内建文件浏览 + HTTP 流播放 #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 = "" 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 { Section("Saved Servers") { ForEach(savedServers, id: \.self) { url in Button(action: { connectToServer(url) }) { HStack { Image(systemName: "server.rack") .foregroundColor(.accentColor) Text(url) .foregroundColor(.primary) .lineLimit(1) Spacer() Image(systemName: "chevron.right") .font(.caption).foregroundColor(.secondary) } } } .onDelete { idx in savedServers.remove(atOffsets: idx) UserDefaults.standard.set(savedServers, forKey: "MiniPlayer.smbServers") } } } // 新增连接 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) } // 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: - 连接逻辑 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 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 // 保存到历史 if !self.savedServers.contains(t) { self.savedServers.insert(t, at: 0) UserDefaults.standard.set(self.savedServers, forKey: "MiniPlayer.smbServers") } } } catch { DispatchQueue.main.async { self.isConnecting = false self.connectError = "Connection failed. Opening Files app..." DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { UIApplication.shared.open(url) { ok in self.connectError = ok ? "Login in Files, then come back." : "Invalid URL" } } } } } } 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 { if item.url.scheme == "smb" { // HTTP 流式播放 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