mirror of
https://github.com/yattee/yattee.git
synced 2026-04-11 01:56:57 +00:00
Yattee v2 rewrite
This commit is contained in:
178
Yattee/Views/Settings/AddSource/AddLocalFolderView.swift
Normal file
178
Yattee/Views/Settings/AddSource/AddLocalFolderView.swift
Normal file
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// AddLocalFolderView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for adding a local folder as a media source.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if !os(tvOS)
|
||||
struct AddLocalFolderView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
// MARK: - State
|
||||
|
||||
@State private var name = ""
|
||||
@State private var selectedFolderURL: URL?
|
||||
@State private var testResult: SourceTestResult?
|
||||
|
||||
#if os(iOS)
|
||||
@State private var showingFolderPicker = false
|
||||
#endif
|
||||
|
||||
// Closure to dismiss the parent sheet
|
||||
var dismissSheet: DismissAction?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var canAdd: Bool {
|
||||
!name.isEmpty && selectedFolderURL != nil
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
nameSection
|
||||
folderSection
|
||||
|
||||
if let result = testResult {
|
||||
SourceTestResultSection(result: result)
|
||||
}
|
||||
|
||||
actionSection
|
||||
}
|
||||
.navigationTitle(String(localized: "sources.addLocalFolder"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showingFolderPicker) {
|
||||
FolderPickerView { url in
|
||||
handleFolderSelection(url)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var nameSection: some View {
|
||||
Section {
|
||||
TextField(String(localized: "sources.field.name"), text: $name)
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.displayName"))
|
||||
}
|
||||
}
|
||||
|
||||
private var folderSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
Button {
|
||||
showingFolderPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
if let url = selectedFolderURL {
|
||||
Label(url.lastPathComponent, systemImage: "folder.fill")
|
||||
} else {
|
||||
Label(String(localized: "sources.selectFolder"), systemImage: "folder.badge.plus")
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button {
|
||||
selectFolderMacOS()
|
||||
} label: {
|
||||
HStack {
|
||||
if let url = selectedFolderURL {
|
||||
Label(url.lastPathComponent, systemImage: "folder.fill")
|
||||
} else {
|
||||
Label(String(localized: "sources.selectFolder"), systemImage: "folder.badge.plus")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.folder"))
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.folder"))
|
||||
}
|
||||
}
|
||||
|
||||
private var actionSection: some View {
|
||||
Section {
|
||||
Button {
|
||||
addSource()
|
||||
} label: {
|
||||
Text(String(localized: "sources.addSource"))
|
||||
}
|
||||
.disabled(!canAdd)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func handleFolderSelection(_ url: URL) {
|
||||
selectedFolderURL = url
|
||||
if name.isEmpty {
|
||||
name = url.lastPathComponent
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func selectFolderMacOS() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = false
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
handleFolderSelection(url)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func addSource() {
|
||||
guard let appEnvironment,
|
||||
let url = selectedFolderURL else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
let bookmarkData = try await appEnvironment.localFileClient.createBookmark(for: url)
|
||||
|
||||
await MainActor.run {
|
||||
let source = MediaSource.localFolder(
|
||||
name: name,
|
||||
url: url,
|
||||
bookmarkData: bookmarkData
|
||||
)
|
||||
appEnvironment.mediaSourcesManager.add(source)
|
||||
if let dismissSheet {
|
||||
dismissSheet()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
testResult = .failure(String(localized: "sources.error.folderAccess \(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AddLocalFolderView()
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
608
Yattee/Views/Settings/AddSource/AddRemoteServerView.swift
Normal file
608
Yattee/Views/Settings/AddSource/AddRemoteServerView.swift
Normal file
@@ -0,0 +1,608 @@
|
||||
//
|
||||
// AddRemoteServerView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for adding a remote server (Invidious, Piped, PeerTube, Yattee Server) as a source.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - UI State Machine
|
||||
|
||||
/// Represents the current state of the Add Remote Server view.
|
||||
private enum RemoteServerUIState: Equatable {
|
||||
/// Initial state: URL field visible, manual fields hidden
|
||||
case initial
|
||||
/// Detection in progress: skeleton loading visible
|
||||
case detecting
|
||||
/// Detection succeeded: fields revealed with pre-filled values
|
||||
case detected(InstanceType)
|
||||
/// Detection failed: fields auto-revealed with error message
|
||||
case error(String)
|
||||
|
||||
static func == (lhs: RemoteServerUIState, rhs: RemoteServerUIState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.initial, .initial): return true
|
||||
case (.detecting, .detecting): return true
|
||||
case (.detected(let a), .detected(let b)): return a == b
|
||||
case (.error(let a), .error(let b)): return a == b
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddRemoteServerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
// MARK: - UI State
|
||||
|
||||
@State private var uiState: RemoteServerUIState = .initial
|
||||
@State private var detectionTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - URL Entry
|
||||
|
||||
@State private var urlString = ""
|
||||
|
||||
// MARK: - Server Configuration
|
||||
|
||||
@State private var name = ""
|
||||
@State private var detectedType: InstanceType?
|
||||
@State private var detectionResult: InstanceDetectionResult?
|
||||
@State private var allowInvalidCertificates = false
|
||||
@State private var showSSLToggle = false
|
||||
|
||||
// Yattee Server authentication (always required)
|
||||
@State private var yatteeServerUsername = ""
|
||||
@State private var yatteeServerPassword = ""
|
||||
@State private var isValidatingCredentials = false
|
||||
@State private var credentialValidationError: String?
|
||||
|
||||
// Yattee Server warning dialog
|
||||
@State private var showingYatteeServerWarning = false
|
||||
@State private var pendingYatteeServerInstance: Instance?
|
||||
|
||||
// Closure to dismiss the parent sheet
|
||||
var dismissSheet: DismissAction?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var isFieldsRevealed: Bool {
|
||||
switch uiState {
|
||||
case .initial, .detecting:
|
||||
return false
|
||||
case .detected, .error:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var canAdd: Bool {
|
||||
guard !urlString.isEmpty else { return false }
|
||||
|
||||
// For detected Yattee Server, require credentials
|
||||
if detectedType == .yatteeServer {
|
||||
return !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
VStack(spacing: 0) {
|
||||
formContent
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "sources.yatteeServer.warning.title"),
|
||||
isPresented: $showingYatteeServerWarning,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
yatteeServerWarningButtons
|
||||
} message: {
|
||||
Text(String(localized: "sources.yatteeServer.warning.message"))
|
||||
}
|
||||
#else
|
||||
formContent
|
||||
.navigationTitle(String(localized: "sources.addRemoteServer"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.confirmationDialog(
|
||||
String(localized: "sources.yatteeServer.warning.title"),
|
||||
isPresented: $showingYatteeServerWarning,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
yatteeServerWarningButtons
|
||||
} message: {
|
||||
Text(String(localized: "sources.yatteeServer.warning.message"))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var yatteeServerWarningButtons: some View {
|
||||
Button(String(localized: "sources.yatteeServer.warning.disableOthers"), role: .destructive) {
|
||||
if let instance = pendingYatteeServerInstance {
|
||||
appEnvironment?.instancesManager.disableOtherYatteeServerInstances(except: instance.id)
|
||||
appEnvironment?.instancesManager.add(instance)
|
||||
if let dismissSheet {
|
||||
dismissSheet()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||
pendingYatteeServerInstance = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form Content
|
||||
|
||||
private var formContent: some View {
|
||||
Form {
|
||||
urlEntrySection
|
||||
|
||||
if case .detecting = uiState {
|
||||
skeletonSection
|
||||
}
|
||||
|
||||
if case .error(let message) = uiState {
|
||||
errorSection(message)
|
||||
}
|
||||
|
||||
if isFieldsRevealed {
|
||||
serverConfigurationFields
|
||||
actionSection
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - URL Entry Section
|
||||
|
||||
private var urlEntrySection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.placeholder.urlOrAddress"), text: $urlString)
|
||||
.onChange(of: urlString) { _, _ in
|
||||
handleURLChange()
|
||||
}
|
||||
|
||||
if !isFieldsRevealed {
|
||||
Button {
|
||||
startDetection()
|
||||
} label: {
|
||||
if case .detecting = uiState {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text(String(localized: "sources.detecting"))
|
||||
}
|
||||
} else {
|
||||
Text(String(localized: "sources.detect"))
|
||||
}
|
||||
}
|
||||
.buttonStyle(TVSettingsButtonStyle())
|
||||
.disabled(urlString.isEmpty || uiState == .detecting)
|
||||
}
|
||||
#else
|
||||
TextField(String(localized: "sources.placeholder.urlOrAddress"), text: $urlString)
|
||||
.textContentType(.URL)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("addRemoteServer.urlField")
|
||||
.onChange(of: urlString) { _, _ in
|
||||
handleURLChange()
|
||||
}
|
||||
|
||||
if !isFieldsRevealed {
|
||||
Button {
|
||||
startDetection()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if case .detecting = uiState {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(String(localized: "sources.detect"))
|
||||
}
|
||||
}
|
||||
.disabled(urlString.isEmpty || uiState == .detecting)
|
||||
.accessibilityIdentifier("addRemoteServer.detectButton")
|
||||
}
|
||||
#endif
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.remoteServer"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skeleton Loading Section
|
||||
|
||||
private var skeletonSection: some View {
|
||||
Group {
|
||||
Section {
|
||||
Text("my-server.example.com")
|
||||
.redacted(reason: .placeholder)
|
||||
} header: {
|
||||
Text(String(localized: "sources.detecting"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Text(String(localized: "sources.field.name"))
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Section
|
||||
|
||||
private func errorSection(_ message: String) -> some View {
|
||||
Section {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityIdentifier("addRemoteServer.detectionError")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server Configuration Fields
|
||||
|
||||
@ViewBuilder
|
||||
private var serverConfigurationFields: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.nameOptional"), text: $name)
|
||||
#else
|
||||
TextField(String(localized: "sources.field.nameOptional"), text: $name)
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.displayName"))
|
||||
}
|
||||
|
||||
// Show detected type badge
|
||||
if let detectedType {
|
||||
Section {
|
||||
HStack {
|
||||
Label(detectedType.displayName, systemImage: detectedType.systemImage)
|
||||
.foregroundStyle(.green)
|
||||
Spacer()
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
.accessibilityIdentifier("addRemoteServer.detectedType")
|
||||
} header: {
|
||||
Text(String(localized: "sources.detectedType"))
|
||||
}
|
||||
}
|
||||
|
||||
// SSL Certificate toggle (show if SSL error occurred)
|
||||
if showSSLToggle {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsToggle(
|
||||
title: String(localized: "sources.field.allowInvalidCertificates"),
|
||||
isOn: $allowInvalidCertificates
|
||||
)
|
||||
#else
|
||||
Toggle(String(localized: "sources.field.allowInvalidCertificates"), isOn: $allowInvalidCertificates)
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.security"))
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.allowInvalidCertificates"))
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication fields for Yattee Server (always required)
|
||||
if detectedType == .yatteeServer {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $yatteeServerUsername)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $yatteeServerPassword, isSecure: true)
|
||||
#else
|
||||
TextField(String(localized: "sources.field.username"), text: $yatteeServerUsername)
|
||||
.textContentType(.username)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField(String(localized: "sources.field.password"), text: $yatteeServerPassword)
|
||||
.textContentType(.password)
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.auth"))
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.yatteeServerAuth"))
|
||||
}
|
||||
|
||||
if let error = credentialValidationError {
|
||||
Section {
|
||||
Label(error, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Section
|
||||
|
||||
private var actionSection: some View {
|
||||
Section {
|
||||
Button {
|
||||
addSource()
|
||||
} label: {
|
||||
if isValidatingCredentials {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text(String(localized: "sources.validatingCredentials"))
|
||||
}
|
||||
} else {
|
||||
Text(String(localized: "sources.addSource"))
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("addRemoteServer.actionButton")
|
||||
.disabled(!canAdd || isValidatingCredentials)
|
||||
#if os(tvOS)
|
||||
.buttonStyle(TVSettingsButtonStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func handleURLChange() {
|
||||
cancelDetection()
|
||||
|
||||
if isFieldsRevealed {
|
||||
withAnimation {
|
||||
uiState = .initial
|
||||
detectedType = nil
|
||||
detectionResult = nil
|
||||
showSSLToggle = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelDetection() {
|
||||
detectionTask?.cancel()
|
||||
detectionTask = nil
|
||||
}
|
||||
|
||||
private func startDetection() {
|
||||
guard !urlString.isEmpty else { return }
|
||||
|
||||
guard let url = Instance.normalizeSourceURL(urlString) else {
|
||||
withAnimation {
|
||||
uiState = .error(String(localized: "sources.validation.invalidURL"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cancelDetection()
|
||||
|
||||
withAnimation {
|
||||
uiState = .detecting
|
||||
}
|
||||
|
||||
detectionTask = Task {
|
||||
await performDetection(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func performDetection(url: URL) async {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
let detector: InstanceDetector
|
||||
if allowInvalidCertificates {
|
||||
let insecureClient = appEnvironment.httpClientFactory.createClient(allowInvalidCertificates: true)
|
||||
detector = InstanceDetector(httpClient: insecureClient)
|
||||
} else {
|
||||
detector = appEnvironment.instanceDetector
|
||||
}
|
||||
|
||||
let result = await detector.detectWithResult(url: url)
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
await MainActor.run {
|
||||
switch result {
|
||||
case .success(let detectionResult):
|
||||
LoggingService.shared.debug("[AddRemoteServerView] Detection succeeded: \(detectionResult.type)", category: .api)
|
||||
withAnimation {
|
||||
self.detectedType = detectionResult.type
|
||||
self.detectionResult = detectionResult
|
||||
self.uiState = .detected(detectionResult.type)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
LoggingService.shared.debug("[AddRemoteServerView] Detection failed: \(error)", category: .api)
|
||||
withAnimation {
|
||||
if case .sslCertificateError = error {
|
||||
self.showSSLToggle = true
|
||||
}
|
||||
self.uiState = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSource() {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
guard let url = Instance.normalizeSourceURL(urlString) else {
|
||||
withAnimation {
|
||||
uiState = .error(String(localized: "sources.validation.invalidURL"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If we have a detected type, use it directly
|
||||
if let detectedType {
|
||||
addServer(type: detectedType, url: url, appEnvironment: appEnvironment)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, detect first then add
|
||||
withAnimation {
|
||||
uiState = .detecting
|
||||
}
|
||||
|
||||
detectionTask = Task {
|
||||
let detector: InstanceDetector
|
||||
if allowInvalidCertificates {
|
||||
let insecureClient = appEnvironment.httpClientFactory.createClient(allowInvalidCertificates: true)
|
||||
detector = InstanceDetector(httpClient: insecureClient)
|
||||
} else {
|
||||
detector = appEnvironment.instanceDetector
|
||||
}
|
||||
|
||||
let result = await detector.detectWithResult(url: url)
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
await MainActor.run {
|
||||
switch result {
|
||||
case .success(let detectionResult):
|
||||
self.detectedType = detectionResult.type
|
||||
self.detectionResult = detectionResult
|
||||
|
||||
// For Yattee Server, show auth fields instead of auto-adding
|
||||
if detectionResult.type == .yatteeServer {
|
||||
withAnimation {
|
||||
self.uiState = .detected(detectionResult.type)
|
||||
}
|
||||
} else {
|
||||
// Auto-add for other types
|
||||
addServer(type: detectionResult.type, url: url, appEnvironment: appEnvironment)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
withAnimation {
|
||||
if case .sslCertificateError = error {
|
||||
self.showSSLToggle = true
|
||||
}
|
||||
self.uiState = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addServer(type: InstanceType, url: URL, appEnvironment: AppEnvironment) {
|
||||
// For Yattee Server, always validate credentials first
|
||||
if type == .yatteeServer {
|
||||
guard !yatteeServerUsername.isEmpty, !yatteeServerPassword.isEmpty else {
|
||||
credentialValidationError = String(localized: "sources.error.credentialsRequired")
|
||||
return
|
||||
}
|
||||
|
||||
isValidatingCredentials = true
|
||||
credentialValidationError = nil
|
||||
|
||||
Task {
|
||||
let isValid = await validateYatteeServerCredentials(
|
||||
url: url,
|
||||
username: yatteeServerUsername,
|
||||
password: yatteeServerPassword,
|
||||
appEnvironment: appEnvironment
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
isValidatingCredentials = false
|
||||
|
||||
if isValid {
|
||||
let instance = Instance(
|
||||
type: type,
|
||||
url: url,
|
||||
name: name.isEmpty ? nil : name,
|
||||
allowInvalidCertificates: allowInvalidCertificates
|
||||
)
|
||||
|
||||
appEnvironment.yatteeServerCredentialsManager.setCredentials(
|
||||
username: yatteeServerUsername,
|
||||
password: yatteeServerPassword,
|
||||
for: instance
|
||||
)
|
||||
|
||||
if !appEnvironment.instancesManager.enabledYatteeServerInstances.isEmpty {
|
||||
pendingYatteeServerInstance = instance
|
||||
showingYatteeServerWarning = true
|
||||
} else {
|
||||
appEnvironment.instancesManager.add(instance)
|
||||
if let dismissSheet {
|
||||
dismissSheet()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
credentialValidationError = String(localized: "sources.error.invalidCredentials")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For other instance types (no auth required)
|
||||
let instance = Instance(
|
||||
type: type,
|
||||
url: url,
|
||||
name: name.isEmpty ? nil : name,
|
||||
allowInvalidCertificates: allowInvalidCertificates
|
||||
)
|
||||
|
||||
appEnvironment.instancesManager.add(instance)
|
||||
if let dismissSheet {
|
||||
dismissSheet()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func validateYatteeServerCredentials(url: URL, username: String, password: String, appEnvironment: AppEnvironment) async -> Bool {
|
||||
let client: HTTPClient
|
||||
if allowInvalidCertificates {
|
||||
client = appEnvironment.httpClientFactory.createClient(allowInvalidCertificates: true)
|
||||
} else {
|
||||
client = appEnvironment.httpClient
|
||||
}
|
||||
|
||||
let credentials = "\(username):\(password)"
|
||||
guard let credentialData = credentials.data(using: .utf8) else { return false }
|
||||
let authHeader = "Basic \(credentialData.base64EncodedString())"
|
||||
|
||||
let endpoint = GenericEndpoint.get("/info", customHeaders: ["Authorization": authHeader])
|
||||
|
||||
do {
|
||||
let response: YatteeServerInfoValidation = try await client.fetch(endpoint, baseURL: url)
|
||||
return response.version != nil
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Yattee Server Validation Response
|
||||
|
||||
private struct YatteeServerInfoValidation: Decodable {
|
||||
let name: String?
|
||||
let version: String?
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AddRemoteServerView()
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
}
|
||||
223
Yattee/Views/Settings/AddSource/AddSMBView.swift
Normal file
223
Yattee/Views/Settings/AddSource/AddSMBView.swift
Normal file
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// AddSMBView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for adding an SMB (Samba) share as a media source.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddSMBView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
// MARK: - State
|
||||
|
||||
@State private var name = ""
|
||||
@State private var server = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var protocolVersion: SMBProtocol = .auto
|
||||
|
||||
@State private var isTesting = false
|
||||
@State private var testResult: SourceTestResult?
|
||||
@State private var testProgress: String?
|
||||
|
||||
// Pre-filled from network discovery
|
||||
var prefillServer: String?
|
||||
var prefillName: String?
|
||||
|
||||
// Closure to dismiss the parent sheet
|
||||
var dismissSheet: DismissAction?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var canAdd: Bool {
|
||||
!name.isEmpty && !server.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
nameSection
|
||||
serverSection
|
||||
authSection
|
||||
protocolSection
|
||||
|
||||
if let result = testResult {
|
||||
SourceTestResultSection(result: result)
|
||||
}
|
||||
|
||||
actionSection
|
||||
}
|
||||
#if os(iOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.navigationTitle(String(localized: "sources.addSMB"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
if let prefillServer {
|
||||
server = prefillServer
|
||||
}
|
||||
if let prefillName, name.isEmpty {
|
||||
name = prefillName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var nameSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
|
||||
#else
|
||||
TextField(String(localized: "sources.field.name"), text: $name)
|
||||
#endif
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.displayName"))
|
||||
}
|
||||
}
|
||||
|
||||
private var serverSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.placeholder.smbServer"), text: $server)
|
||||
#else
|
||||
TextField(String(localized: "sources.placeholder.smbServer"), text: $server, prompt: Text(String(localized: "sources.placeholder.smbServer")))
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
#endif
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.smb"))
|
||||
}
|
||||
}
|
||||
|
||||
private var authSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.usernameOptional"), text: $username)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.passwordOptional"), text: $password, isSecure: true)
|
||||
#else
|
||||
TextField(String(localized: "sources.field.usernameOptional"), text: $username)
|
||||
.textContentType(.username)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField(String(localized: "sources.field.passwordOptional"), text: $password)
|
||||
.textContentType(.password)
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.auth"))
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.auth"))
|
||||
}
|
||||
}
|
||||
|
||||
private var protocolSection: some View {
|
||||
Section {
|
||||
Picker(String(localized: "sources.field.smbProtocol"), selection: $protocolVersion) {
|
||||
ForEach(SMBProtocol.allCases, id: \.self) { proto in
|
||||
Text(proto.displayName).tag(proto)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.advanced"))
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.smbProtocol"))
|
||||
}
|
||||
}
|
||||
|
||||
private var actionSection: some View {
|
||||
Section {
|
||||
Button {
|
||||
addSource()
|
||||
} label: {
|
||||
if isTesting {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text(testProgress ?? String(localized: "sources.testing"))
|
||||
}
|
||||
} else {
|
||||
Text(String(localized: "sources.addSource"))
|
||||
}
|
||||
}
|
||||
.disabled(!canAdd || isTesting)
|
||||
#if os(tvOS)
|
||||
.buttonStyle(TVSettingsButtonStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func addSource() {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
let urlString = "smb://\(server)"
|
||||
guard let encodedURLString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: encodedURLString) else {
|
||||
testResult = .failure(String(localized: "sources.error.invalidSMBAddress"))
|
||||
return
|
||||
}
|
||||
|
||||
isTesting = true
|
||||
testResult = nil
|
||||
testProgress = String(localized: "sources.testing.connecting")
|
||||
|
||||
let source = MediaSource.smb(
|
||||
name: name,
|
||||
url: url,
|
||||
username: username.isEmpty ? nil : username,
|
||||
protocolVersion: protocolVersion
|
||||
)
|
||||
|
||||
Task {
|
||||
do {
|
||||
_ = try await appEnvironment.smbClient.testConnection(
|
||||
source: source,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if !password.isEmpty {
|
||||
appEnvironment.mediaSourcesManager.setPassword(password, for: source)
|
||||
}
|
||||
|
||||
appEnvironment.mediaSourcesManager.add(source)
|
||||
isTesting = false
|
||||
testProgress = nil
|
||||
if let dismissSheet {
|
||||
dismissSheet()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isTesting = false
|
||||
testProgress = nil
|
||||
testResult = .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AddSMBView()
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
}
|
||||
106
Yattee/Views/Settings/AddSource/AddSourceShared.swift
Normal file
106
Yattee/Views/Settings/AddSource/AddSourceShared.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// AddSourceShared.swift
|
||||
// Yattee
|
||||
//
|
||||
// Shared components for the Add Source views.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Test Result
|
||||
|
||||
/// Result of a connection test for WebDAV/SMB sources.
|
||||
enum SourceTestResult {
|
||||
case success
|
||||
case successWithBandwidth(BandwidthTestResult)
|
||||
case failure(String)
|
||||
}
|
||||
|
||||
// MARK: - Test Result Section
|
||||
|
||||
/// Displays the result of a connection test.
|
||||
struct SourceTestResultSection: View {
|
||||
let result: SourceTestResult
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
switch result {
|
||||
case .success:
|
||||
Label(String(localized: "sources.status.connected"), systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
|
||||
case .successWithBandwidth(let bandwidth):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label(String(localized: "sources.status.connected"), systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
if bandwidth.hasWriteAccess {
|
||||
if let upload = bandwidth.formattedUploadSpeed {
|
||||
Label("Upload: \(upload)", systemImage: "arrow.up.circle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let download = bandwidth.formattedDownloadSpeed {
|
||||
Label("Download: \(download)", systemImage: "arrow.down.circle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !bandwidth.hasWriteAccess {
|
||||
Label(String(localized: "sources.status.readOnly"), systemImage: "lock.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let warning = bandwidth.warning {
|
||||
Text(warning)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
Label(error, systemImage: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Folder Picker (iOS)
|
||||
|
||||
#if os(iOS)
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct FolderPickerView: UIViewControllerRepresentable {
|
||||
let onSelect: (URL) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsMultipleSelection = false
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onSelect: onSelect)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let onSelect: (URL) -> Void
|
||||
|
||||
init(onSelect: @escaping (URL) -> Void) {
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
onSelect(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
227
Yattee/Views/Settings/AddSource/AddWebDAVView.swift
Normal file
227
Yattee/Views/Settings/AddSource/AddWebDAVView.swift
Normal file
@@ -0,0 +1,227 @@
|
||||
//
|
||||
// AddWebDAVView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for adding a WebDAV share as a media source.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddWebDAVView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
// MARK: - State
|
||||
|
||||
@State private var name = ""
|
||||
@State private var urlString = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var allowInvalidCertificates = false
|
||||
|
||||
@State private var isTesting = false
|
||||
@State private var testResult: SourceTestResult?
|
||||
@State private var testProgress: String?
|
||||
|
||||
// Pre-filled from network discovery
|
||||
var prefillURL: URL?
|
||||
var prefillName: String?
|
||||
var prefillAllowInvalidCertificates: Bool = false
|
||||
|
||||
// Closure to dismiss the parent sheet
|
||||
var dismissSheet: DismissAction?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var canAdd: Bool {
|
||||
!name.isEmpty && !urlString.isEmpty && URL(string: urlString) != nil
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
nameSection
|
||||
serverSection
|
||||
authSection
|
||||
securitySection
|
||||
|
||||
if let result = testResult {
|
||||
SourceTestResultSection(result: result)
|
||||
}
|
||||
|
||||
actionSection
|
||||
}
|
||||
#if os(iOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.navigationTitle(String(localized: "sources.addWebDAV"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
if let url = prefillURL {
|
||||
urlString = url.absoluteString
|
||||
}
|
||||
if let prefillName, name.isEmpty {
|
||||
name = prefillName
|
||||
}
|
||||
if prefillAllowInvalidCertificates {
|
||||
allowInvalidCertificates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var nameSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
|
||||
#else
|
||||
TextField(String(localized: "sources.field.name"), text: $name)
|
||||
#endif
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.displayName"))
|
||||
}
|
||||
}
|
||||
|
||||
private var serverSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.placeholder.webdavUrl"), text: $urlString)
|
||||
#else
|
||||
TextField(String(localized: "sources.placeholder.webdavUrl"), text: $urlString)
|
||||
.textContentType(.URL)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
#endif
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.webdav"))
|
||||
}
|
||||
}
|
||||
|
||||
private var authSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.usernameOptional"), text: $username)
|
||||
TVSettingsTextField(title: String(localized: "sources.field.passwordOptional"), text: $password, isSecure: true)
|
||||
#else
|
||||
TextField(String(localized: "sources.field.usernameOptional"), text: $username)
|
||||
.textContentType(.username)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField(String(localized: "sources.field.passwordOptional"), text: $password)
|
||||
.textContentType(.password)
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.auth"))
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.auth"))
|
||||
}
|
||||
}
|
||||
|
||||
private var securitySection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
TVSettingsToggle(
|
||||
title: String(localized: "sources.field.allowInvalidCertificates"),
|
||||
isOn: $allowInvalidCertificates
|
||||
)
|
||||
#else
|
||||
Toggle(String(localized: "sources.field.allowInvalidCertificates"), isOn: $allowInvalidCertificates)
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "sources.header.security"))
|
||||
} footer: {
|
||||
Text(String(localized: "sources.footer.allowInvalidCertificates"))
|
||||
}
|
||||
}
|
||||
|
||||
private var actionSection: some View {
|
||||
Section {
|
||||
Button {
|
||||
addSource()
|
||||
} label: {
|
||||
if isTesting {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text(testProgress ?? String(localized: "sources.testing"))
|
||||
}
|
||||
} else {
|
||||
Text(String(localized: "sources.addSource"))
|
||||
}
|
||||
}
|
||||
.disabled(!canAdd || isTesting)
|
||||
#if os(tvOS)
|
||||
.buttonStyle(TVSettingsButtonStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func addSource() {
|
||||
guard let appEnvironment,
|
||||
let url = URL(string: urlString) else { return }
|
||||
|
||||
isTesting = true
|
||||
testResult = nil
|
||||
testProgress = String(localized: "sources.testing.connecting")
|
||||
|
||||
let source = MediaSource.webdav(
|
||||
name: name,
|
||||
url: url,
|
||||
username: username.isEmpty ? nil : username,
|
||||
allowInvalidCertificates: allowInvalidCertificates
|
||||
)
|
||||
|
||||
let webDAVClient = appEnvironment.webDAVClientFactory.createClient(for: source)
|
||||
|
||||
Task {
|
||||
do {
|
||||
_ = try await webDAVClient.testConnection(
|
||||
source: source,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if !password.isEmpty {
|
||||
appEnvironment.mediaSourcesManager.setPassword(password, for: source)
|
||||
}
|
||||
|
||||
appEnvironment.mediaSourcesManager.add(source)
|
||||
isTesting = false
|
||||
testProgress = nil
|
||||
if let dismissSheet {
|
||||
dismissSheet()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isTesting = false
|
||||
testProgress = nil
|
||||
testResult = .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AddWebDAVView()
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user