mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The API layer marking an account `credentialRejected` posts the same notification that recomputes statuses, which saw the Keychain item still present and immediately reported "signed in" again. The rejection could therefore never survive the moment it was recorded, and the user was never asked to sign in again. Only the server can clear a rejection now; storing a new credential resets it explicitly. Cache invalidation follows the effective context rather than the stored one. A credential arriving or disappearing flips reads between the account and local without the stored context moving, so the global feed caches kept whichever side had populated them. The login sheet stays open when the credential could not be stored — an unresolved identity scope or a Keychain failure — instead of dismissing and discarding what the user typed.
814 lines
33 KiB
Swift
814 lines
33 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
|
|
|
|
// Account login state. `loginTarget` is non-nil while the sheet is up and
|
|
// carries the account being signed into — nil inside it means "create one".
|
|
@State private var loginTarget: AccountLoginTarget?
|
|
@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?
|
|
|
|
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 {
|
|
InstanceAccountsSection(instance: instance, loginTarget: $loginTarget)
|
|
|
|
// 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"),
|
|
showsDismissButton: true
|
|
) {
|
|
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"),
|
|
showsDismissButton: true
|
|
) {
|
|
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"))
|
|
}
|
|
}
|
|
|
|
#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
|
|
.opaqueSettingsFormBackground()
|
|
.confirmationDialog(
|
|
String(localized: "sources.delete.confirmation.single \(instance.displayName)"),
|
|
isPresented: $showingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.delete"), role: .destructive) {
|
|
appEnvironment?.removeInstance(instance)
|
|
dismiss()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
#if os(tvOS)
|
|
.fullScreenCover(item: $loginTarget) { target in
|
|
InstanceLoginView(instance: instance) { credential, username in
|
|
storeLogin(credential: credential, username: username, target: target)
|
|
}
|
|
}
|
|
#else
|
|
.sheet(item: $loginTarget) { target in
|
|
InstanceLoginView(instance: instance) { credential, username in
|
|
storeLogin(credential: credential, username: username, target: target)
|
|
}
|
|
}
|
|
#endif
|
|
// Signing out happens in the accounts section below, which cannot reach
|
|
// this state directly. Without this the authenticated import actions
|
|
// stay on screen after the last account is removed, and then fail.
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
|
|
isLoggedIn = appEnvironment?.isSignedIn(to: instance) ?? false
|
|
}
|
|
.onAppear {
|
|
isLoggedIn = appEnvironment?.isSignedIn(to: 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
|
|
|
|
/// Files a successful login against an account, creating one if this was a
|
|
/// fresh sign-in.
|
|
///
|
|
/// The identifier is stored beside the credential in the Keychain rather
|
|
/// than on the account record, so it never travels as synced metadata.
|
|
/// - Returns: whether the credential was stored. A login the server
|
|
/// accepted is not the same as one this device could keep.
|
|
@discardableResult
|
|
private func storeLogin(credential: String, username: String, target: AccountLoginTarget) -> Bool {
|
|
guard let appEnvironment else { return false }
|
|
|
|
// Credential storage refuses to write under an unresolved identity
|
|
// scope, so creating the account first would leave one with no
|
|
// credential — and in a namespace that is discarded the moment the real
|
|
// scope arrives. Fail visibly instead.
|
|
guard appEnvironment.credentialIdentityScope.isResolved else {
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "accounts.signIn.identityUnresolved")
|
|
)
|
|
return false
|
|
}
|
|
|
|
let account = target.account
|
|
?? appEnvironment.accountsManager.addAccount(instanceID: instance.id)
|
|
|
|
guard let credentials = appEnvironment.accountCredentialsManager(for: account) else { return false }
|
|
|
|
credentials.setCredential(credential, for: account)
|
|
|
|
// Confirm the write landed before reporting success: a Keychain failure
|
|
// would otherwise leave an account that looks signed in but has nothing
|
|
// behind it.
|
|
guard credentials.credential(for: account) != nil else {
|
|
if target.account == nil {
|
|
appEnvironment.accountsManager.removeAccount(account.id)
|
|
}
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "accounts.signIn.storeFailed")
|
|
)
|
|
return false
|
|
}
|
|
if !username.isEmpty {
|
|
credentials.setUsername(username, for: account)
|
|
}
|
|
|
|
appEnvironment.accountsManager.setStatus(.signedIn, for: account.id)
|
|
|
|
// `usesAccountLogin` is deliberately not set. It is pre-revamp state
|
|
// that migration only reads, and it syncs through iCloud key-value
|
|
// storage independently of the account records — a device receiving the
|
|
// flag before the account would synthesize a migrated account for a
|
|
// login that was never legacy, and the marker that arrives later does
|
|
// not retract it. Accounts describe logins now; the flag only describes
|
|
// the world before they existed.
|
|
|
|
// Signing in again keeps the same account, so nothing downstream sees a
|
|
// change of context — but the credential behind it may now belong to
|
|
// somebody else, and their data must not survive the swap.
|
|
appEnvironment.accountCredentialDidChange(for: account)
|
|
|
|
isLoggedIn = true
|
|
return true
|
|
}
|
|
|
|
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() {
|
|
// Start from the manager's latest copy so flags updated while this sheet
|
|
// was open (e.g. usesAccountLogin set by the login sheet) are preserved.
|
|
var updated = appEnvironment?.instancesManager.instances.first { $0.id == instance.id } ?? 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)
|
|
}
|
|
updated.usesBasicAuth = false
|
|
} else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty {
|
|
appEnvironment?.basicAuthCredentialsManager.setCredentials(
|
|
username: basicAuthUsername,
|
|
password: basicAuthPassword,
|
|
for: instance
|
|
)
|
|
updated.usesBasicAuth = true
|
|
} else if hadStoredBasicAuth, instance.type != .yatteeServer {
|
|
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
|
|
updated.usesBasicAuth = false
|
|
}
|
|
|
|
appEnvironment?.instancesManager.update(updated)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// 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 hasExistingPassword = false
|
|
@State private var showingDeleteConfirmation = false
|
|
|
|
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
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 500, minHeight: 450)
|
|
#endif
|
|
#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"))
|
|
}
|
|
}
|
|
|
|
#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
|
|
.opaqueSettingsFormBackground()
|
|
.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)
|
|
}
|
|
|
|
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)
|
|
}
|