Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,860 @@
//
// 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
// Yattee Server credentials
@State private var yatteeServerUsername: String = ""
@State private var yatteeServerPassword: String = ""
// 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)
}
var body: some View {
NavigationStack {
#if os(tvOS)
VStack(spacing: 0) {
HStack {
Button(String(localized: "common.cancel")) {
dismiss()
}
.buttonStyle(TVToolbarButtonStyle())
Spacer()
Text(String(localized: "sources.editSource"))
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(String(localized: "common.save")) {
saveChanges()
}
.buttonStyle(TVToolbarButtonStyle())
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
formContent
.accessibilityIdentifier("editSource.view")
}
#else
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")
#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.url.absoluteString)
}
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
TVSettingsToggle(title: 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.type == .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"))
}
// 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 {
ImportSubscriptionsView(instance: instance)
} label: {
Label(String(localized: "sources.import.subscriptions"), systemImage: "person.2")
}
.accessibilityIdentifier("sources.import.subscriptions")
NavigationLink {
ImportPlaylistsView(instance: instance)
} 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"))
}
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)
}
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
.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) {}
}
.sheet(isPresented: $showLoginSheet) {
InstanceLoginView(instance: instance) { credential in
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
isLoggedIn = true
}
}
.onAppear {
isLoggedIn = appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false
// Load existing Yattee Server credentials
if instance.type == .yatteeServer,
let credentials = appEnvironment?.yatteeServerCredentialsManager.credentials(for: instance) {
yatteeServerUsername = credentials.username
yatteeServerPassword = credentials.password
}
}
.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"))
}
}
// 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
// Save Yattee Server credentials if provided
if instance.type == .yatteeServer && !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty {
appEnvironment?.yatteeServerCredentialsManager.setCredentials(
username: yatteeServerUsername,
password: yatteeServerPassword,
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 {
NavigationStack {
#if os(tvOS)
VStack(spacing: 0) {
HStack {
Button(String(localized: "common.cancel")) {
dismiss()
}
.buttonStyle(TVToolbarButtonStyle())
Spacer()
Text(String(localized: "sources.editSource"))
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(String(localized: "common.save")) {
saveChanges()
}
.disabled(name.isEmpty)
.buttonStyle(TVToolbarButtonStyle())
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
formContent
}
.onAppear {
hasExistingPassword = appEnvironment?.mediaSourcesManager.password(for: source) != nil
smbProtocolVersion = source.smbProtocolVersion ?? .auto
}
#else
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)
#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
)
#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)
}
}
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
.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) {}
}
}
@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("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)
}
}
}
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)
}