From 0a9ed6e7464da09ecc550b228413adbcbc654803 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 4 Jul 2026 17:33:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20network=20file=20browser=20=E2=80=94=20?= =?UTF-8?q?browse=20SMB/NFS=20shares,=20auto-pair=20ASS=20subtitles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - macOS: browse /Volumes (mounted shares), manual SMB/NFS connect - iOS: system file picker (Files app SMB) + manual URL entry - Auto-detect matching .ass files when selecting media - Network button in control toolbar - loadExternalSubtitle() skeleton for future ASS parsing --- Sources/MiniPlayerApp.swift | 4 + Sources/NetworkFileBrowser.swift | 462 +++++++++++++++++++++++++++++++ Sources/PlayerBridge.swift | 16 ++ 3 files changed, 482 insertions(+) create mode 100644 Sources/NetworkFileBrowser.swift diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 23ea9df..38bb925 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -568,6 +568,9 @@ struct BricksAppView: View { .sheet(isPresented: $bridge.showLibrarySheet) { MediaLibraryView(library: bridge.library, bridge: bridge) } + .sheet(isPresented: $bridge.showNetworkBrowser) { + NetworkFilePanel(bridge: bridge, isPresented: $bridge.showNetworkBrowser) + } #if os(iOS) .fileImporter( isPresented: $bridge.showFileImporter, @@ -662,6 +665,7 @@ struct ControlToolbar: View { Divider().frame(height: 20).padding(.horizontal, 4) TB(icon: "folder") { bridge.openFileDialog(); onTouch() } + TB(icon: "network") { bridge.toggleNetworkBrowser(); onTouch() } TB(icon: "link") { bridge.showURLDialog = true; onTouch() } #if os(macOS) TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() } diff --git a/Sources/NetworkFileBrowser.swift b/Sources/NetworkFileBrowser.swift new file mode 100644 index 0000000..b30cb9e --- /dev/null +++ b/Sources/NetworkFileBrowser.swift @@ -0,0 +1,462 @@ +import SwiftUI +import AVFoundation +#if os(macOS) +import AppKit +#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) { + NSWorkspace.shared.open(url) { _, 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 + + 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://... or smb://...", text: $manualURL) + .textFieldStyle(.roundedBorder) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + + Button("Open") { + guard let url = URL(string: manualURL.trimmingCharacters(in: .whitespaces)), + !manualURL.trimmingCharacters(in: .whitespaces).isEmpty else { 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 } + } + } + .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 diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index a8427bd..f407eaf 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -77,6 +77,7 @@ final class PlayerBridge: ObservableObject { @Published var showQRScanner = false @Published var showLibrarySheet = false @Published var showFileImporter = false + @Published var showNetworkBrowser = false #if os(macOS) var qrScannerController: QRScannerWindowController? #endif @@ -518,6 +519,21 @@ final class PlayerBridge: ObservableObject { currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" } + // MARK: - 外部字幕加载(骨架,后续 ASS 解析在此接入) + /// 存储当前加载的外部字幕文件 URL + @Published var externalSubtitleURL: URL? + + func loadExternalSubtitle(url: URL) { + externalSubtitleURL = url + diagLog("External subtitle loaded: \(url.lastPathComponent)") + showToast("📝 \(url.lastPathComponent)") + } + + // MARK: - 网络文件浏览 + func toggleNetworkBrowser() { + showNetworkBrowser = true + } + // MARK: - 循环模式 func cycleRepeatMode() { repeatMode = repeatMode.next