Put the account switcher where the data is, and retire the old setting

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.
This commit is contained in:
Arkadiusz Fal
2026-09-03 23:59:32 +02:00
parent afc016c5ce
commit 175b98097a
14 changed files with 525 additions and 576 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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" : {

View File

@@ -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

View File

@@ -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<Void, Never>?
// 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()
}
}
}
}

View File

@@ -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

View File

@@ -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")
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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