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
This commit is contained in:
parent
1412c234f8
commit
0a9ed6e746
@ -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() }
|
||||
|
||||
462
Sources/NetworkFileBrowser.swift
Normal file
462
Sources/NetworkFileBrowser.swift
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user