mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
When all playlists/subscriptions were imported, every row collapsed to a non-focusable checkmark and the Add All toolbar item disappeared, leaving the view with no focusable element. The Menu button then closed the app instead of popping the navigation stack. Wrap the import destinations in TVSidebarDetailContainer for visual consistency and add a Done toolbar item (cancellationAction) that is always present on tvOS, reachable from any list row via swipe-up.
951 lines
37 KiB
Swift
951 lines
37 KiB
Swift
//
|
|
// EditSourceView.swift
|
|
// Yattee
|
|
//
|
|
// Unified sheet for editing any source type.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct EditSourceView: View {
|
|
let source: UnifiedSource
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
switch source {
|
|
case .remoteServer(let instance):
|
|
EditRemoteServerContent(instance: instance)
|
|
case .fileSource(let mediaSource):
|
|
EditFileSourceContent(source: mediaSource)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Remote Server Content
|
|
|
|
private struct EditRemoteServerContent: View {
|
|
let instance: Instance
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
@State private var name: String
|
|
@State private var isEnabled: Bool
|
|
@State private var allowInvalidCertificates: Bool
|
|
@State private var proxiesVideos: Bool
|
|
|
|
// HTTP Basic Auth credentials (for any instance type behind a reverse proxy;
|
|
// required for Yattee Server, optional for Invidious/Piped/PeerTube).
|
|
@State private var basicAuthUsername: String = ""
|
|
@State private var basicAuthPassword: String = ""
|
|
/// Tracks whether credentials existed when the view loaded, so we can detect
|
|
/// "user cleared the fields" and delete the stored credentials on save.
|
|
@State private var hadStoredBasicAuth: Bool = false
|
|
|
|
// Invidious login state
|
|
@State private var showLoginSheet = false
|
|
@State private var isLoggedIn = false
|
|
|
|
// Yattee Server validation
|
|
@State private var showingYatteeServerWarning = false
|
|
|
|
// Delete confirmation
|
|
@State private var showingDeleteConfirmation = false
|
|
|
|
// Yattee Server info
|
|
@State private var serverInfo: InstanceDetectorModels.YatteeServerFullInfo?
|
|
@State private var isLoadingServerInfo = false
|
|
@State private var serverInfoError: String?
|
|
|
|
// Connection testing
|
|
@State private var isTesting = false
|
|
@State private var testResult: RemoteServerTestResult?
|
|
|
|
enum RemoteServerTestResult {
|
|
case success
|
|
case failure(String)
|
|
}
|
|
|
|
init(instance: Instance) {
|
|
self.instance = instance
|
|
_name = State(initialValue: instance.name ?? "")
|
|
_isEnabled = State(initialValue: instance.isEnabled)
|
|
_allowInvalidCertificates = State(initialValue: instance.allowInvalidCertificates)
|
|
_proxiesVideos = State(initialValue: instance.proxiesVideos)
|
|
}
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
formContent
|
|
.accessibilityIdentifier("editSource.view")
|
|
#else
|
|
NavigationStack {
|
|
formContent
|
|
.navigationTitle(String(localized: "sources.editSource"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: "common.cancel")) {
|
|
dismiss()
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "common.save")) {
|
|
saveChanges()
|
|
}
|
|
}
|
|
}
|
|
.accessibilityIdentifier("editSource.view")
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 500, minHeight: 450)
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
private var formContent: some View {
|
|
Form {
|
|
Section {
|
|
LabeledContent(String(localized: "sources.field.type"), value: instance.type.displayName)
|
|
LabeledContent(String(localized: "sources.field.url"), value: instance.displayURL)
|
|
}
|
|
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
|
|
TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled)
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.name")) {
|
|
TextField("", text: $name)
|
|
}
|
|
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
|
|
#else
|
|
TextField(String(localized: "sources.field.name"), text: $name)
|
|
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
|
|
#endif
|
|
}
|
|
|
|
if instance.supportsHTTPBasicAuthProxy {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
|
|
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true)
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.username")) {
|
|
TextField("", text: $basicAuthUsername)
|
|
.textContentType(.username)
|
|
.autocorrectionDisabled()
|
|
}
|
|
LabeledContent(String(localized: "sources.field.password")) {
|
|
SecureField("", text: $basicAuthPassword)
|
|
.textContentType(.password)
|
|
}
|
|
#else
|
|
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
|
|
.textContentType(.username)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.autocorrectionDisabled()
|
|
|
|
SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword)
|
|
.textContentType(.password)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: instance.type == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth"))
|
|
} footer: {
|
|
Text(String(localized: instance.type == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth"))
|
|
}
|
|
}
|
|
|
|
if instance.type == .yatteeServer {
|
|
// Server Info Section
|
|
Section {
|
|
if isLoadingServerInfo {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "sources.serverInfo.loading"))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if let info = serverInfo {
|
|
LabeledContent(String(localized: "sources.field.serverVersion"), value: info.version ?? "—")
|
|
LabeledContent(String(localized: "sources.field.ytdlp"), value: info.dependencies?.ytDlp ?? "—")
|
|
LabeledContent(String(localized: "sources.field.ffmpeg"), value: info.dependencies?.ffmpeg ?? "—")
|
|
LabeledContent(String(localized: "sources.field.invidiousInstance"), value: invidiousDisplayValue(info))
|
|
|
|
// Extractors section
|
|
if info.config?.allowAllSitesForExtraction == true {
|
|
LabeledContent(String(localized: "sources.field.extractors"), value: String(localized: "sources.serverInfo.allSitesSupported"))
|
|
} else if let sites = info.sites, !sites.isEmpty {
|
|
#if os(tvOS)
|
|
LabeledContent(String(localized: "sources.field.extractors"), value: "\(sites.count)")
|
|
#else
|
|
DisclosureGroup(String(localized: "sources.serverInfo.enabledExtractors \(sites.count)")) {
|
|
ForEach(sites, id: \.name) { site in
|
|
Text(site.name)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
#endif
|
|
} else {
|
|
LabeledContent(String(localized: "sources.field.extractors"), value: String(localized: "sources.serverInfo.noneConfigured"))
|
|
}
|
|
} else if let error = serverInfoError {
|
|
Label(error, systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
} header: {
|
|
Text(String(localized: "sources.header.serverInfo"))
|
|
}
|
|
}
|
|
|
|
if instance.supportsAuthentication {
|
|
Section {
|
|
if isLoggedIn {
|
|
Button(role: .destructive) {
|
|
logout()
|
|
} label: {
|
|
Label(String(localized: "login.logout"), systemImage: "rectangle.portrait.and.arrow.forward")
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
} else {
|
|
Button {
|
|
showLoginSheet = true
|
|
} label: {
|
|
Label(String(localized: "login.loginToAccount"), systemImage: "person.badge.key")
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
}
|
|
} header: {
|
|
Text(String(localized: "login.header.account"))
|
|
} footer: {
|
|
if isLoggedIn {
|
|
Text(String(localized: "login.footer.loggedIn"))
|
|
} else {
|
|
Text(String(localized: "login.footer.loginBenefits"))
|
|
}
|
|
}
|
|
|
|
// Import section - show when logged in for Invidious and Piped instances
|
|
if isLoggedIn && (instance.type == .invidious || instance.type == .piped) {
|
|
Section {
|
|
NavigationLink {
|
|
#if os(tvOS)
|
|
TVSidebarDetailContainer(
|
|
systemImage: "person.2",
|
|
title: String(localized: "sources.import.subscriptions")
|
|
) {
|
|
ImportSubscriptionsView(instance: instance)
|
|
}
|
|
#else
|
|
ImportSubscriptionsView(instance: instance)
|
|
#endif
|
|
} label: {
|
|
Label(String(localized: "sources.import.subscriptions"), systemImage: "person.2")
|
|
}
|
|
.accessibilityIdentifier("sources.import.subscriptions")
|
|
|
|
NavigationLink {
|
|
#if os(tvOS)
|
|
TVSidebarDetailContainer(
|
|
systemImage: "list.bullet.rectangle",
|
|
title: String(localized: "sources.import.playlists")
|
|
) {
|
|
ImportPlaylistsView(instance: instance)
|
|
}
|
|
#else
|
|
ImportPlaylistsView(instance: instance)
|
|
#endif
|
|
} label: {
|
|
Label(String(localized: "sources.import.playlists"), systemImage: "list.bullet.rectangle")
|
|
}
|
|
.accessibilityIdentifier("sources.import.playlists")
|
|
} header: {
|
|
Text(String(localized: "sources.header.import"))
|
|
}
|
|
}
|
|
}
|
|
|
|
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"))
|
|
}
|
|
|
|
if instance.supportsVideoProxying {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsToggle(
|
|
title: String(localized: "sources.field.proxiesVideos"),
|
|
isOn: $proxiesVideos
|
|
)
|
|
#else
|
|
Toggle(String(localized: "sources.field.proxiesVideos"), isOn: $proxiesVideos)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "sources.header.proxy"))
|
|
} footer: {
|
|
Text(String(localized: "sources.footer.proxiesVideos"))
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
testConnection()
|
|
} label: {
|
|
if isTesting {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "sources.testing"))
|
|
}
|
|
} else {
|
|
Label(String(localized: "sources.testConnection"), systemImage: "network")
|
|
}
|
|
}
|
|
.disabled(isTesting)
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
}
|
|
|
|
if let result = testResult {
|
|
testResultSection(result)
|
|
}
|
|
|
|
#if os(tvOS)
|
|
Section {
|
|
Button {
|
|
saveChanges()
|
|
} label: {
|
|
Label(String(localized: "common.save"), systemImage: "checkmark.circle")
|
|
}
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
}
|
|
#endif
|
|
|
|
Section {
|
|
Button(role: .destructive) {
|
|
showingDeleteConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "sources.deleteSource"), systemImage: "trash")
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
#endif
|
|
#if os(macOS)
|
|
.formStyle(.grouped)
|
|
#endif
|
|
.confirmationDialog(
|
|
String(localized: "sources.delete.confirmation.single \(instance.displayName)"),
|
|
isPresented: $showingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.delete"), role: .destructive) {
|
|
appEnvironment?.instancesManager.remove(instance)
|
|
dismiss()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
#if os(tvOS)
|
|
.fullScreenCover(isPresented: $showLoginSheet) {
|
|
InstanceLoginView(instance: instance) { credential in
|
|
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
|
|
isLoggedIn = true
|
|
}
|
|
}
|
|
#else
|
|
.sheet(isPresented: $showLoginSheet) {
|
|
InstanceLoginView(instance: instance) { credential in
|
|
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
|
|
isLoggedIn = true
|
|
}
|
|
}
|
|
#endif
|
|
.onAppear {
|
|
isLoggedIn = appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false
|
|
|
|
// Load existing HTTP Basic Auth credentials (works for any instance type)
|
|
if let credentials = appEnvironment?.basicAuthCredentialsManager.credentials(for: instance) {
|
|
basicAuthUsername = credentials.username
|
|
basicAuthPassword = credentials.password
|
|
hadStoredBasicAuth = true
|
|
}
|
|
}
|
|
.task {
|
|
await loadServerInfo()
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "sources.yatteeServer.warning.title"),
|
|
isPresented: $showingYatteeServerWarning,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "sources.yatteeServer.warning.disableOthers"), role: .destructive) {
|
|
appEnvironment?.instancesManager.disableOtherYatteeServerInstances(except: instance.id)
|
|
performSave()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
} message: {
|
|
Text(String(localized: "sources.yatteeServer.warning.message"))
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var hasYatteeServer: Bool {
|
|
appEnvironment?.instancesManager.hasYatteeServerInstances ?? false
|
|
}
|
|
|
|
private func invidiousDisplayValue(_ info: InstanceDetectorModels.YatteeServerFullInfo) -> String {
|
|
guard let invidiousInstance = info.config?.invidiousInstance else {
|
|
return String(localized: "sources.serverInfo.notConfigured")
|
|
}
|
|
if invidiousInstance == "not configured" || invidiousInstance.isEmpty {
|
|
return String(localized: "sources.serverInfo.notConfigured")
|
|
}
|
|
// Extract just the host from the URL for cleaner display
|
|
if let url = URL(string: invidiousInstance), let host = url.host {
|
|
return host
|
|
}
|
|
return invidiousInstance
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func logout() {
|
|
appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance)
|
|
isLoggedIn = false
|
|
}
|
|
|
|
private func loadServerInfo() async {
|
|
guard instance.type == .yatteeServer, let appEnvironment else { return }
|
|
|
|
isLoadingServerInfo = true
|
|
serverInfoError = nil
|
|
|
|
do {
|
|
serverInfo = try await appEnvironment.contentService.yatteeServerInfo(for: instance)
|
|
} catch {
|
|
serverInfoError = String(localized: "sources.serverInfo.loadError")
|
|
}
|
|
|
|
isLoadingServerInfo = false
|
|
}
|
|
|
|
private func saveChanges() {
|
|
// Check if we're enabling a Yattee Server instance
|
|
let wasDisabled = !instance.isEnabled
|
|
let willBeEnabled = isEnabled
|
|
let isYatteeServer = instance.type == .yatteeServer
|
|
|
|
if isYatteeServer && wasDisabled && willBeEnabled {
|
|
let otherEnabled = appEnvironment?.instancesManager.enabledYatteeServerInstances ?? []
|
|
if !otherEnabled.isEmpty {
|
|
showingYatteeServerWarning = true
|
|
return
|
|
}
|
|
}
|
|
|
|
performSave()
|
|
}
|
|
|
|
private func performSave() {
|
|
var updated = instance
|
|
updated.name = name.isEmpty ? nil : name
|
|
updated.isEnabled = isEnabled
|
|
updated.allowInvalidCertificates = allowInvalidCertificates
|
|
updated.proxiesVideos = proxiesVideos
|
|
|
|
// Save / clear HTTP Basic Auth credentials. Yattee Server never auto-clears
|
|
// (credentials are required and the user re-enters to overwrite). Piped never
|
|
// persists them — its session token reuses the same Authorization header.
|
|
if !instance.supportsHTTPBasicAuthProxy {
|
|
if hadStoredBasicAuth {
|
|
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
|
|
}
|
|
} else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty {
|
|
appEnvironment?.basicAuthCredentialsManager.setCredentials(
|
|
username: basicAuthUsername,
|
|
password: basicAuthPassword,
|
|
for: instance
|
|
)
|
|
} else if hadStoredBasicAuth, instance.type != .yatteeServer {
|
|
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
|
|
}
|
|
|
|
appEnvironment?.instancesManager.update(updated)
|
|
dismiss()
|
|
}
|
|
|
|
private func testConnection() {
|
|
guard let appEnvironment else { return }
|
|
isTesting = true
|
|
testResult = nil
|
|
|
|
Task {
|
|
do {
|
|
_ = try await appEnvironment.contentService.popular(for: instance)
|
|
await MainActor.run {
|
|
isTesting = false
|
|
testResult = .success
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isTesting = false
|
|
testResult = .failure(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func testResultSection(_ result: RemoteServerTestResult) -> some View {
|
|
Section {
|
|
switch result {
|
|
case .success:
|
|
Label(String(localized: "sources.test.success"), systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
case .failure(let error):
|
|
Label(error, systemImage: "xmark.circle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - File Source Content
|
|
|
|
private struct EditFileSourceContent: View {
|
|
let source: MediaSource
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
@State private var name: String
|
|
@State private var isEnabled: Bool
|
|
@State private var username: String
|
|
@State private var password: String
|
|
@State private var allowInvalidCertificates: Bool
|
|
@State private var smbProtocolVersion: SMBProtocol = .auto
|
|
@State private var isTesting = false
|
|
@State private var testResult: TestResult?
|
|
@State private var testProgress: String?
|
|
@State private var hasExistingPassword = false
|
|
@State private var showingDeleteConfirmation = false
|
|
|
|
enum TestResult {
|
|
case success
|
|
case successWithBandwidth(BandwidthTestResult)
|
|
case failure(String)
|
|
}
|
|
|
|
init(source: MediaSource) {
|
|
self.source = source
|
|
_name = State(initialValue: source.name)
|
|
_isEnabled = State(initialValue: source.isEnabled)
|
|
_username = State(initialValue: source.username ?? "")
|
|
_password = State(initialValue: "")
|
|
_allowInvalidCertificates = State(initialValue: source.allowInvalidCertificates)
|
|
}
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
formContent
|
|
.onAppear {
|
|
hasExistingPassword = appEnvironment?.mediaSourcesManager.password(for: source) != nil
|
|
smbProtocolVersion = source.smbProtocolVersion ?? .auto
|
|
}
|
|
#else
|
|
NavigationStack {
|
|
formContent
|
|
.navigationTitle(String(localized: "sources.editSource"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: "common.cancel")) {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "common.save")) {
|
|
saveChanges()
|
|
}
|
|
.disabled(name.isEmpty)
|
|
}
|
|
}
|
|
.onAppear {
|
|
hasExistingPassword = appEnvironment?.mediaSourcesManager.password(for: source) != nil
|
|
smbProtocolVersion = source.smbProtocolVersion ?? .auto
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private var formContent: some View {
|
|
Form {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
|
|
TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled)
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.name")) {
|
|
TextField("", text: $name)
|
|
}
|
|
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
|
|
#else
|
|
TextField(String(localized: "sources.field.name"), text: $name)
|
|
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "sources.header.general"))
|
|
}
|
|
|
|
Section {
|
|
HStack {
|
|
Text(String(localized: "sources.field.type"))
|
|
Spacer()
|
|
Label(source.type.displayName, systemImage: source.type.systemImage)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
#if !os(tvOS)
|
|
.alignmentGuide(.listRowSeparatorLeading) { d in d[.leading] }
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
HStack {
|
|
Text(String(localized: "sources.field.url"))
|
|
Spacer()
|
|
Text(source.url.absoluteString)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
#else
|
|
if source.type != .localFolder {
|
|
HStack {
|
|
Text(String(localized: "sources.field.url"))
|
|
Spacer()
|
|
Text(source.url.absoluteString)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
if source.type == .webdav || source.type == .smb {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $username)
|
|
TVSettingsTextField(
|
|
title: hasExistingPassword
|
|
? String(localized: "sources.field.passwordKeep")
|
|
: String(localized: "sources.field.passwordRequired"),
|
|
text: $password,
|
|
isSecure: true
|
|
)
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.username")) {
|
|
TextField("", text: $username)
|
|
.textContentType(.username)
|
|
.autocorrectionDisabled()
|
|
}
|
|
LabeledContent(String(localized: "sources.field.password")) {
|
|
SecureField(
|
|
hasExistingPassword
|
|
? String(localized: "sources.field.passwordKeep")
|
|
: String(localized: "sources.field.passwordRequired"),
|
|
text: $password
|
|
)
|
|
.textContentType(.password)
|
|
}
|
|
#else
|
|
TextField(String(localized: "sources.field.username"), text: $username)
|
|
.textContentType(.username)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.autocorrectionDisabled()
|
|
|
|
SecureField(
|
|
hasExistingPassword
|
|
? String(localized: "sources.field.passwordKeep")
|
|
: String(localized: "sources.field.passwordRequired"),
|
|
text: $password
|
|
)
|
|
.textContentType(.password)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "sources.header.auth"))
|
|
}
|
|
}
|
|
|
|
if source.type == .smb {
|
|
Section {
|
|
Picker(String(localized: "sources.field.smbProtocol"), selection: $smbProtocolVersion) {
|
|
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"))
|
|
}
|
|
}
|
|
|
|
if source.type == .webdav {
|
|
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"))
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
testConnection()
|
|
} label: {
|
|
if isTesting {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(testProgress ?? String(localized: "sources.testing"))
|
|
}
|
|
} else {
|
|
Label(String(localized: "sources.testConnection"), systemImage: "speedometer")
|
|
}
|
|
}
|
|
.disabled(isTesting)
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
}
|
|
|
|
if let result = testResult {
|
|
testResultSection(result)
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
Section {
|
|
Button {
|
|
saveChanges()
|
|
} label: {
|
|
Label(String(localized: "common.save"), systemImage: "checkmark.circle")
|
|
}
|
|
.disabled(name.isEmpty)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
}
|
|
#endif
|
|
|
|
Section {
|
|
Button(role: .destructive) {
|
|
showingDeleteConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "sources.deleteSource"), systemImage: "trash")
|
|
.foregroundStyle(.red)
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
#endif
|
|
#if os(macOS)
|
|
.formStyle(.grouped)
|
|
#endif
|
|
.confirmationDialog(
|
|
String(localized: "sources.delete.confirmation.single \(source.name)"),
|
|
isPresented: $showingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.delete"), role: .destructive) {
|
|
deleteSource()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func testResultSection(_ result: TestResult) -> 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(String(localized: "sources.bandwidth.upload \(upload)"), systemImage: "arrow.up.circle")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let download = bandwidth.formattedDownloadSpeed {
|
|
Label(String(localized: "sources.bandwidth.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func testConnection() {
|
|
guard let appEnvironment else { return }
|
|
|
|
isTesting = true
|
|
testResult = nil
|
|
testProgress = nil
|
|
|
|
let testPassword = password.isEmpty
|
|
? appEnvironment.mediaSourcesManager.password(for: source)
|
|
: password
|
|
|
|
var updatedSource = source
|
|
updatedSource.username = username.isEmpty ? nil : username
|
|
updatedSource.allowInvalidCertificates = allowInvalidCertificates
|
|
|
|
// Use factory to create client with appropriate SSL settings
|
|
let webDAVClient = appEnvironment.webDAVClientFactory.createClient(for: updatedSource)
|
|
|
|
Task {
|
|
do {
|
|
let bandwidthResult = try await webDAVClient.testBandwidth(
|
|
source: updatedSource,
|
|
password: testPassword
|
|
) { status in
|
|
Task { @MainActor in
|
|
self.testProgress = status
|
|
}
|
|
}
|
|
await MainActor.run {
|
|
isTesting = false
|
|
testProgress = nil
|
|
testResult = .successWithBandwidth(bandwidthResult)
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isTesting = false
|
|
testProgress = nil
|
|
testResult = .failure(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveChanges() {
|
|
guard let appEnvironment else { return }
|
|
|
|
var updatedSource = source
|
|
updatedSource.name = name
|
|
updatedSource.isEnabled = isEnabled
|
|
|
|
if source.type == .webdav || source.type == .smb {
|
|
updatedSource.username = username.isEmpty ? nil : username
|
|
|
|
if !password.isEmpty {
|
|
appEnvironment.mediaSourcesManager.setPassword(password, for: source)
|
|
}
|
|
}
|
|
|
|
if source.type == .webdav {
|
|
updatedSource.allowInvalidCertificates = allowInvalidCertificates
|
|
}
|
|
|
|
if source.type == .smb {
|
|
updatedSource.smbProtocolVersion = smbProtocolVersion
|
|
|
|
// Clear SMB cache if credentials or protocol changed
|
|
let credentialsChanged = (source.username != updatedSource.username) || !password.isEmpty
|
|
let protocolChanged = source.smbProtocolVersion != smbProtocolVersion
|
|
|
|
if credentialsChanged || protocolChanged {
|
|
Task {
|
|
await appEnvironment.smbClient.clearCache(for: source)
|
|
}
|
|
}
|
|
}
|
|
|
|
appEnvironment.mediaSourcesManager.update(updatedSource)
|
|
dismiss()
|
|
}
|
|
|
|
private func deleteSource() {
|
|
guard let appEnvironment else { return }
|
|
appEnvironment.mediaSourcesManager.remove(source)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Remote Server") {
|
|
EditSourceView(
|
|
source: .remoteServer(Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!))
|
|
)
|
|
.appEnvironment(.preview)
|
|
}
|
|
|
|
#Preview("WebDAV") {
|
|
EditSourceView(
|
|
source: .fileSource(.webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!, username: "user"))
|
|
)
|
|
.appEnvironment(.preview)
|
|
}
|