MiniPlayer/Sources/NetworkFileBrowser.swift
yumoqing 80e332bda0 feat: iOS — saved server list, built-in file browser, HTTP stream server
- LocalStreamServer: NWListener-based HTTP proxy, streams SMB via localhost
- Saved server list with delete, one-tap reconnect
- Removed file importer — all browsing in MiniPlayer
- Removed SMBStreamingLoader (custom scheme approach didn't work)
2026-07-04 19:51:42 +08:00

526 lines
23 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: -
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
#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 { NetworkFileItem(url: $0, name: $0.lastPathComponent,
isDirectory: true, fileSize: nil, hasMatchingASS: false) }
}
var body: some View {
VStack(spacing: 0) {
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 e = errorMessage {
Spacer(); VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundColor(.orange)
Text(e).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 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 = 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 }
}
}
}
private func goUp() {
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) }
}
private func playFile(_ item: NetworkFileItem) {
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 connectToShare() {
let trimmed = connectURL.trimmingCharacters(in: .whitespaces)
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
}
}
}
}
}
struct NetworkFileRow: View {
let item: NetworkFileItem; let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
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 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)
}
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"
case "ass","srt","vtt": return "captions.bubble"
default: return "doc"
}
}
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: -
private func buildFileItems(_ contents: [URL], assNames: Set<String>) -> [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 + + HTTP
#if os(iOS)
import UniformTypeIdentifiers
struct NetworkFileBrowserIOS: View {
@ObservedObject var bridge: PlayerBridge
@Binding var isPresented: Bool
//
@State private var savedServers: [String] = UserDefaults.standard.stringArray(forKey: "MiniPlayer.smbServers") ?? []
@State private var smbURL = ""
@State private var isConnecting = false
//
@State private var browseURL: URL?
@State private var items: [NetworkFileItem] = []
@State private var pathStack: [URL] = []
@State private var isLoading = false
@State private var connectError: String?
// URL
@State private var streamURL = ""
var body: some View {
NavigationView {
VStack(spacing: 0) {
if let _ = browseURL {
browseView
} else {
serverListView
}
}
.navigationTitle(browseURL != nil ? "Browsing" : "Servers")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if browseURL != nil {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { browseURL = nil; pathStack.removeAll(); items = [] }) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Servers")
}
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Close") { isPresented = false }
}
}
}
}
// MARK: -
private var serverListView: some View {
List {
//
if !savedServers.isEmpty {
Section("Saved Servers") {
ForEach(savedServers, id: \.self) { url in
Button(action: { connectToServer(url) }) {
HStack {
Image(systemName: "server.rack")
.foregroundColor(.accentColor)
Text(url)
.foregroundColor(.primary)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.font(.caption).foregroundColor(.secondary)
}
}
}
.onDelete { idx in
savedServers.remove(atOffsets: idx)
UserDefaults.standard.set(savedServers, forKey: "MiniPlayer.smbServers")
}
}
}
//
Section("Connect to Server") {
HStack {
TextField("smb://192.168.1.14/sambashare", text: $smbURL)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
Button(action: { connectToServer(smbURL) }) {
if isConnecting {
ProgressView().scaleEffect(0.8)
} else {
Text("Connect")
}
}
.disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting)
}
if let e = connectError {
Text(e).font(.caption).foregroundColor(.red)
}
}
// URL
Section("Stream URL") {
HStack {
TextField("https://...", text: $streamURL)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
Button("Open") {
let t = streamURL.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty else { return }
bridge.addURL(t); isPresented = false
}
.disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.listStyle(.insetGrouped)
}
// MARK: -
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?.lastPathComponent ?? "")
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
Text(browseURL?.path ?? "")
.font(.system(size: 11)).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" : iconFor(item.url))
.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)
}
}
}
// MARK: -
private func connectToServer(_ urlStr: String) {
let t = urlStr.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty, let url = URL(string: t) else { return }
isConnecting = true; connectError = nil; smbURL = t
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
//
if !self.savedServers.contains(t) {
self.savedServers.insert(t, at: 0)
UserDefaults.standard.set(self.savedServers, forKey: "MiniPlayer.smbServers")
}
}
} catch {
DispatchQueue.main.async {
self.isConnecting = false
self.connectError = "Connection failed. Opening Files app..."
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
UIApplication.shared.open(url) { ok in
self.connectError = ok ? "Login in Files, then come back." : "Invalid URL"
}
}
}
}
}
}
private func goUp() {
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 {
if item.url.scheme == "smb" {
// HTTP
bridge.playSMBSource(url: item.url, name: item.name)
if item.hasMatchingASS {
bridge.loadExternalSubtitle(url: item.url.deletingPathExtension().appendingPathExtension("ass"))
}
isPresented = false
} else {
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 iconFor(_ url: URL) -> String {
switch 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 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