fix(iOS): SMB connect flow — use FileManager first, fallback to Files app. Extract shared buildFileItems helper.
This commit is contained in:
parent
592a23b02e
commit
bf734454f8
@ -21,8 +21,7 @@ struct NetworkFileItem: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 跨平台网络文件入口
|
// MARK: - 跨平台入口
|
||||||
// 显示一个面板:macOS 浏览 /Volumes,iOS 用系统文件选择器
|
|
||||||
struct NetworkFilePanel: View {
|
struct NetworkFilePanel: View {
|
||||||
@ObservedObject var bridge: PlayerBridge
|
@ObservedObject var bridge: PlayerBridge
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ -36,7 +35,7 @@ struct NetworkFilePanel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - macOS 版本:浏览 /Volumes + 手动挂载 SMB/NFS
|
// MARK: - macOS 版本
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
struct NetworkFileBrowserMac: View {
|
struct NetworkFileBrowserMac: View {
|
||||||
@ObservedObject var bridge: PlayerBridge
|
@ObservedObject var bridge: PlayerBridge
|
||||||
@ -47,38 +46,39 @@ struct NetworkFileBrowserMac: View {
|
|||||||
@State private var pathStack: [URL] = []
|
@State private var pathStack: [URL] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
@State private var connectURL = ""
|
@State private var connectURL = ""
|
||||||
@State private var showConnectSheet = false
|
@State private var showConnectSheet = false
|
||||||
|
|
||||||
private var rootItems: [NetworkFileItem] {
|
private var rootItems: [NetworkFileItem] {
|
||||||
let vols = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil,
|
let vols = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil,
|
||||||
options: [.skipHiddenVolumes]) ?? []
|
options: [.skipHiddenVolumes]) ?? []
|
||||||
return vols
|
return vols.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
.map { NetworkFileItem(url: $0, name: $0.lastPathComponent,
|
||||||
.map { url in
|
isDirectory: true, fileSize: nil, hasMatchingASS: false) }
|
||||||
NetworkFileItem(url: url, name: url.lastPathComponent,
|
|
||||||
isDirectory: true, fileSize: nil,
|
|
||||||
hasMatchingASS: false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
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()
|
Divider()
|
||||||
|
|
||||||
if isLoading {
|
if isLoading { Spacer(); ProgressView("Loading..."); Spacer() }
|
||||||
Spacer()
|
else if let e = errorMessage {
|
||||||
ProgressView("Loading...")
|
Spacer(); VStack(spacing: 12) {
|
||||||
Spacer()
|
|
||||||
} else if let error = errorMessage {
|
|
||||||
Spacer()
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundColor(.orange)
|
Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundColor(.orange)
|
||||||
Text(error).foregroundColor(.secondary).multilineTextAlignment(.center)
|
Text(e).foregroundColor(.secondary).multilineTextAlignment(.center)
|
||||||
}.padding()
|
}.padding(); Spacer()
|
||||||
Spacer()
|
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
@ -97,332 +97,82 @@ struct NetworkFileBrowserMac: View {
|
|||||||
.onAppear { if currentURL == nil { navigateToRoot() } }
|
.onAppear { if currentURL == nil { navigateToRoot() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentItems: [NetworkFileItem] {
|
private var currentItems: [NetworkFileItem] { currentURL == nil ? rootItems : items }
|
||||||
currentURL == nil ? rootItems : items
|
|
||||||
}
|
|
||||||
|
|
||||||
private var headerBar: some View {
|
private func navigateToRoot() { pathStack.removeAll(); currentURL = nil; items = []; errorMessage = nil }
|
||||||
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) {
|
private func navigateTo(_ url: URL) {
|
||||||
isLoading = true
|
isLoading = true; errorMessage = nil
|
||||||
errorMessage = nil
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
do {
|
do {
|
||||||
let contents = try FileManager.default.contentsOfDirectory(
|
let contents = try FileManager.default.contentsOfDirectory(
|
||||||
at: url,
|
at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
|
||||||
includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
|
options: [.skipsHiddenFiles])
|
||||||
options: [.skipsHiddenFiles]
|
|
||||||
)
|
|
||||||
|
|
||||||
let assNames = Set(contents
|
let assNames = Set(contents
|
||||||
.filter { $0.pathExtension.lowercased() == "ass" }
|
.filter { $0.pathExtension.lowercased() == "ass" }
|
||||||
.map { $0.deletingPathExtension().lastPathComponent.lowercased() })
|
.map { $0.deletingPathExtension().lastPathComponent.lowercased() })
|
||||||
|
let fileItems = buildFileItems(contents, assNames: assNames)
|
||||||
let fileItems: [NetworkFileItem] = contents
|
DispatchQueue.main.async { self.items = fileItems; self.currentURL = url; self.isLoading = false }
|
||||||
.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 {
|
} catch {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { self.errorMessage = error.localizedDescription; self.isLoading = false }
|
||||||
self.errorMessage = error.localizedDescription
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func goUp() {
|
private func goUp() {
|
||||||
guard let current = currentURL else { return }
|
guard let _ = currentURL else { return }
|
||||||
if pathStack.isEmpty {
|
if pathStack.isEmpty { navigateToRoot() }
|
||||||
navigateToRoot()
|
else { let parent = pathStack.removeLast(); currentURL = nil; DispatchQueue.main.async { self.navigateTo(parent) } }
|
||||||
} else {
|
|
||||||
let parent = pathStack.removeLast()
|
|
||||||
currentURL = nil
|
|
||||||
DispatchQueue.main.async { self.navigateTo(parent) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSelect(_ item: NetworkFileItem) {
|
private func handleSelect(_ item: NetworkFileItem) {
|
||||||
if item.isDirectory {
|
if item.isDirectory { if let cur = currentURL { pathStack.append(cur) }; navigateTo(item.url) }
|
||||||
if let cur = currentURL { pathStack.append(cur) }
|
else if item.isMediaFile { playFile(item) }
|
||||||
navigateTo(item.url)
|
|
||||||
} else if item.isMediaFile {
|
|
||||||
playFile(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playFile(_ item: NetworkFileItem) {
|
private func playFile(_ item: NetworkFileItem) {
|
||||||
bridge.addItem(url: item.url, name: item.name, type: "network")
|
bridge.addItem(url: item.url, name: item.name, type: "network")
|
||||||
if item.hasMatchingASS {
|
if item.hasMatchingASS { bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass")) }
|
||||||
let assURL = item.url.deletingPathExtension().appendingPathExtension("ass")
|
|
||||||
bridge.loadExternalSubtitle(url: assURL)
|
|
||||||
}
|
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func connectToShare() {
|
private func connectToShare() {
|
||||||
let trimmed = connectURL.trimmingCharacters(in: .whitespaces)
|
let trimmed = connectURL.trimmingCharacters(in: .whitespaces)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return }
|
||||||
if let url = URL(string: trimmed) {
|
let config = NSWorkspace.OpenConfiguration()
|
||||||
let config = NSWorkspace.OpenConfiguration()
|
NSWorkspace.shared.open(url, configuration: config) { _, error in
|
||||||
NSWorkspace.shared.open(url, configuration: config) { _, error in
|
if let error = error {
|
||||||
if let error = error {
|
DispatchQueue.main.async { self.errorMessage = "Mount failed: \(error.localizedDescription)" }
|
||||||
DispatchQueue.main.async {
|
} else {
|
||||||
self.errorMessage = "Mount failed: \(error.localizedDescription)"
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
}
|
self.navigateToRoot(); self.showConnectSheet = false
|
||||||
} 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 {
|
struct NetworkFileRow: View {
|
||||||
let item: NetworkFileItem
|
let item: NetworkFileItem; let action: () -> Void
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: iconName)
|
Image(systemName: item.isDirectory ? "folder.fill" : iconFor(item.url))
|
||||||
.font(.system(size: 16)).frame(width: 24)
|
.font(.system(size: 16)).frame(width: 24)
|
||||||
.foregroundColor(item.isDirectory ? .accentColor : .secondary)
|
.foregroundColor(item.isDirectory ? .accentColor : .secondary)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.name).font(.system(size: 13)).foregroundColor(.primary).lineLimit(1)
|
Text(item.name).font(.system(size: 13)).foregroundColor(.primary).lineLimit(1)
|
||||||
if !item.isDirectory, let size = item.fileSize {
|
if !item.isDirectory, let s = item.fileSize { Text(fmtSize(s)).font(.system(size: 10)).foregroundColor(.secondary) }
|
||||||
Text(formatFileSize(size)).font(.system(size: 10)).foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if item.hasMatchingASS { Text("ASS").font(.system(size: 8, weight: .bold)).foregroundColor(.white)
|
||||||
if item.hasMatchingASS {
|
.padding(.horizontal, 4).padding(.vertical, 2).background(Color.green.opacity(0.8)).cornerRadius(3) }
|
||||||
Text("ASS").font(.system(size: 8, weight: .bold)).foregroundColor(.white)
|
if item.isDirectory { Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(.secondary) }
|
||||||
.padding(.horizontal, 4).padding(.vertical, 2)
|
}.padding(.horizontal, 12).padding(.vertical, 6).contentShape(Rectangle())
|
||||||
.background(Color.green.opacity(0.8)).cornerRadius(3)
|
}.buttonStyle(.plain)
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
private var iconName: String {
|
switch u.pathExtension.lowercased() {
|
||||||
if item.isDirectory { return "folder.fill" }
|
|
||||||
switch item.url.pathExtension.lowercased() {
|
|
||||||
case "mp4","mov","m4v","mkv","avi","webm","ts": return "film"
|
case "mp4","mov","m4v","mkv","avi","webm","ts": return "film"
|
||||||
case "mp3","wav","m4a","flac","aac": return "music.note"
|
case "mp3","wav","m4a","flac","aac": return "music.note"
|
||||||
case "m3u8": return "antenna.radiowaves.left.and.right"
|
case "m3u8": return "antenna.radiowaves.left.and.right"
|
||||||
@ -430,63 +180,250 @@ struct NetworkFileRow: View {
|
|||||||
default: return "doc"
|
default: return "doc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private func fmtSize(_ s: Int64) -> String {
|
||||||
private func formatFileSize(_ size: Int64) -> String {
|
if s < 1024 { return "\(s) B" }
|
||||||
if size < 1024 { return "\(size) B" }
|
if s < 1024*1024 { return String(format: "%.1f KB", Double(s)/1024) }
|
||||||
if size < 1024*1024 { return String(format: "%.1f KB", Double(size)/1024) }
|
if s < 1024*1024*1024 { return String(format: "%.1f MB", Double(s)/(1024*1024)) }
|
||||||
if size < 1024*1024*1024 { return String(format: "%.1f MB", Double(size)/(1024*1024)) }
|
return String(format: "%.1f GB", Double(s)/(1024*1024*1024))
|
||||||
return String(format: "%.1f GB", Double(size)/(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
|
#endif
|
||||||
|
|
||||||
// MARK: - 连接输入面板(macOS)
|
// MARK: - 共享工具函数
|
||||||
#if os(macOS)
|
private func buildFileItems(_ contents: [URL], assNames: Set<String>) -> [NetworkFileItem] {
|
||||||
struct ConnectSheet: View {
|
contents.filter { url in
|
||||||
@Binding var connectURL: String
|
let name = url.lastPathComponent
|
||||||
let onConnect: () -> Void
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
NavigationView {
|
||||||
Text("Connect to Network Share").font(.headline)
|
VStack(spacing: 0) {
|
||||||
|
if browseURL != nil { browseView } else { connectView }
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
}
|
||||||
Text("SMB or NFS URL:").font(.caption).foregroundColor(.secondary)
|
.navigationTitle(browseURL != nil ? "Browsing" : "Network Files")
|
||||||
HStack {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
TextField("smb://192.168.1.100/share", text: $connectURL)
|
.toolbar {
|
||||||
.textFieldStyle(.roundedBorder).onSubmit(onConnect)
|
if browseURL != nil {
|
||||||
Button("Connect", action: onConnect).keyboardShortcut(.defaultAction)
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Back") { browseURL = nil; pathStack.removeAll(); items = [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Close") { isPresented = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fileImporter(isPresented: $showFileImporter,
|
||||||
if !recentURLs.isEmpty {
|
allowedContentTypes: [.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3, .wav, .mpeg4Audio],
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
allowsMultipleSelection: false) { result in
|
||||||
Text("Recent:").font(.caption).foregroundColor(.secondary)
|
switch result {
|
||||||
ForEach(recentURLs, id: \.self) { url in
|
case .success(let urls):
|
||||||
Button(url) { connectURL = url; onConnect() }
|
guard let url = urls.first else { return }
|
||||||
.buttonStyle(.link).font(.system(size: 12))
|
let didStart = url.startAccessingSecurityScopedResource()
|
||||||
}
|
bridge.addItem(url: url, name: url.lastPathComponent, type: "network")
|
||||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
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() {
|
private var connectView: some View {
|
||||||
let trimmed = connectURL.trimmingCharacters(in: .whitespaces)
|
ScrollView {
|
||||||
guard !trimmed.isEmpty else { return }
|
VStack(spacing: 20) {
|
||||||
var urls = recentURLs.filter { $0 != trimmed }
|
Spacer().frame(height: 20)
|
||||||
urls.insert(trimmed, at: 0)
|
VStack(spacing: 10) {
|
||||||
let capped = Array(urls.prefix(10))
|
Image(systemName: "network").font(.system(size: 40)).foregroundColor(.accentColor)
|
||||||
recentURLs = capped
|
Text("Connect to Server").font(.title3).fontWeight(.medium)
|
||||||
UserDefaults.standard.set(capped, forKey: "MiniPlayer.recentNetworkURLs")
|
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
|
#endif
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user