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: - 跨平台网络文件入口 // 显示一个面板:macOS 浏览 /Volumes,iOS 用系统文件选择器 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 版本:浏览 /Volumes + 手动挂载 SMB/NFS #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 { url in NetworkFileItem(url: url, name: url.lastPathComponent, isDirectory: true, fileSize: nil, hasMatchingASS: false) } } var body: some View { VStack(spacing: 0) { headerBar Divider() if isLoading { Spacer() ProgressView("Loading...") Spacer() } else if let error = errorMessage { Spacer() VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundColor(.orange) Text(error).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 var headerBar: some View { 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(currentPathDisplay) .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 to network share (SMB/NFS)") } .padding(.horizontal, 12).padding(.vertical, 8) .background(Color(NSColor.controlBackgroundColor)) } private var currentPathDisplay: String { currentURL?.path ?? "/Volumes" } 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: [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 } let ext = url.pathExtension.lowercased() let mediaExts = Set(["mp4","mov","m4v","mkv","avi","mp3","wav","m4a","flac","aac","webm","ts","m3u8","ass","srt","vtt"]) return mediaExts.contains(ext) } .sorted { a, b in var aDir: ObjCBool = false, bDir: ObjCBool = false FileManager.default.fileExists(atPath: a.path, isDirectory: &aDir) FileManager.default.fileExists(atPath: b.path, isDirectory: &bDir) if aDir.boolValue != bDir.boolValue { return aDir.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 baseName = url.deletingPathExtension().lastPathComponent.lowercased() let hasASS = assNames.contains(baseName) let fileSize: Int64? = isDir.boolValue ? nil : { (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map(Int64.init) }() return NetworkFileItem(url: url, name: url.lastPathComponent, isDirectory: isDir.boolValue, fileSize: fileSize, hasMatchingASS: hasASS) } 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 current = 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 { let assURL = item.url.deletingPathExtension().appendingPathExtension("ass") bridge.loadExternalSubtitle(url: assURL) } isPresented = false } private func connectToShare() { let trimmed = connectURL.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } if let url = URL(string: trimmed) { 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 } } } } } } #endif // MARK: - iOS 版本:系统文件选择器 + 手动 URL #if os(iOS) import UniformTypeIdentifiers struct NetworkFileBrowserIOS: View { @ObservedObject var bridge: PlayerBridge @Binding var isPresented: Bool @State private var manualURL = "" @State private var showFileImporter = false @State private var showSMBAlert = false @State private var smbAlertURL = "" private func openInFiles(url: URL) { smbAlertURL = url.absoluteString showSMBAlert = true } private func openFilesApp() { // 打开 Files 应用 if let filesURL = URL(string: "shareddocuments://") { UIApplication.shared.open(filesURL) } } var body: some View { NavigationView { VStack(spacing: 24) { Spacer() // 打开系统文件选择器 VStack(spacing: 12) { Image(systemName: "folder.badge.questionmark") .font(.system(size: 48)) .foregroundColor(.accentColor) Text("Browse Network Files") .font(.title3) .fontWeight(.medium) Text("Open your connected servers from the Files app.\nSMB/NFS shares appear in the sidebar.") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) Button(action: { showFileImporter = true }) { Label("Browse Files", systemImage: "folder") .frame(maxWidth: 280) } .buttonStyle(.borderedProminent) .controlSize(.large) } Divider().padding(.horizontal, 40) // 手动输入 URL VStack(spacing: 12) { Text("Or enter a direct URL") .font(.headline) HStack { TextField("https://... 播放流地址", text: $manualURL) .textFieldStyle(.roundedBorder) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) Button("Open") { let trimmed = manualURL.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return } // SMB/NFS 地址不能直接播放,引导去 Files 连接 if url.scheme == "smb" || url.scheme == "nfs" { openInFiles(url: url) return } let name = url.lastPathComponent.isEmpty ? (url.host ?? "Stream") : url.lastPathComponent bridge.addURL(manualURL) isPresented = false } .buttonStyle(.borderedProminent) .disabled(manualURL.trimmingCharacters(in: .whitespaces).isEmpty) } .padding(.horizontal, 40) Text("Tip: Add SMB servers in Files → ... → Connect to Server") .font(.caption2) .foregroundColor(.secondary) } Spacer() } .navigationTitle("Network Files") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Close") { isPresented = false } } } .alert("SMB/NFS Share", isPresented: $showSMBAlert) { Button("Open Files App") { openFilesApp() } Button("Cancel", role: .cancel) {} } message: { Text("To access SMB/NFS shares on iPhone:\n\n1. Open the Files app\n2. Tap ⋯ → Connect to Server\n3. Enter: \(smbAlertURL)\n4. Login with your credentials\n\nThen come back and use 'Browse Files'.") } .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") // 检查同名 ASS let assURL = url.deletingPathExtension().appendingPathExtension("ass") if FileManager.default.fileExists(atPath: assURL.path) { bridge.loadExternalSubtitle(url: assURL) } if didStart { url.stopAccessingSecurityScopedResource() } isPresented = false case .failure(let error): bridge.showToastMsg(error.localizedDescription) } } } } } #endif // MARK: - 文件行(macOS 用) #if os(macOS) struct NetworkFileRow: View { let item: NetworkFileItem let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 8) { Image(systemName: iconName) .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 size = item.fileSize { Text(formatFileSize(size)).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 var iconName: String { if item.isDirectory { return "folder.fill" } switch item.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 formatFileSize(_ size: Int64) -> String { if size < 1024 { return "\(size) B" } if size < 1024*1024 { return String(format: "%.1f KB", Double(size)/1024) } if size < 1024*1024*1024 { return String(format: "%.1f MB", Double(size)/(1024*1024)) } return String(format: "%.1f GB", Double(size)/(1024*1024*1024)) } } #endif // MARK: - 连接输入面板(macOS) #if os(macOS) struct ConnectSheet: View { @Binding var connectURL: String let onConnect: () -> Void @State private var recentURLs: [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 !recentURLs.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Recent:").font(.caption).foregroundColor(.secondary) ForEach(recentURLs, id: \.self) { url in Button(url) { connectURL = url; onConnect() } .buttonStyle(.link).font(.system(size: 12)) } }.frame(maxWidth: .infinity, alignment: .leading) } Text("Examples:\nsmb://server-name/share\nnfs://server:/export/path") .font(.system(size: 11)).foregroundColor(.secondary) .multilineTextAlignment(.center) } .padding(24).frame(width: 420) .onDisappear { saveRecent() } } private func saveRecent() { let trimmed = connectURL.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } var urls = recentURLs.filter { $0 != trimmed } urls.insert(trimmed, at: 0) let capped = Array(urls.prefix(10)) recentURLs = capped UserDefaults.standard.set(capped, forKey: "MiniPlayer.recentNetworkURLs") } } #endif