Files
yattee/Yattee/Views/Settings/SourcesListView.swift
Arkadiusz Fal 6d3bea7678 Rework tvOS sources list with sidebar and full-screen add flow
Give TVSidebarDetailContainer an optional bottom action slot and use it to
show the Add Source button beside the sources list on tvOS. Switch the
Settings > Sources list from a focus-capturing List to the same
ScrollView+LazyVStack layout MediaSourcesView already uses, drop
.buttonStyle(.card) so row icons no longer clip, and bump the row
icon-to-title spacing to 24pt. Replace the sheet-based Add/Edit flow in
MediaSourcesView with navigationDestinations wrapped in the sidebar
container, and decorate each Add Source form (WebDAV, SMB, remote server,
PeerTube browse) with its own sidebar icon and title.
2026-04-18 20:38:01 +02:00

476 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 os(tvOS)
TVSidebarDetailContainer(
systemImage: "server.rack",
title: String(localized: "sources.title"),
bottomAction: {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
.accessibilityIdentifier("sources.addButton")
}
) {
sourcesInner
}
#else
sourcesInner
#endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "sources.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
.accessibilityIdentifier("sources.addButton")
}
}
#endif
#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
}
}
#if !os(tvOS)
.presentationCompactAdaptation(.sheet)
#endif
}
@ViewBuilder
private var sourcesInner: some View {
if isEmpty {
emptyState
} else {
sourcesList
}
}
// 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 {
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
.ignoresSafeArea()
.overlay(
ScrollView {
LazyVStack(spacing: 0) {
remoteServersSection
fileSourcesSection
}
}
)
}
// 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)
}
.foregroundStyle(.primary)
}
#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 {
#if os(tvOS)
let rowSpacing: CGFloat = 24
#else
let rowSpacing: CGFloat = 12
#endif
return HStack(spacing: rowSpacing) {
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)
}
.foregroundStyle(.primary)
}
#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 {
#if os(tvOS)
let rowSpacing: CGFloat = 24
#else
let rowSpacing: CGFloat = 12
#endif
return HStack(spacing: rowSpacing) {
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)
}