527 lines
23 KiB
Swift
527 lines
23 KiB
Swift
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 {
|
||
// 如果浏览的根是 SMB 共享,全部走 HTTP 流播放
|
||
let isSMB = browseURL?.scheme == "smb" || item.url.scheme == "smb"
|
||
if isSMB {
|
||
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
|