Files
yattee/Yattee/Views/Settings/ImportSubscriptionsView.swift
Arkadiusz Fal c19be0a0ef Give the empty tvOS import screens a focusable Done button
With nothing to import, the playlists and subscriptions import screens
had no focusable content; only the container's toolbar Done, which is
not a dependable focus target on tvOS.
2026-10-18 23:15:16 +02:00

483 lines
16 KiB
Swift

//
// ImportSubscriptionsView.swift
// Yattee
//
// View for importing subscriptions from an Invidious or Piped instance into a
// chosen destination: the local store, or another logged-in account.
//
import SwiftUI
struct ImportSubscriptionsView: View {
let instance: Instance
@Environment(\.appEnvironment) private var appEnvironment
#if os(tvOS)
@Environment(\.dismiss) private var dismiss
#endif
@State private var channels: [Channel] = []
@State private var subscribedChannelIDs: Set<String> = []
@State private var isLoading = true
@State private var error: Error?
@State private var showAddAllConfirmation = false
@State private var selectedDestination: DataContext = .local
@State private var destinationSeeded = false
/// Channels with a subscribe in flight, so a slow server write cannot be
/// double-tapped into two requests.
@State private var pendingChannelIDs: Set<String> = []
@State private var isAddingAll = false
// MARK: - Accessibility Identifiers
private enum AccessibilityID {
static let view = "import.subscriptions.view"
static let loadingIndicator = "import.subscriptions.loading"
static let errorMessage = "import.subscriptions.error"
static let emptyState = "import.subscriptions.empty"
static let list = "import.subscriptions.list"
static func row(_ channelID: String) -> String {
"import.subscriptions.row.\(channelID)"
}
static func addButton(_ channelID: String) -> String {
"import.subscriptions.add.\(channelID)"
}
static func subscribedIndicator(_ channelID: String) -> String {
"import.subscriptions.subscribed.\(channelID)"
}
static let addAllButton = "import.subscriptions.addAll"
static let destinationPicker = "import.subscriptions.destination"
}
// MARK: - Body
var body: some View {
content
#if !os(tvOS)
.navigationTitle(String(localized: "import.subscriptions.title"))
#endif
.accessibilityIdentifier(AccessibilityID.view)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
if !unsubscribedChannels.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button {
showAddAllConfirmation = true
} label: {
Label(String(localized: "import.subscriptions.addAll"), systemImage: "plus.circle")
}
.disabled(isAddingAll)
.accessibilityIdentifier(AccessibilityID.addAllButton)
}
}
}
.confirmationDialog(
String(localized: "import.subscriptions.addAllConfirmation \(unsubscribedChannels.count)"),
isPresented: $showAddAllConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "import.subscriptions.addAll")) {
addAllSubscriptions()
}
}
.presentationCompactAdaptation(.sheet)
.task {
seedDestination()
await loadSubscriptions()
}
// Re-asked per destination: the marks describe where the channels
// would be added, not where they came from.
.task(id: selectedDestination) {
await refreshSubscribedChannelIDs()
}
}
// MARK: - Content Views
@ViewBuilder
private var content: some View {
if isLoading {
loadingView
} else if let error {
errorView(error)
} else if channels.isEmpty {
emptyView
} else {
listView
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text(String(localized: "import.subscriptions.loading"))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier(AccessibilityID.loadingIndicator)
}
private func errorView(_ error: Error) -> some View {
ContentUnavailableView {
Label(String(localized: "import.subscriptions.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button(String(localized: "common.retry")) {
Task { await loadSubscriptions() }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier(AccessibilityID.errorMessage)
}
private var emptyView: some View {
ContentUnavailableView {
Label(String(localized: "import.subscriptions.emptyTitle"), systemImage: "person.2.slash")
} description: {
Text(String(localized: "import.subscriptions.emptyDescription"))
} actions: {
#if os(tvOS)
// Nothing in the content is focusable here; the container's
// toolbar Done is not something to rely on to keep the remote free.
Button(String(localized: "common.done")) {
dismiss()
}
#endif
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier(AccessibilityID.emptyState)
}
private var listView: some View {
List {
if destinations.count > 1 {
Section {
destinationPicker
.accessibilityIdentifier(AccessibilityID.destinationPicker)
}
}
Section {
ForEach(channels) { channel in
subscriptionRow(channel)
.accessibilityIdentifier(AccessibilityID.row(channel.id.channelID))
}
}
}
.opaqueSettingsFormBackground()
.accessibilityIdentifier(AccessibilityID.list)
}
// MARK: - Destination Picker
private var destinationPicker: some View {
#if os(tvOS)
// A tvOS menu picker shows only its value; the name of the control has
// to be the row's own — the same shape as the playlist selector's.
Menu {
Picker(selection: $selectedDestination) {
destinationOptionRows
} label: {}
} label: {
HStack {
Text(String(localized: "accounts.switcher.title"))
Spacer(minLength: 8)
Text(DataContextSwitcherLabels.title(for: selectedDestination, in: appEnvironment))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
#else
Picker(String(localized: "accounts.switcher.title"), selection: $selectedDestination) {
destinationOptionRows
}
.pickerStyle(.menu)
#endif
}
@ViewBuilder private var destinationOptionRows: some View {
ForEach(destinations, id: \.self) { context in
Text(DataContextSwitcherLabels.title(for: context, in: appEnvironment))
.tag(context)
}
}
// MARK: - Row View
@ViewBuilder
private func subscriptionRow(_ channel: Channel) -> some View {
HStack(spacing: 12) {
// Channel avatar
if let avatarURL = channel.thumbnailURL {
AsyncImage(url: avatarURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Image(systemName: "person.circle.fill")
.resizable()
.foregroundStyle(.secondary)
}
.frame(width: 32, height: 32)
.clipShape(Circle())
} else {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 32, height: 32)
.foregroundStyle(.secondary)
}
// Name
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(channel.name)
.lineLimit(1)
if channel.isVerified {
Image(systemName: "checkmark.seal.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let subscriberCount = channel.formattedSubscriberCount {
Text(subscriberCount)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
// Add/Subscribed indicator
if subscribedChannelIDs.contains(channel.id.channelID) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.imageScale(.large)
.accessibilityIdentifier(AccessibilityID.subscribedIndicator(channel.id.channelID))
} else if pendingChannelIDs.contains(channel.id.channelID) {
ProgressView()
} else {
Button {
addSubscription(channel)
} label: {
Image(systemName: "plus.circle")
.imageScale(.large)
}
.buttonStyle(.borderless)
.disabled(isAddingAll)
.accessibilityIdentifier(AccessibilityID.addButton(channel.id.channelID))
}
}
.contentShape(Rectangle())
}
// MARK: - Computed Properties
private var unsubscribedChannels: [Channel] {
channels.filter { !subscribedChannelIDs.contains($0.id.channelID) }
}
/// The account whose list is being imported. Its context is not offered as
/// a destination — every channel is already subscribed there.
private var sourceAccountContext: DataContext? {
guard let appEnvironment,
let account = appEnvironment.account(forInstance: instance) else { return nil }
return .account(account.id)
}
/// Where the channels can be added: the same destinations a subscribe menu
/// offers, minus the source itself.
private var destinations: [DataContext] {
SubscriptionDestinations.available(in: appEnvironment)
.filter { $0 != sourceAccountContext }
}
// MARK: - Actions
/// Opens on the account being browsed when it is a valid target, otherwise
/// local — which always is.
private func seedDestination() {
guard !destinationSeeded else { return }
destinationSeeded = true
guard let current = appEnvironment?.accountsManager.currentContext,
!current.isLocal,
destinations.contains(current)
else { return }
selectedDestination = current
}
private func loadSubscriptions() async {
guard let appEnvironment,
let credential = appEnvironment.credential(forInstance: instance) else {
error = ImportError.notLoggedIn
isLoading = false
return
}
isLoading = true
error = nil
do {
guard instance.type.accountCapabilities.supportsSubscriptions else {
throw ImportError.notSupported
}
let fetchedChannels = try await appEnvironment.contentService.subscriptions(
for: instance,
credential: credential
)
channels = fetchedChannels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
isLoading = false
} catch {
self.error = error
isLoading = false
}
}
/// Marks the rows already subscribed on the selected destination.
private func refreshSubscribedChannelIDs() async {
guard let service = appEnvironment?.subscriptionService else { return }
subscribedChannelIDs = []
let destination = selectedDestination
let ids = await service.subscribedChannelIDs(in: destination)
guard destination == selectedDestination else { return }
subscribedChannelIDs = ids ?? []
}
private func addSubscription(_ channel: Channel) {
guard let appEnvironment else { return }
let destination = selectedDestination
let channelID = channel.id.channelID
pendingChannelIDs.insert(channelID)
Task {
defer { pendingChannelIDs.remove(channelID) }
do {
try await appEnvironment.subscriptionService.subscribe(to: channel, in: destination)
} catch SubscriptionProviderError.alreadySubscribed {
// The mark below is the only correction needed.
} catch {
appEnvironment.toastManager.showError(
String(localized: "channel.subscribe.error.title"),
subtitle: error.localizedDescription
)
return
}
if destination == selectedDestination {
subscribedChannelIDs.insert(channelID)
}
appEnvironment.toastManager.showSuccess(
String(localized: "import.subscriptions.added.title"),
subtitle: DataContextSwitcherLabels.title(for: destination, in: appEnvironment)
)
}
}
private func addAllSubscriptions() {
guard let appEnvironment else { return }
let destination = selectedDestination
let toAdd = unsubscribedChannels
isAddingAll = true
Task {
defer { isAddingAll = false }
var added = 0
var failed = 0
// Serially: a server destination gets one request at a time, not a
// burst the size of someone's subscription list.
for channel in toAdd {
let channelID = channel.id.channelID
pendingChannelIDs.insert(channelID)
defer { pendingChannelIDs.remove(channelID) }
do {
try await appEnvironment.subscriptionService.subscribe(to: channel, in: destination)
added += 1
} catch SubscriptionProviderError.alreadySubscribed {
added += 1
} catch {
failed += 1
LoggingService.shared.error(
"Failed to import subscription \(channelID): \(error.localizedDescription)",
category: .general
)
continue
}
if destination == selectedDestination {
subscribedChannelIDs.insert(channelID)
}
}
if failed > 0 {
appEnvironment.toastManager.showError(
String(localized: "channel.subscribe.error.title"),
subtitle: String(localized: "import.subscriptions.count.subtitle \(added)")
)
} else {
appEnvironment.toastManager.showSuccess(
String(localized: "import.subscriptions.added.title"),
subtitle: String(localized: "import.subscriptions.count.subtitle \(added)")
)
}
}
}
// MARK: - Errors
enum ImportError: LocalizedError {
case notLoggedIn
case notSupported
var errorDescription: String? {
switch self {
case .notLoggedIn:
return String(localized: "import.subscriptions.notLoggedIn")
case .notSupported:
return String(localized: "import.subscriptions.notSupported")
}
}
}
}
// MARK: - Preview
#Preview("Invidious") {
NavigationStack {
ImportSubscriptionsView(
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
)
.appEnvironment(.preview)
}
}
#Preview("Piped") {
NavigationStack {
ImportSubscriptionsView(
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
)
.appEnvironment(.preview)
}
}