Files
yattee/Yattee/Views/MediaBrowser/MediaSourcesView.swift
Arkadiusz Fal f44c1d91c3 Refuse to repair a context that is not this identity's
Foreground activation repairs a selection whose account this device
knows was deleted. The check for whether that is safe was the identity
scope being resolved, which it already is in the window between an Apple
account change moving the namespace and the reload refilling memory —
so the repair weighed the previous identity's selection against the
previous identity's tombstones and announced the result to a user who
had not been shown an account yet. Fenced on the loaded namespace, the
same way saves are.

Three smaller things around the same theme:

- The destination rows forgot their membership answers on a credential
  or account change but not on an instance change, which adds a row to
  the picker while it is open. Unknown reads as unsubscribed, so that
  row offered to subscribe a channel the account already has.

- The subscriptions settings count applied whatever the fetch returned.
  A selection change is fenced by the service's provider generation, but
  the fallback to local is driven by availability and announces nothing,
  so the context is captured before the fetch and rechecked after it.

- Deleting a source from the browser runs the same cascade as deleting
  it from Settings, and was the one dialog left that did not say the
  logins go with it.
2026-09-04 23:59:27 +02:00

673 lines
23 KiB
Swift

//
// MediaSourcesView.swift
// Yattee
//
// View for browsing all configured sources (instances and media sources).
//
import SwiftUI
struct MediaSourcesView: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var sourceToEdit: UnifiedSource?
@State private var showingAddSheet = false
@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 sourcesSettings: SourcesSettings? {
appEnvironment?.sourcesSettings
}
private var isEmpty: Bool {
(instancesManager?.enabledInstances.isEmpty ?? true) &&
(mediaSourcesManager?.enabledSources.isEmpty ?? true)
}
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
/// 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
}
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")
}
if let settings = sourcesSettings {
sortAndGroupMenu(settings)
}
Spacer()
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 8)
.focusSection()
mediaSourcesInner
.focusSection()
}
}
#else
mediaSourcesInner
#endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "sources.title"))
.toolbarTitleDisplayMode(.inlineLarge)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
}
ToolbarItem(placement: .primaryAction) {
if let settings = sourcesSettings {
sortAndGroupMenu(settings)
}
}
}
#endif
#if os(tvOS)
.navigationDestination(item: $sourceToEdit) { source in
TVSidebarDetailContainer(systemImage: "pencil.circle", title: String(localized: "sources.editSource")) {
EditSourceView(source: source)
}
}
.navigationDestination(isPresented: $showingAddSheet) {
TVSidebarDetailContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) {
AddSourceView()
}
}
#else
.sheet(item: $sourceToEdit) { source in
EditSourceView(source: source)
}
.sheet(isPresented: $showingAddSheet) {
AddSourceView()
}
#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
}
} message: {
// The same cascade runs from here as from Settings, so the same
// logins go with it — this dialog was simply the one still silent
// about it.
if let accounts = deleteConfirmationAccounts {
Text(accounts)
}
}
#if !os(tvOS)
.presentationCompactAdaptation(.sheet)
#endif
}
@ViewBuilder
private var mediaSourcesInner: some View {
if isEmpty {
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)
} else {
sourcesList
}
}
// MARK: - Private
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")
}
private var deleteConfirmationAccounts: String? {
guard let instance = pendingDeleteInstance else { return nil }
return InstanceDeletionDisclosure.message(for: instance, in: appEnvironment)
}
private func confirmDelete() {
if let instance = pendingDeleteInstance {
appEnvironment?.removeInstance(instance)
pendingDeleteInstance = nil
}
if let source = pendingDeleteSource {
mediaSourcesManager?.remove(source)
pendingDeleteSource = nil
}
}
@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
}
private var sourcesList: some View {
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
.ignoresSafeArea()
.overlay(
ScrollView {
LazyVStack(spacing: 0) {
if let settings = sourcesSettings, !settings.groupByType {
// Ungrouped: All sources in one section
allSourcesSection(settings)
} else {
// Grouped by type (default)
groupedSourcesSections
}
}
}
)
#if os(tvOS)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
firstSourceFocused = true
}
}
#endif
}
@ViewBuilder
private var groupedSourcesSections: some View {
let settings = sourcesSettings
let hasInstances = !(instancesManager?.enabledInstances.isEmpty ?? true)
// Instances section
if let manager = instancesManager, !manager.enabledInstances.isEmpty {
sectionHeader(String(localized: "sources.section.remoteServers"))
let sortedInstances = settings?.sorted(manager.enabledInstances) ?? manager.enabledInstances
sectionCard {
instancesSectionContent(sortedInstances, firstIsGlobalFirst: true)
}
}
// Media sources section
if let manager = mediaSourcesManager, !manager.enabledSources.isEmpty {
sectionHeader(String(localized: "sources.section.fileSources"))
let sortedSources = settings?.sorted(manager.enabledSources) ?? manager.enabledSources
sectionCard {
fileSourcesSectionContent(sortedSources, firstIsGlobalFirst: !hasInstances)
}
}
}
@ViewBuilder
private func allSourcesSection(_ settings: SourcesSettings) -> some View {
let sortedSources = allUnifiedSources(settings: settings)
if !sortedSources.isEmpty {
sectionHeader(String(localized: "sources.section.allSources"))
sectionCard {
ForEach(Array(sortedSources.enumerated()), id: \.element.id) { index, item in
let isLast = index == sortedSources.count - 1
let isFirst = index == 0
switch item {
case .instance(let instance):
instanceRowView(instance, isLast: isLast, isFirst: isFirst)
case .mediaSource(let source):
fileSourceRowView(source, isLast: isLast, isFirst: isFirst)
}
}
}
}
}
private func allUnifiedSources(settings: SourcesSettings) -> [UnifiedSourceItem] {
let instances = instancesManager?.enabledInstances ?? []
let mediaSources = mediaSourcesManager?.enabledSources ?? []
var allSources: [UnifiedSourceItem] = []
allSources.append(contentsOf: instances.map { UnifiedSourceItem.instance($0) })
allSources.append(contentsOf: mediaSources.map { UnifiedSourceItem.mediaSource($0) })
return sortUnifiedSources(allSources, settings: settings)
}
private func sortUnifiedSources(_ sources: [UnifiedSourceItem], settings: SourcesSettings) -> [UnifiedSourceItem] {
sources.sorted { first, second in
let comparison: Bool
switch settings.sortOption {
case .name:
comparison = first.displayName.localizedCaseInsensitiveCompare(second.displayName) == .orderedAscending
case .type:
comparison = first.typeDisplayName.localizedCaseInsensitiveCompare(second.typeDisplayName) == .orderedAscending
case .dateAdded:
comparison = first.dateAdded < second.dateAdded
}
return settings.sortDirection == .ascending ? comparison : !comparison
}
}
// MARK: - Sort and Group Menu
@ViewBuilder
private func sortAndGroupMenu(_ settings: SourcesSettings) -> some View {
Menu {
// Sort options
Section {
Picker(selection: Binding(
get: { settings.sortOption },
set: { settings.sortOption = $0 }
)) {
ForEach(settings.availableSortOptions, id: \.self) { option in
Label(option.displayName, systemImage: option.systemImage)
.tag(option)
}
} label: {
Label(String(localized: "sources.sort.title"), systemImage: "arrow.up.arrow.down")
}
// Sort direction
Button {
settings.sortDirection.toggle()
} label: {
Label(
settings.sortDirection == .ascending
? String(localized: "sources.sort.ascending")
: String(localized: "sources.sort.descending"),
systemImage: settings.sortDirection.systemImage
)
}
}
// Grouping
Section {
Toggle(isOn: Binding(
get: { settings.groupByType },
set: {
settings.groupByType = $0
// Reset to name sort if type sort was selected and grouping is now enabled
if $0 && settings.sortOption == .type {
settings.sortOption = .name
}
}
)) {
Label(String(localized: "sources.groupByType"), systemImage: "rectangle.3.group")
}
}
} label: {
Label(String(localized: "sources.sortAndGroup"), systemImage: "line.3.horizontal.decrease.circle")
}
}
@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()
}
.background(ListBackgroundStyle.card.color)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.bottom, 16)
} else {
LazyVStack(spacing: 0) {
content()
}
.padding(.bottom, 16)
}
#endif
}
@ViewBuilder
private func instancesSectionContent(_ instances: [Instance], firstIsGlobalFirst: Bool = false) -> some View {
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
let isLastInSection = index == instances.count - 1
instanceRowView(instance, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
}
}
@ViewBuilder
private func instanceRowView(_ instance: Instance, isLast: Bool, isFirst: Bool = false) -> some View {
SourceListRow(isLast: isLast, listStyle: listStyle) {
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
instanceRow(instance)
}
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary)
#endif
}
#if os(tvOS)
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#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")
}
}
}
@ViewBuilder
private func fileSourcesSectionContent(_ sources: [MediaSource], firstIsGlobalFirst: Bool = false) -> some View {
ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in
let isLastInSection = index == sources.count - 1
fileSourceRowView(source, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
}
}
@ViewBuilder
private func fileSourceRowView(_ source: MediaSource, isLast: Bool, isFirst: Bool = false) -> some View {
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
SourceListRow(isLast: isLast, listStyle: listStyle) {
if needsPassword {
Button {
sourceToEdit = .fileSource(source)
} label: {
mediaSourceRow(source, needsPassword: true)
}
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary)
#endif
} else {
NavigationLink(value: NavigationDestination.mediaBrowser(source, path: "/")) {
mediaSourceRow(source, needsPassword: false)
}
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary)
#endif
}
}
#if os(tvOS)
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#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")
}
}
}
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) {
Text(instance.displayName)
.font(titleFont)
.foregroundStyle(.primary)
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
.font(.caption)
.foregroundStyle(.secondary)
if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()
#if !os(macOS)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
#endif
}
.contentShape(Rectangle())
}
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) {
Text(source.name)
.font(titleFont)
.foregroundStyle(.primary)
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(.secondary)
#endif
}
.contentShape(Rectangle())
}
}
// MARK: - Unified Source Item
/// Unified wrapper for sorting instances and media sources together.
private enum UnifiedSourceItem: Identifiable {
case instance(Instance)
case mediaSource(MediaSource)
var id: String {
switch self {
case .instance(let instance):
return "instance-\(instance.id.uuidString)"
case .mediaSource(let source):
return "source-\(source.id.uuidString)"
}
}
var displayName: String {
switch self {
case .instance(let instance):
return instance.displayName
case .mediaSource(let source):
return source.name
}
}
var typeDisplayName: String {
switch self {
case .instance(let instance):
return instance.type.displayName
case .mediaSource(let source):
return source.type.displayName
}
}
var dateAdded: Date {
switch self {
case .instance(let instance):
return instance.dateAdded
case .mediaSource(let source):
return source.dateAdded
}
}
}
#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 {
MediaSourcesView()
}
.appEnvironment(.preview)
}