From 175b98097a361ab11611b60fad9a952fd27c5f99 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 3 Sep 2026 23:59:32 +0200 Subject: [PATCH] Put the account switcher where the data is, and retire the old setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching accounts was buried in Subscriptions settings behind a picker and a confirmation dialog, and it wrote two stores that could disagree. One shared switcher now sits in the Subscriptions and Playlists toolbars, the macOS sidebar footer and the Settings root, and every path goes through AppEnvironment.switchDataContext(to:) so a switch behaves the same wherever it is made. Switching is one tap: it is reversible, so there is nothing to confirm. Rows come from DataContextValidator.availableContexts, name themselves through displayName(for:), and carry a status suffix when the account is not signed in — plus the instance host when two accounts share a name. tvOS gets the same rows as a full-screen picker. Subscriptions also explains itself when the selected account cannot answer: a thin banner names the cause and says the data below is local. This is the first consumer of the validator's unavailability, and correctIfContextRemoved() now runs on foreground activation, where SubscriptionAccountValidator used to auto-correct any selection it could not immediately verify. With the switcher reading the context directly, the legacy subscriptionAccount setting has no readers left: the setting, its validator, the mirror that kept it in step and the feed cache's instance-keyed fallbacks all go. The cache invalidation the mirror also carried stays, under its own name. AccountsManager still reads the raw legacy key for the one-way context migration. --- Yattee/Core/AppEnvironment.swift | 128 +++----- .../Settings/SettingsManager+General.swift | 29 -- Yattee/Core/SettingsManager.swift | 4 - Yattee/Localizable.xcstrings | 77 +++++ Yattee/Services/SubscriptionFeedCache.swift | 55 +--- .../SubscriptionAccountValidator.swift | 300 ------------------ .../Components/DataContextSwitcherMenu.swift | 251 +++++++++++++++ .../DataContextUnavailableBanner.swift | 69 ++++ Yattee/Views/Home/PlaylistsListView.swift | 5 + Yattee/Views/Navigation/UnifiedTabView.swift | 17 + Yattee/Views/Settings/SettingsView.swift | 4 + .../Settings/SubscriptionsSettingsView.swift | 117 +------ .../Subscriptions/SubscriptionsView.swift | 33 +- Yattee/YatteeApp.swift | 12 +- 14 files changed, 525 insertions(+), 576 deletions(-) delete mode 100644 Yattee/Services/Subscriptions/SubscriptionAccountValidator.swift create mode 100644 Yattee/Views/Components/DataContextSwitcherMenu.swift create mode 100644 Yattee/Views/Components/DataContextUnavailableBanner.swift diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 47cb7051..3a6c4beb 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -59,7 +59,6 @@ final class AppEnvironment { let credentialProbeService: CredentialProbeService let accountService: AccountService let remotePlaylistsCache: RemotePlaylistsCache - let subscriptionAccountValidator: SubscriptionAccountValidator let dataContextValidator: DataContextValidator let playerControlsLayoutService: PlayerControlsLayoutService let legacyMigrationService: LegacyDataMigrationService @@ -71,7 +70,7 @@ final class AppEnvironment { private(set) var activeControlsCenterSettings: CenterSectionSettings = .default @ObservationIgnored private var controlsSettingsObservers: [NSObjectProtocol] = [] - @ObservationIgnored private var contextMirrorObservers: [NSObjectProtocol] = [] + @ObservationIgnored private var contextCacheObservers: [NSObjectProtocol] = [] /// The context the account-scoped caches currently hold data for. @ObservationIgnored private var lastCachedContext: DataContext = .local @@ -429,18 +428,8 @@ final class AppEnvironment { // Initialize Home Instance Cache self.homeInstanceCache = .shared - // Initialize Subscription Account Validator - self.subscriptionAccountValidator = SubscriptionAccountValidator( - settingsManager: settings, - instancesManager: instances, - invidiousCredentialsManager: invidiousCreds, - pipedCredentialsManager: pipedCreds, - toastManager: toast, - feedCache: .shared - ) - - // Validates the account-based data context. Unlike the validator above - // it never corrects a selection it merely cannot verify yet + // Validates the account-based data context. Corrections are restricted + // to deletions: a selection that merely cannot be verified yet is kept self.dataContextValidator = DataContextValidator( accountsManager: accounts, instancesManager: instances, @@ -494,12 +483,10 @@ final class AppEnvironment { dataContextValidator.setAppEnvironment(self) subscriptionService.setContextValidator(dataContextValidator) - subscriptionAccountValidator.setAccountsManager(accounts) // The context can now change without anyone touching this device — a - // choice made on another one, or an account deleted there. The legacy - // setting several views still read has to follow it, and the feed cache - // is global rather than account-keyed, so it would otherwise keep + // choice made on another one, or an account deleted there. The feed + // cache is global rather than account-keyed, so it would otherwise keep // serving the previous account's videos. // // A deletion that arrives from another device takes the credential with @@ -567,7 +554,7 @@ final class AppEnvironment { // back is the same Apple account, and with iCloud Drive off // that happens on every activation. self.discardCachesFromAnotherIdentityIfNeeded() - self.mirrorContextToLegacySelection(resetWhenUnresolvable: true) + self.refreshCachesForEffectiveContext() } } @@ -587,29 +574,28 @@ final class AppEnvironment { // Accounts and instances arriving are triggers too, not redundant ones: // a context naming an account that has not synced yet — or one whose - // instance has not — cannot be mapped onto the legacy shape, and these - // are what restore the mapping once it can be made rather than leaving - // that moment permanent. The same arrivals are what make a synced - // account's credential findable, so statuses are recomputed with them: - // an account that appears mid-session would otherwise sit at "sign in - // required" until the app was relaunched. + // instance has not — reads as unavailable, and these arrivals are what + // move the effective context back onto it. They are also what make a + // synced account's credential findable, so statuses are recomputed with + // them: an account that appears mid-session would otherwise sit at + // "sign in required" until the app was relaunched. // A status change is deliberately not one of the refresh triggers: the // refresher reads the Keychain, which cannot know what a server said, // so reacting to a status by recomputing it would erase every verdict - // the API layer records. It still runs the mirror, because a credential - // arriving or being rejected moves the effective context. + // the API layer records. It still re-examines the caches, because a + // credential arriving or being rejected moves the effective context. for name: Notification.Name in [ .dataContextDidChange, .accountsDidChange, .instancesDidChange, .accountStatusesDidChange, .connectivityDidReturn ] { - contextMirrorObservers.append( + contextCacheObservers.append( NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } if name != .dataContextDidChange, name != .accountStatusesDidChange { self.refreshAccountStatuses() } - self.mirrorContextToLegacySelection() + self.refreshCachesForEffectiveContext() } } ) @@ -709,20 +695,34 @@ final class AppEnvironment { // MARK: - Accounts - /// Keeps the pre-revamp `subscriptionAccount` selection, and the feed cache - /// keyed to it, in step with the current data context. + /// Switches the data context, from wherever in the app the user asked. /// - /// The two stores are written together everywhere the user changes the - /// context by hand; this covers the changes nobody on this device made — a - /// selection synced from another device, or the selected account being - /// deleted there. Phase 3's switcher removes the last readers of the legacy - /// setting and this mirror with them. - /// - Parameter resetWhenUnresolvable: whether a context that cannot be - /// mapped should fall back to `.local` instead of leaving the previous - /// value in place. True only when the Apple account changed: keeping the - /// previous identity's selection is worse than losing a mapping, and the - /// correct value returns as soon as the new namespace's accounts arrive. - private func mirrorContextToLegacySelection(resetWhenUnresolvable: Bool = false) { + /// The one entry point, so every switcher behaves identically: the cache + /// the feed is served from is global rather than account-keyed, and a + /// switch that skipped this would leave the previous account's videos on + /// screen under the new account's name. + func switchDataContext(to context: DataContext) { + guard context != accountsManager.currentContext else { return } + + accountsManager.setContext(context) + SubscriptionFeedCache.shared.handleAccountChange() + + LoggingService.shared.info( + "Data context switched by the user", + category: .general, + details: "context=\(context)" + ) + } + + /// Drops the account-scoped caches when the context whose data they hold + /// has moved. + /// + /// Covers the moves nobody on this device made — a selection synced from + /// another device, an account deleted there, a credential arriving or being + /// rejected. The switcher calls `switchDataContext(to:)` instead, which + /// clears the feed itself rather than waiting for a notification to make + /// the round trip. + private func refreshCachesForEffectiveContext() { // Cached data is invalidated on the *effective* context — the one whose // data is actually on screen. Not on the legacy value, because two // logins on the same instance collapse onto the same one; and not on @@ -738,50 +738,6 @@ final class AppEnvironment { lastCachedContext = effective clearAccountScopedCaches() } - - // The context in memory belongs to whichever namespace was loaded, and - // an unresolved scope is not one whose accounts anybody has looked at - // yet. Mirroring from it would rewrite the legacy selection to `.local` - // on every activation dip — several times a day with iCloud Drive off — - // and the user would find their picker reset. The observer refires when - // the scope comes back, so the mirror catches up rather than being lost. - guard credentialIdentityScope.isResolved else { return } - - let legacy: SubscriptionAccount - - switch accountsManager.currentContext { - case .local: - legacy = .local - - case let .account(id): - // An account whose instance has not arrived yet cannot be mapped - // onto the legacy shape. Leaving the old value alone is right: the - // context is preserved, and the validator reports it unavailable - // rather than pointing the UI at something else. A later arrival - // runs this again and restores the mapping. - guard let account = accountsManager.account(id: id), - let instance = accountsManager.instance(for: account) else { - if resetWhenUnresolvable, settingsManager.subscriptionAccount != .local { - settingsManager.subscriptionAccount = .local - } - return - } - - switch instance.type { - case .invidious: legacy = .invidious(instance.id) - case .piped: legacy = .piped(instance.id) - default: return - } - } - - guard settingsManager.subscriptionAccount != legacy else { return } - - settingsManager.subscriptionAccount = legacy - - LoggingService.shared.info( - "Data context changed — updated the legacy subscription selection", - category: .general - ) } /// Discards everything cached under an account's previous credential. diff --git a/Yattee/Core/Settings/SettingsManager+General.swift b/Yattee/Core/Settings/SettingsManager+General.swift index 89e76763..d8acd704 100644 --- a/Yattee/Core/Settings/SettingsManager+General.swift +++ b/Yattee/Core/Settings/SettingsManager+General.swift @@ -353,35 +353,6 @@ extension SettingsManager { } } - // MARK: - Subscription Account Settings - - /// The subscription account storage key. - private static let subscriptionAccountKey = "subscriptionAccount" - - /// The active subscription account configuration. - /// Determines where subscriptions are stored and fetched from. - /// Defaults to local (iCloud) if not set. - var subscriptionAccount: SubscriptionAccount { - get { - if let cached = _subscriptionAccount { return cached } - guard let data = localDefaults.data(forKey: Self.subscriptionAccountKey), - let account = try? JSONDecoder().decode(SubscriptionAccount.self, from: data) else { - return .local - } - return account - } - set { - _subscriptionAccount = newValue - if let data = try? JSONEncoder().encode(newValue) { - localDefaults.set(data, forKey: Self.subscriptionAccountKey) - // Sync to iCloud if enabled - if iCloudSyncEnabled && syncSettings { - ubiquitousStore.set(data, forKey: Self.subscriptionAccountKey) - } - } - } - } - // MARK: - Notification Settings /// Whether background notifications are enabled. Default is false. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 0e6abfff..e203dff1 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -192,9 +192,6 @@ final class SettingsManager { var _saveRecentChannels: Bool? var _saveRecentPlaylists: Bool? - // Subscription account settings - var _subscriptionAccount: SubscriptionAccount? - // Queue settings var _queueEnabled: Bool? var _queueAutoPlayNext: Bool? @@ -536,7 +533,6 @@ final class SettingsManager { _saveRecentSearches = nil _saveRecentChannels = nil _saveRecentPlaylists = nil - _subscriptionAccount = nil _queueEnabled = nil _queueAutoPlayNext = nil _queueAutoPlayCountdown = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 4fd4aee7..679055e1 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -293,6 +293,17 @@ } } }, + "accounts.switcher.title" : { + "comment" : "Label of the control that switches the selected account", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account" + } + } + } + }, "accounts.useThisAccount" : { "localizations" : { "en" : { @@ -17961,6 +17972,72 @@ } } }, + "subscriptions.banner.checking" : { + "comment" : "Banner shown while the selected account cannot be used yet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for iCloud before your account can be used. Showing local data." + } + } + } + }, + "subscriptions.banner.credentialMissing" : { + "comment" : "Banner shown while the selected account cannot be used yet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to this account on this device to see its data. Showing local data." + } + } + } + }, + "subscriptions.banner.credentialRejected" : { + "comment" : "Banner shown while the selected account cannot be used yet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This account needs to be signed in again. Showing local data." + } + } + } + }, + "subscriptions.banner.instanceDisabled" : { + "comment" : "Banner shown while the selected account cannot be used yet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This account's source is switched off. Showing local data." + } + } + } + }, + "subscriptions.banner.instancePending" : { + "comment" : "Banner shown while the selected account cannot be used yet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This account's source has not arrived from iCloud yet. Showing local data." + } + } + } + }, + "subscriptions.banner.unreachable" : { + "comment" : "Banner shown while the selected account cannot be used yet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This account's server could not be reached. Showing local data." + } + } + } + }, "subscriptions.channels.empty" : { "comment" : "Empty state for channels", "localizations" : { diff --git a/Yattee/Services/SubscriptionFeedCache.swift b/Yattee/Services/SubscriptionFeedCache.swift index bb24f28a..84f24c95 100644 --- a/Yattee/Services/SubscriptionFeedCache.swift +++ b/Yattee/Services/SubscriptionFeedCache.swift @@ -373,37 +373,18 @@ final class SubscriptionFeedCache { } /// Gets the authenticated Invidious instance and session ID. + /// + /// Resolved through the selected account and nothing else: a second login on + /// the same instance has its own credential, and the instance-keyed lookup + /// this used to fall back to cannot tell the two apart. private func getInvidiousAuth(using appEnvironment: AppEnvironment) -> (Instance, String)? { - // Resolve through the selected account so a second login on the same - // instance uses its own credential. - if let account = appEnvironment.account(forSelectedContext: .invidious), - let instance = appEnvironment.accountsManager.instance(for: account), - let sid = appEnvironment.accountCredentialsManager(for: account)?.credential(for: account) { - return (instance, sid) - } - - let legacyAccount = appEnvironment.settingsManager.subscriptionAccount - - let instance: Instance? - if let instanceID = legacyAccount.instanceID { - instance = appEnvironment.instancesManager.instances.first { $0.id == instanceID } - } else { - // Fall back to first enabled Invidious instance - instance = appEnvironment.instancesManager.instances.first { - $0.type == .invidious && $0.isEnabled - } - } - - guard let instance else { - LoggingService.shared.debug("No Invidious instance found for subscription account", category: .general) + guard let account = appEnvironment.account(forSelectedContext: .invidious), + let instance = appEnvironment.accountsManager.instance(for: account), + let sid = appEnvironment.accountCredentialsManager(for: account)?.credential(for: account) else { + LoggingService.shared.debug("No Invidious account resolved for the selected context", category: .general) return nil } - - guard let sid = appEnvironment.credential(forInstance: instance) else { - LoggingService.shared.debug("No session ID found for Invidious instance: \(instance.id)", category: .general) - return nil - } - + return (instance, sid) } @@ -451,22 +432,14 @@ final class SubscriptionFeedCache { /// Gets the authenticated Piped instance and auth token. private func getPipedAuth(using appEnvironment: AppEnvironment) -> (Instance, String)? { - if let account = appEnvironment.account(forSelectedContext: .piped), - let instance = appEnvironment.accountsManager.instance(for: account), - let token = appEnvironment.accountCredentialsManager(for: account)?.credential(for: account) { - return (instance, token) - } - - let legacyAccount = appEnvironment.settingsManager.subscriptionAccount - - guard let instanceID = legacyAccount.instanceID, - let instance = appEnvironment.instancesManager.instances.first(where: { $0.id == instanceID }), - let authToken = appEnvironment.credential(forInstance: instance) else { - LoggingService.shared.debug("No authenticated Piped instance found for subscription account", category: .general) + guard let account = appEnvironment.account(forSelectedContext: .piped), + let instance = appEnvironment.accountsManager.instance(for: account), + let token = appEnvironment.accountCredentialsManager(for: account)?.credential(for: account) else { + LoggingService.shared.debug("No Piped account resolved for the selected context", category: .general) return nil } - return (instance, authToken) + return (instance, token) } // MARK: - Invidious Feed diff --git a/Yattee/Services/Subscriptions/SubscriptionAccountValidator.swift b/Yattee/Services/Subscriptions/SubscriptionAccountValidator.swift deleted file mode 100644 index 68a35945..00000000 --- a/Yattee/Services/Subscriptions/SubscriptionAccountValidator.swift +++ /dev/null @@ -1,300 +0,0 @@ -// -// SubscriptionAccountValidator.swift -// Yattee -// -// Validates subscription account selection and handles auto-correction -// when the selected account becomes unavailable. -// - -import Foundation - -/// Validates subscription account selection and provides available accounts for the picker. -/// Automatically corrects to a valid account if the current selection becomes invalid. -@MainActor -@Observable -final class SubscriptionAccountValidator { - // MARK: - Dependencies - - private let settingsManager: SettingsManager - private let instancesManager: InstancesManager - private let invidiousCredentialsManager: InvidiousCredentialsManager - private let pipedCredentialsManager: PipedCredentialsManager - private let toastManager: ToastManager - private let feedCache: SubscriptionFeedCache - /// Availability is decided by accounts now: a login created after the - /// revamp writes only an account-scoped Keychain key, so the instance-keyed - /// check this validator used would never see it. - private weak var accountsManager: AccountsManager? - - // MARK: - Observation State - - private var observationTask: Task? - - // MARK: - Initialization - - init( - settingsManager: SettingsManager, - instancesManager: InstancesManager, - invidiousCredentialsManager: InvidiousCredentialsManager, - pipedCredentialsManager: PipedCredentialsManager, - toastManager: ToastManager, - feedCache: SubscriptionFeedCache - ) { - self.settingsManager = settingsManager - self.instancesManager = instancesManager - self.invidiousCredentialsManager = invidiousCredentialsManager - self.pipedCredentialsManager = pipedCredentialsManager - self.toastManager = toastManager - self.feedCache = feedCache - - startObserving() - } - - func setAccountsManager(_ manager: AccountsManager) { - accountsManager = manager - } - - /// Whether any account on this instance is usable. - private func hasSignedInAccount(for instance: Instance) -> Bool { - guard let accountsManager else { return false } - return accountsManager.accounts(forInstance: instance.id) - .contains { accountsManager.status(for: $0.id) == .signedIn } - } - - /// Stops observation. Call before releasing the validator. - func stopObserving() { - observationTask?.cancel() - observationTask = nil - } - - // MARK: - Available Accounts - - /// Available subscription accounts based on current configuration. - /// Only includes accounts that are properly configured and authenticated. - var availableAccounts: [SubscriptionAccount] { - var accounts: [SubscriptionAccount] = [] - - // Local is always offered. Its *feed* needs a Yattee Server and says so - // itself when there is none; that is not a reason to make the user's - // own data unselectable — and since correction no longer moves away - // from local, leaving it out would make it unreachable once left. - accounts.append(.local) - - // Add logged-in and enabled Invidious instances - for instance in instancesManager.instances { - if instance.type == .invidious && - instance.isEnabled && - hasSignedInAccount(for: instance) { - accounts.append(.invidious(instance.id)) - } - } - - // Add logged-in and enabled Piped instances - for instance in instancesManager.instances { - if instance.type == .piped && - instance.isEnabled && - hasSignedInAccount(for: instance) { - accounts.append(.piped(instance.id)) - } - } - - return accounts - } - - /// Whether any subscription accounts are available. - var hasAvailableAccounts: Bool { - !availableAccounts.isEmpty - } - - // MARK: - Validation - - /// Whether the current subscription account is valid. - var isCurrentAccountValid: Bool { - let current = settingsManager.subscriptionAccount - return availableAccounts.contains(current) - } - - /// Validates the current account and auto-corrects if invalid. - /// Call this on app launch and when configuration changes. - func validateAndCorrectIfNeeded() { - // Availability is decided from account statuses, and every status is - // "cannot tell yet" while the credential identity scope is unresolved. - // This runs on every foreground activation, so correcting from that - // would overwrite the user's selection with `.local` on a state that - // was never an answer. The observation loop re-runs when the scope - // resolves and the statuses become real. - if accountsManager?.isIdentityResolved == false { - LoggingService.shared.debug( - "Leaving the subscription account alone — the credential identity is still unresolved", - category: .general - ) - return - } - - guard !isCurrentAccountValid else { - LoggingService.shared.debug( - "Current subscription account is valid: \(settingsManager.subscriptionAccount.type)", - category: .general - ) - return - } - - // Local is always a valid context. This validator predates that: it - // treats local as unavailable unless a Yattee Server is configured, - // which is a *feed* concern — the feed says so itself — not a reason to - // move the user onto somebody's backend account, or to overwrite a - // local choice synced from another device. - if accountsManager?.currentContext.isLocal == true { - LoggingService.shared.debug( - "Leaving the local selection alone — local data is always available", - category: .general - ) - return - } - - // A selection whose account still exists is never corrected here. This - // validator predates account-scoped credentials and reads "not signed - // in" as "not available", which is now routinely temporary — an - // unresolved identity scope, or a credential iCloud Keychain has yet to - // deliver. Correcting on that would silently discard the user's choice - // and desynchronize this setting from the data context, which is - // preserved. Only a deletion removes an account, and that path corrects - // the context and this setting together. - if let instanceID = settingsManager.subscriptionAccount.instanceID, - let accountsManager, - !accountsManager.accounts(forInstance: instanceID).isEmpty { - LoggingService.shared.debug( - "Leaving the subscription account alone — its login has not been removed", - category: .general - ) - return - } - - let previousAccount = settingsManager.subscriptionAccount - - // Find first available account to switch to - if let newAccount = availableAccounts.first { - settingsManager.subscriptionAccount = newAccount - adoptCorrectedSelection(newAccount) - feedCache.handleAccountChange() - - let newName = displayName(for: newAccount) - LoggingService.shared.info( - "Auto-corrected subscription account from \(previousAccount.type) to \(newAccount.type)", - category: .general - ) - - toastManager.showInfo( - String(localized: "subscriptions.accountSwitched.title"), - subtitle: newName - ) - } else { - // No valid accounts available - set to local anyway - // The feed will show an appropriate error state - settingsManager.subscriptionAccount = .local - adoptCorrectedSelection(.local) - feedCache.handleAccountChange() - - LoggingService.shared.warning( - "No valid subscription accounts available, defaulting to local", - category: .general - ) - } - } - - /// Moves the data context with a correction made to the legacy setting. - /// - /// Reads and writes resolve through the context now, so correcting only the - /// legacy value would leave the picker naming one account while the data - /// came from another. - private func adoptCorrectedSelection(_ account: SubscriptionAccount) { - guard let accountsManager else { return } - - let context = accountsManager.context(forLegacyInstanceID: account.instanceID) - guard context != accountsManager.currentContext else { return } - - accountsManager.setContext(context) - } - - // MARK: - Display Names - - /// Returns a display name for the given subscription account. - func displayName(for account: SubscriptionAccount) -> String { - switch account.type { - case .local: - return String(localized: "subscriptions.account.local") - - case .invidious: - guard let instanceID = account.instanceID, - let instance = instancesManager.instances.first(where: { $0.id == instanceID }) else { - return String(localized: "subscriptions.account.invidious") - } - // Use instance name if available, otherwise use hostname - let instanceName: String - if let name = instance.name, !name.isEmpty { - instanceName = name - } else { - instanceName = instance.url.host ?? "Invidious" - } - return String(localized: "subscriptions.account.invidiousInstance \(instanceName)") - - case .piped: - guard let instanceID = account.instanceID, - let instance = instancesManager.instances.first(where: { $0.id == instanceID }) else { - return String(localized: "subscriptions.account.piped") - } - // Use instance name if available, otherwise use hostname - let instanceName: String - if let name = instance.name, !name.isEmpty { - instanceName = name - } else { - instanceName = instance.url.host ?? "Piped" - } - return String(localized: "subscriptions.account.pipedInstance \(instanceName)") - } - } - - /// Returns the instance for an Invidious or Piped account, if available. - func instance(for account: SubscriptionAccount) -> Instance? { - guard account.type == .invidious || account.type == .piped, - let instanceID = account.instanceID else { - return nil - } - return instancesManager.instances.first { $0.id == instanceID } - } - - // MARK: - Observation - - /// Starts observing changes to instances and credentials. - private func startObserving() { - observationTask?.cancel() - - observationTask = Task { [weak self] in - guard let self else { return } - - while !Task.isCancelled { - // Wait for changes using withObservationTracking - await withCheckedContinuation { continuation in - withObservationTracking { - // Access the observed properties to register for tracking - _ = self.instancesManager.instances - _ = self.invidiousCredentialsManager.loggedInInstanceIDs - _ = self.pipedCredentialsManager.loggedInInstanceIDs - } onChange: { - // Resume when a change is detected - continuation.resume() - } - } - - guard !Task.isCancelled else { break } - - // Small delay to batch rapid changes - try? await Task.sleep(for: .milliseconds(100)) - guard !Task.isCancelled else { break } - - // Validate after changes - self.validateAndCorrectIfNeeded() - } - } - } -} diff --git a/Yattee/Views/Components/DataContextSwitcherMenu.swift b/Yattee/Views/Components/DataContextSwitcherMenu.swift new file mode 100644 index 00000000..86c4fa76 --- /dev/null +++ b/Yattee/Views/Components/DataContextSwitcherMenu.swift @@ -0,0 +1,251 @@ +// +// DataContextSwitcherMenu.swift +// Yattee +// +// The one place the selected data context is switched from. +// + +import SwiftUI + +// MARK: - Rows + +/// The switchable contexts, as buttons. +/// +/// Shared by every presentation of the switcher so a context that is offered in +/// one place is offered — and named — identically in all of them. +struct DataContextSwitcherRows: View { + @Environment(\.appEnvironment) private var appEnvironment + + /// Called after a switch, for presentations that have to close themselves. + var onSwitch: (() -> Void)? + + private var validator: DataContextValidator? { appEnvironment?.dataContextValidator } + + var body: some View { + ForEach(validator?.availableContexts ?? [.local], id: \.self) { context in + Button { + appEnvironment?.switchDataContext(to: context) + onSwitch?() + } label: { + rowLabel(for: context) + } + #if os(tvOS) + .buttonStyle(TVSettingsButtonStyle()) + #endif + } + } + + @ViewBuilder + private func rowLabel(for context: DataContext) -> some View { + let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment) + + // A menu row is a single label on macOS — a `VStack` inside one renders + // as nothing there — so the status travels in the title rather than as + // a second line. + if isCurrent(context) { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + + private func isCurrent(_ context: DataContext) -> Bool { + appEnvironment?.accountsManager.currentContext == context + } +} + +// MARK: - Labels + +/// Names the switchable contexts. +enum DataContextSwitcherLabels { + /// The context's name, plus whatever the user needs to tell it apart. + /// + /// The host is appended only when several accounts share a display name: + /// two logins on the same instance are told apart by their username, and a + /// name that already carries the host would otherwise repeat it. + @MainActor + static func title(for context: DataContext, in appEnvironment: AppEnvironment?) -> String { + guard let appEnvironment else { return String(localized: "subscriptions.account.local") } + + let name = appEnvironment.dataContextValidator.displayName(for: context) + + guard case let .account(id) = context, let account = appEnvironment.accountsManager.account(id: id) else { + return name + } + + var title = name + + if isAmbiguous(name, in: appEnvironment), + let host = appEnvironment.accountsManager.instance(for: account)?.url.host, + !name.localizedCaseInsensitiveContains(host) { + title = "\(name) · \(host)" + } + + let status = appEnvironment.accountsManager.status(for: id) + guard status != .signedIn else { return title } + + return "\(title) — \(statusText(for: status))" + } + + @MainActor + private static func isAmbiguous(_ name: String, in appEnvironment: AppEnvironment) -> Bool { + appEnvironment.accountsManager.selectableAccounts + .filter { appEnvironment.dataContextValidator.displayName(for: $0) == name } + .count > 1 + } + + static func statusText(for status: AccountStatus) -> String { + switch status { + case .checking: String(localized: "accounts.status.checking") + case .signedIn: String(localized: "accounts.status.signedIn") + case .credentialMissing: String(localized: "accounts.status.credentialMissing") + case .credentialRejected: String(localized: "accounts.status.credentialRejected") + case .unreachable: String(localized: "accounts.status.unreachable") + } + } +} + +// MARK: - Menu + +#if !os(tvOS) +/// The toolbar switcher: one tap, no confirmation — switching is reversible. +struct DataContextSwitcherMenu: View { + @Environment(\.appEnvironment) private var appEnvironment + + var body: some View { + Menu { + DataContextSwitcherRows() + } label: { + Label(String(localized: "accounts.switcher.title"), systemImage: icon) + } + .help(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)) + } + + private var currentContext: DataContext { + appEnvironment?.accountsManager.currentContext ?? .local + } + + /// The filled glyph says an account is selected; the badge says that + /// account cannot be used right now, which is the whole reason the user + /// would reach for this control. + private var icon: String { + guard !currentContext.isLocal else { return "person.crop.circle" } + + if appEnvironment?.dataContextValidator.unavailability != nil { + return "person.crop.circle.badge.exclamationmark" + } + return "person.crop.circle.fill" + } +} + +#endif + +/// A settings row naming the current context, with the switcher attached. +struct DataContextSwitcherSettingsRow: View { + @Environment(\.appEnvironment) private var appEnvironment + #if os(tvOS) + @State private var showingPicker = false + #endif + + var body: some View { + #if os(tvOS) + Button { + showingPicker = true + } label: { + rowLabel + } + .buttonStyle(TVSettingsButtonStyle()) + .fullScreenCover(isPresented: $showingPicker) { + DataContextSwitcherPicker() + } + #else + Menu { + DataContextSwitcherRows() + } label: { + rowLabel + } + #if os(macOS) + // Both callers are full-width rows, so the default style's stretching + // is wanted here — only its chrome is not. + .menuStyle(.borderlessButton) + #endif + #endif + } + + private var rowLabel: some View { + HStack { + Label(String(localized: "accounts.switcher.title"), systemImage: "person.crop.circle") + Spacer(minLength: 8) + Text(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private var currentContext: DataContext { + appEnvironment?.accountsManager.currentContext ?? .local + } +} + +// MARK: - tvOS picker + +#if os(tvOS) +/// The tvOS switcher, presented in a `fullScreenCover`: menus are unusable +/// there, and a sheet is a card too small to read. +struct DataContextSwitcherPicker: View { + @Environment(\.appEnvironment) private var appEnvironment + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 24) { + Text(String(localized: "accounts.switcher.title")) + .font(.title2.bold()) + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + DataContextSwitcherRows { + dismiss() + } + } + } + + Button(String(localized: "common.close")) { + dismiss() + } + .buttonStyle(TVSettingsButtonStyle()) + } + .frame(maxWidth: 900) + .padding(.horizontal, 80) + .padding(.vertical, 60) + } + } +} + +/// The button that presents the picker, for placing beside other tvOS controls. +struct DataContextSwitcherButton: View { + @Environment(\.appEnvironment) private var appEnvironment + @State private var showingPicker = false + + var body: some View { + Button { + showingPicker = true + } label: { + Label( + DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment), + systemImage: "person.crop.circle" + ) + } + .buttonStyle(.bordered) + .fullScreenCover(isPresented: $showingPicker) { + DataContextSwitcherPicker() + } + } + + private var currentContext: DataContext { + appEnvironment?.accountsManager.currentContext ?? .local + } +} +#endif diff --git a/Yattee/Views/Components/DataContextUnavailableBanner.swift b/Yattee/Views/Components/DataContextUnavailableBanner.swift new file mode 100644 index 00000000..45f7db36 --- /dev/null +++ b/Yattee/Views/Components/DataContextUnavailableBanner.swift @@ -0,0 +1,69 @@ +// +// DataContextUnavailableBanner.swift +// Yattee +// +// Says why the selected account is not the one answering right now. +// + +import SwiftUI + +/// A thin strip shown while the selected account context cannot be used. +/// +/// Informational only: the selection is deliberately kept — it comes back on +/// its own when the credential or instance arrives — and reads fall back to +/// local data meanwhile, which is exactly what the user would otherwise have to +/// guess at. The switcher sits in the toolbar beside it for anyone who wants to +/// choose something else. +struct DataContextUnavailableBanner: View { + let reason: DataContextUnavailability + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundStyle(tint) + + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial) + } + + private var icon: String { + switch reason { + case .checking: "clock" + case .instancePending: "clock" + case .instanceDisabled: "server.rack" + case .credentialMissing: "person.badge.key" + case .credentialRejected: "exclamationmark.triangle.fill" + case .unreachable: "wifi.exclamationmark" + } + } + + private var tint: Color { + switch reason { + case .checking, .instancePending: .secondary + case .instanceDisabled: .secondary + case .credentialMissing: .secondary + case .credentialRejected: .red + case .unreachable: .orange + } + } + + private var message: String { + switch reason { + case .checking: String(localized: "subscriptions.banner.checking") + case .instancePending: String(localized: "subscriptions.banner.instancePending") + case .instanceDisabled: String(localized: "subscriptions.banner.instanceDisabled") + case .credentialMissing: String(localized: "subscriptions.banner.credentialMissing") + case .credentialRejected: String(localized: "subscriptions.banner.credentialRejected") + case .unreachable: String(localized: "subscriptions.banner.unreachable") + } + } +} diff --git a/Yattee/Views/Home/PlaylistsListView.swift b/Yattee/Views/Home/PlaylistsListView.swift index 36ba6b29..3e527b69 100644 --- a/Yattee/Views/Home/PlaylistsListView.swift +++ b/Yattee/Views/Home/PlaylistsListView.swift @@ -101,6 +101,9 @@ struct PlaylistsListView: View { ToolbarSpacer(.flexible, placement: .primaryAction) } #endif + ToolbarItem(placement: viewOptionsPlacement) { + DataContextSwitcherMenu() + } ToolbarItem(placement: viewOptionsPlacement) { Button { showViewOptions = true @@ -169,6 +172,8 @@ struct PlaylistsListView: View { } label: { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } + + DataContextSwitcherButton() } .focusSection() .padding(.horizontal, 48) diff --git a/Yattee/Views/Navigation/UnifiedTabView.swift b/Yattee/Views/Navigation/UnifiedTabView.swift index 0e5c6f4e..4406e6ad 100644 --- a/Yattee/Views/Navigation/UnifiedTabView.swift +++ b/Yattee/Views/Navigation/UnifiedTabView.swift @@ -423,6 +423,23 @@ struct UnifiedTabView: View { // MARK: - Sidebar private var sidebar: some View { + #if os(macOS) + VStack(spacing: 0) { + sidebarList + + Divider() + // The window has no toolbar of its own to hang this off, and the + // sidebar is the one chrome every tab shares. + DataContextSwitcherSettingsRow() + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + #else + sidebarList + #endif + } + + private var sidebarList: some View { List(selection: $selection) { ForEach(visibleMainItems) { item in sidebarRow(for: item) diff --git a/Yattee/Views/Settings/SettingsView.swift b/Yattee/Views/Settings/SettingsView.swift index 04f690c6..698d761d 100644 --- a/Yattee/Views/Settings/SettingsView.swift +++ b/Yattee/Views/Settings/SettingsView.swift @@ -128,6 +128,8 @@ struct SettingsView: View { } .accessibilityIdentifier("settings.row.sources") + DataContextSwitcherSettingsRow() + NavigationLink { TVSidebarDetailContainer(systemImage: "icloud", title: String(localized: "settings.icloud.title")) { iCloudSettingsView() } } label: { @@ -229,6 +231,8 @@ struct SettingsView: View { } } .accessibilityIdentifier("settings.row.sources") + + DataContextSwitcherSettingsRow() } Section { diff --git a/Yattee/Views/Settings/SubscriptionsSettingsView.swift b/Yattee/Views/Settings/SubscriptionsSettingsView.swift index ba7d3268..22e9ee21 100644 --- a/Yattee/Views/Settings/SubscriptionsSettingsView.swift +++ b/Yattee/Views/Settings/SubscriptionsSettingsView.swift @@ -20,10 +20,6 @@ private struct ExportFile: Identifiable { struct SubscriptionsSettingsView: View { @Environment(\.appEnvironment) private var appEnvironment - // Account selection state - @State private var pendingAccountChange: SubscriptionAccount? - @State private var showingAccountSwitchConfirmation = false - // Import state @State private var showingImportPicker = false @State private var isImporting = false @@ -47,14 +43,6 @@ struct SubscriptionsSettingsView: View { appEnvironment?.dataManager } - private var settingsManager: SettingsManager? { - appEnvironment?.settingsManager - } - - private var validator: SubscriptionAccountValidator? { - appEnvironment?.subscriptionAccountValidator - } - private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService } @@ -69,20 +57,18 @@ struct SubscriptionsSettingsView: View { dataManager?.subscriptionCount ?? 0 } - private var currentAccount: SubscriptionAccount { - settingsManager?.subscriptionAccount ?? .local + private var currentContext: DataContext { + appEnvironment?.accountsManager.currentContext ?? .local } var body: some View { List { accountSection - if validator?.hasAvailableAccounts == true { - importSection - exportSection - localDataSection - } + importSection + exportSection + localDataSection } - .task(id: currentAccount) { + .task(id: currentContext) { await refreshAccountSubscriptionCount() } .navigationTitle(String(localized: "settings.subscriptions.title")) @@ -117,20 +103,6 @@ struct SubscriptionsSettingsView: View { Text(error) } } - .confirmationDialog( - String(localized: "settings.subscriptions.account.switch.title"), - isPresented: $showingAccountSwitchConfirmation, - titleVisibility: .visible - ) { - Button(String(localized: "settings.subscriptions.account.switch.action")) { - confirmAccountSwitch() - } - Button(String(localized: "common.cancel"), role: .cancel) { - cancelAccountSwitch() - } - } message: { - Text(String(localized: "settings.subscriptions.account.switch.message")) - } .confirmationDialog( String(localized: "settings.subscriptions.localData.delete.confirmation.title \(localSubscriptionCount)"), isPresented: $showingDeleteLocalConfirmation, @@ -150,81 +122,12 @@ struct SubscriptionsSettingsView: View { @ViewBuilder private var accountSection: some View { Section { - if let validator, validator.hasAvailableAccounts { - Picker( - String(localized: "settings.subscriptions.account.label"), - selection: Binding( - get: { currentAccount }, - set: { newAccount in - if newAccount != currentAccount { - pendingAccountChange = newAccount - showingAccountSwitchConfirmation = true - } - } - ) - ) { - ForEach(validator.availableAccounts, id: \.self) { account in - Text(validator.displayName(for: account)) - .tag(account) - } - } - } else { - // No accounts available - show setup prompt - VStack(alignment: .leading, spacing: 8) { - Label( - String(localized: "settings.subscriptions.account.noAccounts.title"), - systemImage: "exclamationmark.triangle" - ) - .foregroundStyle(.secondary) - - Text(String(localized: "settings.subscriptions.account.noAccounts.message")) - .font(.caption) - .foregroundStyle(.secondary) - - Button(String(localized: "settings.subscriptions.account.noAccounts.action")) { - appEnvironment?.navigationCoordinator.navigate(to: .settings) - } - .font(.caption) - .padding(.top, 4) - } - .padding(.vertical, 4) - } + DataContextSwitcherSettingsRow() } footer: { - if validator?.hasAvailableAccounts == true { - Text(String(localized: "settings.subscriptions.account.footer")) - } + Text(String(localized: "settings.subscriptions.account.footer")) } } - // MARK: - Account Switch Actions - - private func confirmAccountSwitch() { - guard let newAccount = pendingAccountChange else { return } - - // Both stores are written: the context is what the data layer reads, - // the legacy setting is kept in step until the Phase 3 switcher removes - // the last readers of it. Letting them diverge would show one account - // in the UI while serving another's data. - settingsManager?.subscriptionAccount = newAccount - appEnvironment?.accountsManager.setContext( - appEnvironment?.accountsManager.context(forLegacyInstanceID: newAccount.instanceID) ?? .local - ) - SubscriptionFeedCache.shared.handleAccountChange() - - // Trigger feed refresh in background - Task { - guard let appEnvironment else { return } - await SubscriptionFeedCache.shared.refresh(using: appEnvironment) - } - - pendingAccountChange = nil - LoggingService.shared.logSubscriptions("Switched subscription account to: \(String(describing: newAccount.type))") - } - - private func cancelAccountSwitch() { - pendingAccountChange = nil - } - // MARK: - Import Section @ViewBuilder @@ -285,7 +188,7 @@ struct SubscriptionsSettingsView: View { /// is active. Hidden in local mode, where the account IS the local store. @ViewBuilder private var localDataSection: some View { - if currentAccount.type != .local && localSubscriptionCount > 0 { + if !currentContext.isLocal && localSubscriptionCount > 0 { Section { Button(role: .destructive) { showingDeleteLocalConfirmation = true @@ -383,7 +286,7 @@ struct SubscriptionsSettingsView: View { /// mode, or the server's list for Invidious/Piped accounts. private func performExport() async { let subscriptions: [Subscription] - if currentAccount.type == .local { + if currentContext.isLocal { guard let dataManager else { return } subscriptions = dataManager.allSubscriptions } else { diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index d2eb14c0..f6c212b3 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -324,6 +324,15 @@ struct SubscriptionsView: View { return progress > 0 && progress < 1 ? progress : nil } + /// Why the selected account is not the one answering, when it is not. + /// + /// Only for an account context: local data is never unavailable, and the + /// fallback the banner explains is local data itself. + private var contextUnavailability: DataContextUnavailability? { + guard appEnvironment?.accountsManager.currentContext.isLocal == false else { return nil } + return appEnvironment?.dataContextValidator.unavailability + } + /// View options button lives on the leading edge on macOS, trailing elsewhere. private var viewOptionsPlacement: ToolbarItemPlacement { #if os(macOS) @@ -430,6 +439,13 @@ struct SubscriptionsView: View { } #endif } + // Above the feed rather than inside it: the feed is local data + // while this is showing, and nothing else on screen says so. + .safeAreaInset(edge: .top, spacing: 0) { + if let unavailability = contextUnavailability { + DataContextUnavailableBanner(reason: unavailability) + } + } #if !os(tvOS) .navigationTitle(String(localized: "tabs.subscriptions")) #if os(iOS) @@ -438,6 +454,9 @@ struct SubscriptionsView: View { .toolbarTitleDisplayMode(.inlineLarge) #endif .toolbar { + ToolbarItem(placement: viewOptionsPlacement) { + DataContextSwitcherMenu() + } ToolbarItem(placement: viewOptionsPlacement) { Button { showViewOptions = true @@ -812,12 +831,16 @@ struct SubscriptionsView: View { #if os(tvOS) private var tvOSViewOptionsButton: some View { - Button { - showViewOptions = true - } label: { - Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") + HStack(spacing: 16) { + Button { + showViewOptions = true + } label: { + Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") + } + .buttonStyle(.bordered) + + DataContextSwitcherButton() } - .buttonStyle(.bordered) } private var tvOSChannelsSidebar: some View { diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index f438b9ee..f6a97416 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -297,8 +297,10 @@ struct YatteeApp: App { appEnvironment.refreshAccountStatuses() } - // Validate subscription account (auto-correct if invalid) - appEnvironment.subscriptionAccountValidator.validateAndCorrectIfNeeded() + // Repair a context whose account was deleted on another device + // while this one was in the background. Only a deletion moves + // it — an account that merely has not synced yet is left alone. + appEnvironment.dataContextValidator.correctIfContextRemoved() SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment) // Fetch remote CloudKit changes (catches missed push notifications) @@ -353,8 +355,10 @@ struct YatteeApp: App { appEnvironment.refreshAccountStatuses() } - // Validate subscription account (auto-correct if invalid) - appEnvironment.subscriptionAccountValidator.validateAndCorrectIfNeeded() + // Repair a context whose account was deleted on another device + // while this one was in the background. Only a deletion moves + // it — an account that merely has not synced yet is left alone. + appEnvironment.dataContextValidator.correctIfContextRemoved() SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment) // Check clipboard for external video URLs