MiniPlayer/Sources/NetworkFileBrowser.swift

493 lines
19 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
#else
import UIKit
#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) {
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
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