MiniPlayer/Sources/NetworkFileBrowser.swift

493 lines
24 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
#if os(iOS)
import UniformTypeIdentifiers
struct NetworkFileBrowserIOS: View {
@ObservedObject var bridge: PlayerBridge
@Binding var isPresented: Bool
@State private var smbURL = ""
@State private var isConnecting = false
@State private var connectError: String?
@State private var browseURL: URL?
@State private var items: [NetworkFileItem] = []
@State private var pathStack: [URL] = []
@State private var isLoading = false
@State private var streamURL = ""
@State private var showFileImporter = false
@State private var copyingFile = false
@State private var copyProgress = ""
var body: some View {
NavigationView {
VStack(spacing: 0) {
if browseURL != nil { browseView } else { connectView }
}
.navigationTitle(browseURL != nil ? "Browsing" : "Network Files")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if copyingFile {
Color.black.opacity(0.6).ignoresSafeArea()
VStack(spacing: 12) {
ProgressView()
Text(copyProgress).foregroundColor(.white)
}
}
}
.toolbar {
if browseURL != nil {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back") { browseURL = nil; pathStack.removeAll(); items = [] }
}
}
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")
let ass = url.deletingPathExtension().appendingPathExtension("ass")
if FileManager.default.fileExists(atPath: ass.path) { bridge.loadExternalSubtitle(url: ass) }
if didStart { url.stopAccessingSecurityScopedResource() }
isPresented = false
case .failure(let e): bridge.showToastMsg(e.localizedDescription)
}
}
}
}
private var connectView: some View {
ScrollView {
VStack(spacing: 20) {
Spacer().frame(height: 20)
VStack(spacing: 10) {
Image(systemName: "network").font(.system(size: 40)).foregroundColor(.accentColor)
Text("Connect to Server").font(.title3).fontWeight(.medium)
VStack(spacing: 8) {
TextField("smb://192.168.1.14/sambashare", text: $smbURL)
.textFieldStyle(.roundedBorder).keyboardType(.URL)
.autocapitalization(.none).disableAutocorrection(true).padding(.horizontal, 20)
Button(action: connectToSMB) {
HStack { if isConnecting { ProgressView().scaleEffect(0.8) }; Text("Connect") }.frame(maxWidth: 280)
}.buttonStyle(.borderedProminent).disabled(smbURL.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting)
if let e = connectError { Text(e).font(.caption).foregroundColor(.red).multilineTextAlignment(.center).padding(.horizontal) }
}
}
Divider().padding(.horizontal, 40)
VStack(spacing: 10) {
Text("Or browse connected servers").font(.subheadline).foregroundColor(.secondary)
Button(action: { showFileImporter = true }) { Label("Browse Files", systemImage: "folder").frame(maxWidth: 280) }.buttonStyle(.bordered)
}
Divider().padding(.horizontal, 40)
VStack(spacing: 10) {
Text("Play stream URL").font(.subheadline).foregroundColor(.secondary)
HStack {
TextField("https://...", text: $streamURL).textFieldStyle(.roundedBorder)
.keyboardType(.URL).autocapitalization(.none).disableAutocorrection(true)
Button("Open") {
let t = streamURL.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty, URL(string: t) != nil else { return }
bridge.addURL(t); isPresented = false
}.buttonStyle(.borderedProminent).disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty)
}.padding(.horizontal, 20)
}
Spacer().frame(height: 20)
}
}
.onAppear { if let last = UserDefaults.standard.string(forKey: "MiniPlayer.lastSMBURL") { smbURL = last } }
}
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?.path ?? "").font(.system(size: 13)).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" : (["mp4","mov","m4v","mkv","avi","webm","ts"].contains(item.url.pathExtension.lowercased()) ? "film" : "doc"))
.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)
}
}
}
private func connectToSMB() {
let t = smbURL.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty, let url = URL(string: t) else { return }
isConnecting = 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.isConnecting = false; UserDefaults.standard.set(t, forKey: "MiniPlayer.lastSMBURL") }
} catch {
DispatchQueue.main.async {
self.isConnecting = false; self.connectError = "Cannot connect directly. Opening Files app..."
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
UIApplication.shared.open(url) { ok in
self.connectError = ok ? "Login in Files app, then come back → Browse Files" : "Failed to open Files app.\nCheck: smb://server/share"
}
}
}
}
}
}
private func goUp() {
guard let cur = browseURL else { return }
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 AVPlayer smb://
if item.url.scheme == "smb" {
copyFromSMBAndPlay(item)
} else {
playMedia(item)
}
}
}
private func playMedia(_ 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 copyFromSMBAndPlay(_ item: NetworkFileItem) {
copyingFile = true
copyProgress = "Copying \(item.name)..."
DispatchQueue.global(qos: .userInitiated).async {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let localURL = docs.appendingPathComponent(item.name)
//
try? FileManager.default.removeItem(at: localURL)
do {
try FileManager.default.copyItem(at: item.url, to: localURL)
// ASS
var assLocal: URL?
if item.hasMatchingASS {
let assURL = item.url.deletingPathExtension().appendingPathExtension("ass")
let assDest = docs.appendingPathComponent(assURL.lastPathComponent)
if (try? FileManager.default.copyItem(at: assURL, to: assDest)) != nil {
assLocal = assDest
}
}
DispatchQueue.main.async {
self.copyingFile = false
bridge.addItem(url: localURL, name: item.name, type: "network")
if let al = assLocal { bridge.loadExternalSubtitle(url: al) }
isPresented = false
}
} catch {
DispatchQueue.main.async {
self.copyingFile = false
self.connectError = "Copy failed: \(error.localizedDescription)"
}
}
}
}
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