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