From bf734454f839193e8ccc97a37f905a60800d2481 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 4 Jul 2026 18:06:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(iOS):=20SMB=20connect=20flow=20=E2=80=94=20?= =?UTF-8?q?use=20FileManager=20first,=20fallback=20to=20Files=20app.=20Ext?= =?UTF-8?q?ract=20shared=20buildFileItems=20helper.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/NetworkFileBrowser.swift | 633 ++++++++++++++----------------- 1 file changed, 285 insertions(+), 348 deletions(-) diff --git a/Sources/NetworkFileBrowser.swift b/Sources/NetworkFileBrowser.swift index b25a4ea..c25c1bb 100644 --- a/Sources/NetworkFileBrowser.swift +++ b/Sources/NetworkFileBrowser.swift @@ -21,8 +21,7 @@ struct NetworkFileItem: Identifiable, Hashable { } } -// MARK: - 跨平台网络文件入口 -// 显示一个面板:macOS 浏览 /Volumes,iOS 用系统文件选择器 +// MARK: - 跨平台入口 struct NetworkFilePanel: View { @ObservedObject var bridge: PlayerBridge @Binding var isPresented: Bool @@ -36,7 +35,7 @@ struct NetworkFilePanel: View { } } -// MARK: - macOS 版本:浏览 /Volumes + 手动挂载 SMB/NFS +// MARK: - macOS 版本 #if os(macOS) struct NetworkFileBrowserMac: View { @ObservedObject var bridge: PlayerBridge @@ -47,38 +46,39 @@ struct NetworkFileBrowserMac: View { @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) - } + 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) { - headerBar + 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 error = errorMessage { - Spacer() - VStack(spacing: 12) { + if isLoading { Spacer(); ProgressView("Loading..."); Spacer() } + else if let e = errorMessage { + Spacer(); VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundColor(.orange) - Text(error).foregroundColor(.secondary).multilineTextAlignment(.center) - }.padding() - Spacer() + Text(e).foregroundColor(.secondary).multilineTextAlignment(.center) + }.padding(); Spacer() } else { ScrollView { LazyVStack(spacing: 0) { @@ -97,332 +97,82 @@ struct NetworkFileBrowserMac: View { .onAppear { if currentURL == nil { navigateToRoot() } } } - private var currentItems: [NetworkFileItem] { - currentURL == nil ? rootItems : items - } + 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 navigateToRoot() { pathStack.removeAll(); currentURL = nil; items = []; errorMessage = nil } private func navigateTo(_ url: URL) { - isLoading = true - errorMessage = nil - + isLoading = true; errorMessage = nil DispatchQueue.global(qos: .userInitiated).async { do { let contents = try FileManager.default.contentsOfDirectory( - at: url, - includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], - options: [.skipsHiddenFiles] - ) - + 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 - } + 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 - } + 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) } - } + 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) - } + 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) - } + 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 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 - } + 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 } } } } } -#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 - + let item: NetworkFileItem; let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 8) { - Image(systemName: iconName) + 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 size = item.fileSize { - Text(formatFileSize(size)).font(.system(size: 10)).foregroundColor(.secondary) - } + 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) + 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() { + 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" @@ -430,63 +180,250 @@ struct NetworkFileRow: View { 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)) + 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: - 连接输入面板(macOS) -#if os(macOS) -struct ConnectSheet: View { - @Binding var connectURL: String - let onConnect: () -> Void +// 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 recentURLs: [String] = UserDefaults.standard.stringArray(forKey: "MiniPlayer.recentNetworkURLs") ?? [] + @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 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) + NavigationView { + VStack(spacing: 0) { + if browseURL != nil { browseView } else { connectView } + } + .navigationTitle(browseURL != nil ? "Browsing" : "Network Files") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if browseURL != nil { + ToolbarItem(placement: .navigationBarLeading) { + Button("Back") { browseURL = nil; pathStack.removeAll(); items = [] } + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Close") { isPresented = false } } } - - 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) + .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) + } } - - 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") + 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 { 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 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