mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
631 lines
20 KiB
Swift
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)
|
|
}
|