Files
yattee/Yattee/Views/Settings/SourcesListView.swift
Arkadiusz Fal b8390577cc Rename TVSettingsContainer to TVSidebarDetailContainer
The container is now used beyond settings (Open URL and Remote Control
tabs), so the name is broadened to reflect its general role as a
tvOS sidebar-decorated detail container.
2026-04-18 20:38:01 +02:00

472 lines
15 KiB
Swift

//
// SourcesListView.swift
// Yattee
//
// Unified list of all sources (remote servers, WebDAV, local folders).
//
import SwiftUI
struct SourcesListView: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var showingAddSheet = false
@State private var sourceToEdit: UnifiedSource?
// Delete confirmation state
@State private var showingDeleteConfirmation = false
@State private var pendingDeleteInstance: Instance?
@State private var pendingDeleteSource: MediaSource?
private var instancesManager: InstancesManager? {
appEnvironment?.instancesManager
}
private var mediaSourcesManager: MediaSourcesManager? {
appEnvironment?.mediaSourcesManager
}
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
private var isEmpty: Bool {
(instancesManager?.instances.isEmpty ?? true) &&
(mediaSourcesManager?.isEmpty ?? true)
}
var body: some View {
Group {
if isEmpty {
emptyState
} else {
sourcesList
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "sources.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
.accessibilityIdentifier("sources.addButton")
}
}
#if os(tvOS)
.navigationDestination(isPresented: $showingAddSheet) {
TVSidebarDetailContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) { AddSourceView() }
}
#else
.sheet(isPresented: $showingAddSheet) {
AddSourceView()
}
#endif
#if os(tvOS)
.navigationDestination(item: $sourceToEdit) { source in
TVSidebarDetailContainer(systemImage: "pencil.circle", title: String(localized: "sources.editSource")) { EditSourceView(source: source) }
}
#else
.sheet(item: $sourceToEdit) { source in
EditSourceView(source: source)
}
#endif
.confirmationDialog(
deleteConfirmationMessage,
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "common.delete"), role: .destructive) {
confirmDelete()
}
Button(String(localized: "common.cancel"), role: .cancel) {
pendingDeleteInstance = nil
pendingDeleteSource = nil
}
}
.presentationCompactAdaptation(.sheet)
}
// MARK: - Empty State
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: "sources.empty.title"), systemImage: "server.rack")
} description: {
Text(String(localized: "sources.empty.description"))
} actions: {
Button(String(localized: "sources.addSource")) {
showingAddSheet = true
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("sources.view")
}
// MARK: - Sources List
private var sourcesList: some View {
#if os(tvOS)
List {
if let manager = instancesManager, !manager.instances.isEmpty {
Section(String(localized: "sources.section.remoteServers")) {
let instances = manager.instances.sorted { $0.dateAdded < $1.dateAdded }
ForEach(instances) { instance in
Button {
sourceToEdit = .remoteServer(instance)
} label: {
instanceRow(instance)
}
}
}
}
let allFileSources = allMediaSources
if !allFileSources.isEmpty {
Section(String(localized: "sources.section.fileSources")) {
ForEach(allFileSources) { source in
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
Button {
sourceToEdit = .fileSource(source)
} label: {
mediaSourceRow(source, needsPassword: needsPassword)
}
}
}
}
}
.listStyle(.grouped)
#else
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
.ignoresSafeArea()
.overlay(
ScrollView {
LazyVStack(spacing: 0) {
remoteServersSection
fileSourcesSection
}
}
)
#endif
}
// MARK: - Section Header
@ViewBuilder
private func sectionHeader(_ title: String) -> some View {
Text(title)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, listStyle == .inset ? 32 : 16)
.padding(.top, 16)
.padding(.bottom, 8)
}
// MARK: - Section Card
@ViewBuilder
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
if listStyle == .inset {
LazyVStack(spacing: 0) {
content()
}
#if os(tvOS)
.padding(.horizontal, 16)
#else
.background(ListBackgroundStyle.card.color)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
#endif
.padding(.bottom, 16)
} else {
LazyVStack(spacing: 0) {
content()
}
.padding(.bottom, 16)
}
}
// MARK: - Remote Servers Section
@ViewBuilder
private var remoteServersSection: some View {
if let manager = instancesManager, !manager.instances.isEmpty {
sectionHeader(String(localized: "sources.section.remoteServers"))
let instances = manager.instances.sorted { $0.dateAdded < $1.dateAdded }
sectionCard {
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
let isLast = index == instances.count - 1
instanceRowView(instance, isLast: isLast)
}
}
}
}
@ViewBuilder
private func instanceRowView(_ instance: Instance, isLast: Bool) -> some View {
#if os(tvOS)
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
sourceToEdit = .remoteServer(instance)
} label: {
instanceRow(instance)
}
.buttonStyle(.card)
}
#else
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
sourceToEdit = .remoteServer(instance)
} label: {
instanceRow(instance)
}
.foregroundStyle(.primary)
}
.swipeActions {
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
sourceToEdit = .remoteServer(instance)
reset()
}
SwipeAction(symbolImage: "trash", tint: .white, background: .red) { reset in
pendingDeleteInstance = instance
showingDeleteConfirmation = true
reset()
}
}
.contextMenu {
Button {
sourceToEdit = .remoteServer(instance)
} label: {
Label(String(localized: "common.edit"), systemImage: "pencil")
}
Button(role: .destructive) {
pendingDeleteInstance = instance
showingDeleteConfirmation = true
} label: {
Label(String(localized: "common.delete"), systemImage: "trash")
}
}
#endif
}
private func instanceRow(_ instance: Instance) -> some View {
HStack(spacing: 12) {
Image(systemName: instance.type.systemImage)
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(instance.displayName)
.font(.headline)
.foregroundStyle(.primary)
if !instance.isEnabled {
disabledBadge
}
}
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
.font(.caption)
.foregroundStyle(.secondary)
instanceStatusView(for: instance)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
@ViewBuilder
private func instanceStatusView(for instance: Instance) -> some View {
if let status = instancesManager?.status(for: instance) {
switch status {
case .authFailed:
Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.orange)
case .authRequired:
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
default:
EmptyView()
}
}
}
// MARK: - File Sources Section
@ViewBuilder
private var fileSourcesSection: some View {
let allFileSources = allMediaSources
if !allFileSources.isEmpty {
sectionHeader(String(localized: "sources.section.fileSources"))
sectionCard {
ForEach(Array(allFileSources.enumerated()), id: \.element.id) { index, source in
let isLast = index == allFileSources.count - 1
fileSourceRowView(source, isLast: isLast)
}
}
}
}
private var allMediaSources: [MediaSource] {
guard let manager = mediaSourcesManager else { return [] }
var sources: [MediaSource] = []
sources.append(contentsOf: manager.webdavSources)
sources.append(contentsOf: manager.smbSources)
#if !os(tvOS)
sources.append(contentsOf: manager.localFolderSources)
#endif
return sources.sorted { $0.dateAdded < $1.dateAdded }
}
@ViewBuilder
private func fileSourceRowView(_ source: MediaSource, isLast: Bool) -> some View {
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
#if os(tvOS)
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
sourceToEdit = .fileSource(source)
} label: {
mediaSourceRow(source, needsPassword: needsPassword)
}
.buttonStyle(.card)
}
#else
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
sourceToEdit = .fileSource(source)
} label: {
mediaSourceRow(source, needsPassword: needsPassword)
}
.foregroundStyle(.primary)
}
.swipeActions {
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
sourceToEdit = .fileSource(source)
reset()
}
SwipeAction(symbolImage: "trash", tint: .white, background: .red) { reset in
pendingDeleteSource = source
showingDeleteConfirmation = true
reset()
}
}
.contextMenu {
Button {
sourceToEdit = .fileSource(source)
} label: {
Label(String(localized: "common.edit"), systemImage: "pencil")
}
Button(role: .destructive) {
pendingDeleteSource = source
showingDeleteConfirmation = true
} label: {
Label(String(localized: "common.delete"), systemImage: "trash")
}
}
#endif
}
private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View {
HStack(spacing: 12) {
Image(systemName: source.type.systemImage)
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(source.name)
.font(.headline)
.foregroundStyle(.primary)
if !source.isEnabled {
disabledBadge
}
}
Text("\(source.type.displayName) - \(source.urlDisplayString)")
.font(.caption)
.foregroundStyle(.secondary)
if needsPassword {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
// MARK: - Disabled Badge
private var disabledBadge: some View {
Text(String(localized: "sources.status.disabled"))
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary)
.clipShape(Capsule())
}
// MARK: - Actions
private func confirmDelete() {
if let instance = pendingDeleteInstance, let manager = instancesManager {
manager.remove(instance)
pendingDeleteInstance = nil
}
if let source = pendingDeleteSource, let manager = mediaSourcesManager {
manager.remove(source)
pendingDeleteSource = nil
}
}
private var deleteConfirmationMessage: String {
if let instance = pendingDeleteInstance {
return String(localized: "sources.delete.confirmation.single \(instance.displayName)")
} else if let source = pendingDeleteSource {
return String(localized: "sources.delete.confirmation.single \(source.name)")
}
return String(localized: "sources.delete.confirmation")
}
}
// MARK: - Preview
#Preview {
NavigationStack {
SourcesListView()
}
.appEnvironment(.preview)
}