Files
yattee/Yattee/Views/Settings/SourcesListView.swift
Arkadiusz Fal 7c966b18a9 Keep deleted credentials and cached feeds from outliving their account
A deletion can reach a device before the account it deletes does — the
tombstone and the account are separate records, and iCloud Keychain runs
on its own timeline — and the credential cleanup was skipped whenever
the account was unknown here. The scoped key needs only the account ID,
so both credential managers are now asked to drop it; the unscoped
legacy item still needs the instance and is left alone.

Cached data is invalidated on the data context rather than on the legacy
selection it maps to. Two logins on the same instance collapse onto the
same legacy value, so switching between them cleared nothing and the
subscriptions feed kept showing the previous account's videos. Home
feeds are keyed by instance alone and had the same hole across accounts
and across Apple accounts; they are dropped with it.

The third source-deletion path now cascades to accounts like the other
two, and the Invidious private-playlist lookup resolves its session
through the account — a login created after the revamp writes only an
account-scoped credential, so the instance-keyed lookup missed it and
fell through to the public endpoint.
2026-08-25 23:59:17 +02:00

631 lines
20 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?
#if os(tvOS)
@FocusState private var firstSourceFocused: Bool
#endif
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")
) {
VStack(spacing: 0) {
HStack(spacing: 24) {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
.accessibilityIdentifier("sources.addButton")
Spacer()
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 8)
.focusSection()
sourcesInner
.focusSection()
}
}
#elseif os(macOS)
VStack(spacing: 0) {
// macOS 15 does not surface a detail-pane NavigationStack's
// toolbar items to the window title bar, so the toolbar
// "Add Source" button is invisible there. Show an inline
// header button as a fallback on older macOS. On macOS 26+
// the toolbar button works, so this header is omitted.
macOSLegacyAddHeader
sourcesInner
}
#else
sourcesInner
#endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "sources.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(iOS) || os(macOS)
.toolbar {
if showAddButtonInToolbar {
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
}
/// Whether the "Add Source" button should be placed in the window/navigation
/// toolbar. On macOS this only works reliably from macOS 26 onward (see
/// `macOSLegacyAddHeader`); iOS always uses the toolbar.
private var showAddButtonInToolbar: Bool {
#if os(iOS)
return true
#elseif os(macOS)
if #available(macOS 26, *) {
return true
} else {
return false
}
#else
return false
#endif
}
#if os(macOS)
// Inline fallback "Add Source" button for macOS versions where the
// detail-pane toolbar item is not shown (pre-macOS 26). Only needed when
// there are existing sources — the empty state already offers its own
// add button.
@ViewBuilder
private var macOSLegacyAddHeader: some View {
if !showAddButtonInToolbar, !isEmpty {
HStack {
Spacer()
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
.accessibilityIdentifier("sources.addButton")
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
}
}
#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
}
}
)
#if os(tvOS)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
firstSourceFocused = true
}
}
#endif
}
// MARK: - Section Header
@ViewBuilder
private func sectionHeader(_ title: String) -> some View {
#if os(macOS)
Text(title)
.font(.subheadline)
.textCase(.uppercase)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
#else
Text(title)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, listStyle == .inset ? 32 : 16)
.padding(.top, 16)
.padding(.bottom, 8)
#endif
}
// MARK: - Section Card
@ViewBuilder
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
#if os(macOS)
VStack(spacing: 0) {
Divider()
LazyVStack(spacing: 0) {
content()
}
Divider()
}
.padding(.bottom, 12)
#else
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)
}
#endif
}
// 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, isFirst: index == 0)
}
}
}
}
@ViewBuilder
private func instanceRowView(_ instance: Instance, isLast: Bool, isFirst: Bool = false) -> some View {
#if os(tvOS)
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
sourceToEdit = .remoteServer(instance)
} label: {
instanceRow(instance)
}
.foregroundStyle(.primary)
}
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#else
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
sourceToEdit = .remoteServer(instance)
} label: {
instanceRow(instance)
}
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary)
#endif
}
.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
#if os(macOS)
let iconFont: Font = .title3
let iconFrameWidth: CGFloat = 24
let titleFont: Font = .body
#else
let iconFont: Font = .title2
let iconFrameWidth: CGFloat = 32
let titleFont: Font = .headline
#endif
return HStack(spacing: rowSpacing) {
Image(systemName: instance.type.systemImage)
.font(iconFont)
.foregroundStyle(.tint)
.frame(width: iconFrameWidth)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(instance.displayName)
.font(titleFont)
.foregroundStyle(.primary)
if !instance.isEnabled {
disabledBadge
}
}
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
.font(.caption)
.foregroundStyle(.secondary)
instanceStatusView(for: instance)
}
Spacer()
#if !os(macOS)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
#endif
}
.contentShape(Rectangle())
}
/// Whether a remote server instance requires credentials that are missing from
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
private func needsCredentials(_ instance: Instance) -> Bool {
appEnvironment?.needsCredentials(for: instance) ?? false
}
@ViewBuilder
private func instanceStatusView(for instance: Instance) -> some View {
if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
} else 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
let noRemoteServers = instancesManager?.instances.isEmpty ?? true
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, isFirst: noRemoteServers && index == 0)
}
}
}
}
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, isFirst: Bool = false) -> 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)
}
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#else
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
sourceToEdit = .fileSource(source)
} label: {
mediaSourceRow(source, needsPassword: needsPassword)
}
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary)
#endif
}
.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
#if os(macOS)
let iconFont: Font = .title3
let iconFrameWidth: CGFloat = 24
let titleFont: Font = .body
#else
let iconFont: Font = .title2
let iconFrameWidth: CGFloat = 32
let titleFont: Font = .headline
#endif
return HStack(spacing: rowSpacing) {
Image(systemName: source.type.systemImage)
.font(iconFont)
.foregroundStyle(.tint)
.frame(width: iconFrameWidth)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(source.name)
.font(titleFont)
.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()
#if !os(macOS)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
#endif
}
.contentShape(Rectangle())
}
// 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 {
appEnvironment?.removeInstance(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")
}
}
#if os(tvOS)
private struct FirstRowFocusModifier: ViewModifier {
let isFirst: Bool
var focus: FocusState<Bool>.Binding
func body(content: Content) -> some View {
if isFirst {
content.focused(focus)
} else {
content
}
}
}
#endif
// MARK: - Preview
#Preview {
NavigationStack {
SourcesListView()
}
.appEnvironment(.preview)
}