mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
The NavigationStack-level background wasn't enough: macOS draws its own translucent material behind pushed navigation pages, and Form/List containers draw a translucent scroll background on top of anything placed behind them, so several pages kept the wallpaper-tinted look. Bake the opaque background into SettingsFormContainer (covers all pages built on it, pushed or not) and add opaqueSettingsFormBackground(), which hides the container's scroll background before applying the opaque one, to every macOS-reachable Form/List settings page: player controls and its sub-editors, sidebar settings, edit source, legacy data import, subscription/playlist import, customize home and its shortcut style page, and the log viewer. Contributors, translators and the remote control device page get the plain opaque background. Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
318 lines
10 KiB
Swift
318 lines
10 KiB
Swift
//
|
|
// ImportSubscriptionsView.swift
|
|
// Yattee
|
|
//
|
|
// View for importing subscriptions from an Invidious or Piped instance to local storage.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ImportSubscriptionsView: View {
|
|
let instance: Instance
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
@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
|
|
|
|
// 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"
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
.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 {
|
|
await loadSubscriptions()
|
|
}
|
|
}
|
|
|
|
// 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(
|
|
String(localized: "import.subscriptions.emptyTitle"),
|
|
systemImage: "person.2.slash",
|
|
description: Text(String(localized: "import.subscriptions.emptyDescription"))
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.accessibilityIdentifier(AccessibilityID.emptyState)
|
|
}
|
|
|
|
private var listView: some View {
|
|
List {
|
|
ForEach(channels) { channel in
|
|
subscriptionRow(channel)
|
|
.accessibilityIdentifier(AccessibilityID.row(channel.id.channelID))
|
|
}
|
|
}
|
|
.opaqueSettingsFormBackground()
|
|
.accessibilityIdentifier(AccessibilityID.list)
|
|
}
|
|
|
|
// 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 {
|
|
Button {
|
|
addSubscription(channel)
|
|
} label: {
|
|
Image(systemName: "plus.circle")
|
|
.imageScale(.large)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.accessibilityIdentifier(AccessibilityID.addButton(channel.id.channelID))
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var unsubscribedChannels: [Channel] {
|
|
channels.filter { !subscribedChannelIDs.contains($0.id.channelID) }
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func loadSubscriptions() async {
|
|
guard let appEnvironment,
|
|
let credential = appEnvironment.credentialsManager(for: instance)?.credential(for: instance) else {
|
|
error = ImportError.notLoggedIn
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
error = nil
|
|
|
|
do {
|
|
let fetchedChannels: [Channel]
|
|
|
|
switch instance.type {
|
|
case .invidious:
|
|
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
|
|
let subscriptions = try await api.subscriptions(instance: instance, sid: credential)
|
|
fetchedChannels = subscriptions.map { $0.toChannel(baseURL: instance.url) }
|
|
|
|
case .piped:
|
|
let api = PipedAPI(httpClient: appEnvironment.httpClient)
|
|
let subscriptions = try await api.subscriptions(instance: instance, authToken: credential)
|
|
fetchedChannels = subscriptions.map { $0.toChannel() }
|
|
|
|
default:
|
|
throw ImportError.notSupported
|
|
}
|
|
|
|
channels = fetchedChannels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
|
|
// Get already-subscribed channel IDs
|
|
subscribedChannelIDs = Set(
|
|
appEnvironment.dataManager.subscriptions().map(\.channelID)
|
|
)
|
|
|
|
isLoading = false
|
|
} catch {
|
|
self.error = error
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
private func addSubscription(_ channel: Channel) {
|
|
guard let dataManager = appEnvironment?.dataManager else { return }
|
|
|
|
dataManager.subscribe(to: channel)
|
|
subscribedChannelIDs.insert(channel.id.channelID)
|
|
|
|
appEnvironment?.toastManager.showSuccess(String(localized: "import.subscriptions.added.title"))
|
|
}
|
|
|
|
private func addAllSubscriptions() {
|
|
guard let dataManager = appEnvironment?.dataManager else { return }
|
|
|
|
let toAdd = unsubscribedChannels
|
|
for channel in toAdd {
|
|
dataManager.subscribe(to: channel)
|
|
subscribedChannelIDs.insert(channel.id.channelID)
|
|
}
|
|
|
|
appEnvironment?.toastManager.showSuccess(
|
|
String(localized: "import.subscriptions.added.title"),
|
|
subtitle: String(localized: "import.subscriptions.count.subtitle \(toAdd.count)")
|
|
)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|