MiniPlayer/Sources/NetworkFileBrowser.swift
yumoqing 0a9ed6e746 feat: network file browser — browse SMB/NFS shares, auto-pair ASS subtitles
- 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
2026-07-04 17:33:58 +08:00

463 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 /VolumesiOS
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