mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The toolbar Cancel is iOS/macOS only, so while scanning with no shares found yet (and when discovery is unavailable) the tvOS sheet had nothing the remote could focus.
244 lines
7.9 KiB
Swift
244 lines
7.9 KiB
Swift
//
|
|
// NetworkShareDiscoverySheet.swift
|
|
// Yattee
|
|
//
|
|
// Sheet for discovering and selecting network shares (WebDAV/SMB) via Bonjour.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct NetworkShareDiscoverySheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
/// Called when user selects a share.
|
|
let onSelect: (DiscoveredShare) -> Void
|
|
|
|
/// Optional filter to show only specific share types.
|
|
let filterType: DiscoveredShare.ShareType?
|
|
|
|
init(filterType: DiscoveredShare.ShareType? = nil, onSelect: @escaping (DiscoveredShare) -> Void) {
|
|
self.filterType = filterType
|
|
self.onSelect = onSelect
|
|
}
|
|
|
|
private var discoveryService: NetworkShareDiscoveryService? {
|
|
appEnvironment?.networkShareDiscoveryService
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
content
|
|
#if os(tvOS)
|
|
.scrollClipDisabled()
|
|
.padding(.horizontal, 40)
|
|
.padding(.vertical, 40)
|
|
#else
|
|
.navigationTitle(String(localized: "discovery.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: "common.cancel")) {
|
|
discoveryService?.stopDiscovery()
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
.onAppear {
|
|
discoveryService?.startDiscovery()
|
|
}
|
|
.onDisappear {
|
|
discoveryService?.stopDiscovery()
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 450, minHeight: 400)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if let service = discoveryService {
|
|
List {
|
|
// Scanning indicator
|
|
if service.isScanning {
|
|
Section {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "discovery.scanning"))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
#if os(tvOS)
|
|
// The toolbar Cancel is iOS/macOS only. Until a share
|
|
// turns up, this is the only thing the remote can focus.
|
|
Button(String(localized: "common.cancel")) {
|
|
service.stopDiscovery()
|
|
dismiss()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// Filtered shares based on filterType
|
|
let shares = filteredShares(from: service.discoveredShares)
|
|
|
|
if shares.isEmpty && !service.isScanning {
|
|
// Empty state
|
|
Section {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "discovery.empty.title"), systemImage: "network.slash")
|
|
} description: {
|
|
Text(String(localized: "discovery.empty.description"))
|
|
} actions: {
|
|
Button(String(localized: "discovery.scanAgain")) {
|
|
service.startDiscovery()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
} else {
|
|
// Group shares by type
|
|
let groupedShares = Dictionary(grouping: shares) { $0.type }
|
|
|
|
// WebDAV shares (combined webdav and webdavs)
|
|
let webdavShares = (groupedShares[.webdav] ?? []) + (groupedShares[.webdavs] ?? [])
|
|
if !webdavShares.isEmpty {
|
|
Section {
|
|
ForEach(webdavShares) { share in
|
|
ShareRow(share: share) {
|
|
selectShare(share)
|
|
}
|
|
}
|
|
} header: {
|
|
Text(String(localized: "discovery.section.webdav"))
|
|
}
|
|
}
|
|
|
|
// SMB shares
|
|
if let smbShares = groupedShares[.smb], !smbShares.isEmpty {
|
|
Section {
|
|
ForEach(smbShares) { share in
|
|
ShareRow(share: share) {
|
|
selectShare(share)
|
|
}
|
|
}
|
|
} header: {
|
|
Text(String(localized: "discovery.section.smb"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.listStyle(.grouped)
|
|
#endif
|
|
} else {
|
|
// No environment available
|
|
ContentUnavailableView {
|
|
Label(String(localized: "discovery.unavailable.title"), systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(String(localized: "discovery.unavailable.description"))
|
|
} actions: {
|
|
#if os(tvOS)
|
|
Button(String(localized: "common.cancel")) {
|
|
dismiss()
|
|
}
|
|
#endif
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
private func filteredShares(from shares: [DiscoveredShare]) -> [DiscoveredShare] {
|
|
guard let filterType else { return shares }
|
|
|
|
switch filterType {
|
|
case .webdav, .webdavs:
|
|
// When filtering for WebDAV, include both HTTP and HTTPS variants
|
|
return shares.filter { $0.type == .webdav || $0.type == .webdavs }
|
|
case .smb:
|
|
return shares.filter { $0.type == .smb }
|
|
}
|
|
}
|
|
|
|
private func selectShare(_ share: DiscoveredShare) {
|
|
discoveryService?.stopDiscovery()
|
|
onSelect(share)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: - Share Row
|
|
|
|
private struct ShareRow: View {
|
|
let share: DiscoveredShare
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack {
|
|
Image(systemName: share.type.systemImage)
|
|
.foregroundStyle(iconColor)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(share.name)
|
|
.font(.body)
|
|
.foregroundStyle(.primary)
|
|
|
|
Text(addressDisplay)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if share.type == .webdavs {
|
|
Image(systemName: "lock.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.green)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var iconColor: Color {
|
|
switch share.type {
|
|
case .webdav: .blue
|
|
case .webdavs: .green
|
|
case .smb: .orange
|
|
}
|
|
}
|
|
|
|
private var addressDisplay: String {
|
|
var display = share.host
|
|
|
|
if let port = share.port {
|
|
display += ":\(port)"
|
|
}
|
|
|
|
if let path = share.path, !path.isEmpty {
|
|
display += path.hasPrefix("/") ? path : "/\(path)"
|
|
}
|
|
|
|
return display
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NetworkShareDiscoverySheet { _ in }
|
|
.appEnvironment(.preview)
|
|
}
|