From 94b2dff75b5c7d69ac3f72766cbd9026b8cbf4a0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 11 Sep 2026 19:08:25 +0200 Subject: [PATCH] Let Subscriptions settle on an answer and heal when the server returns MAC3: an error the context banner answers now reaches a terminal Feed Unavailable view instead of the spinner branch; an effective-context move keeps the cached feed instead of purging it; the view retries on appear, on activation, on a status lift, and every minute while a network error is on screen. --- Yattee/Services/SubscriptionFeedCache.swift | 18 +++ .../Subscriptions/SubscriptionsView.swift | 105 +++++++++++++++++- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/Yattee/Services/SubscriptionFeedCache.swift b/Yattee/Services/SubscriptionFeedCache.swift index 7ba5e3d1..1127e161 100644 --- a/Yattee/Services/SubscriptionFeedCache.swift +++ b/Yattee/Services/SubscriptionFeedCache.swift @@ -310,6 +310,24 @@ final class SubscriptionFeedCache { isLoading = false } + /// Fences whatever is in flight and lets a replacement start, keeping the + /// feed that is already here. + /// + /// For a move between the selected account and local data that the user did + /// not make — an unreachable server, an instance switched off. The + /// selection has not changed, so what is cached is still that account's own + /// feed and it is the only thing standing between the banner and an empty + /// screen. Purging it is what left the feed with nothing to fall back to + /// and no way to reach a terminal state: the request it emptied the cache + /// for is the one that cannot answer. + func handleEffectiveContextChange() { + LoggingService.shared.info("Subscription reads moved, keeping the cached feed", category: .general) + accountGeneration += 1 + cancelPolling() + feedLoadState = .idle + isLoading = false + } + /// Which login the cache currently holds data for. private var accountGeneration = 0 diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index 351856e3..7aba3bbb 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -12,6 +12,7 @@ import AppKit struct SubscriptionsView: View { @Environment(\.appEnvironment) private var appEnvironment + @Environment(\.scenePhase) private var scenePhase @Namespace private var sheetTransition #if os(tvOS) @FocusState private var focusedVideoID: String? @@ -25,6 +26,9 @@ struct SubscriptionsView: View { /// The effective context the visible subscriptions were loaded for, so a /// status change that does not move it does not force a reload. @State private var loadedContext: DataContext? + /// How many times the feed has been tried again since it last failed on the + /// network, so each attempt restarts the wait for the next one. + @State private var feedRetryAttempt = 0 @State private var selectedChannelID: String? = nil @State private var errorMessage: String? @State private var watchEntriesMap: [String: WatchEntry] = [:] @@ -175,6 +179,27 @@ struct SubscriptionsView: View { displayedFeedError != nil && feedCache.videos.isEmpty } + /// Whether the feed stopped on a failure the banner is already the answer + /// to, and so has nothing of its own to show. + /// + /// Without this the view falls back through to the spinner it started on + /// and stays there for as long as the account is unusable: the request that + /// would replace it is the one that cannot be made. + private var feedSettledOnAnsweredError: Bool { + guard case .error = feedCache.feedLoadState else { return false } + return displayedFeedError == nil + } + + /// How long a feed that failed on the network waits before trying again. + private static let feedRetryInterval: TimeInterval = 60 + + /// The failure the feed is currently sitting on, if it is one that trying + /// again could answer differently. + private var feedRetryToken: Int? { + guard case .error(.networkError(_)) = feedCache.feedLoadState else { return nil } + return feedRetryAttempt + } + private var dataManager: DataManager? { appEnvironment?.dataManager } private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService } private var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } @@ -515,6 +540,13 @@ struct SubscriptionsView: View { // Synchronous load for local accounts so the sidebar is // present in the first layout pass (avoids layout jump). loadSubscriptions() + // Coming back to a feed that failed while it was away is + // the most likely moment for it to work again. + retryFeedIfNeeded() + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + retryFeedIfNeeded() } .task { await loadSubscriptionsAsync() @@ -544,9 +576,12 @@ struct SubscriptionsView: View { } // Instances count for the same reason: switching one off // moves reads to local without touching the stored context - // or any account status. + // or any account status. And a status that lifts without + // moving reads at all still deserves the attempt the + // failure before it never got. .onReceive(NotificationCenter.default.publisher(for: .accountStatusesDidChange)) { _ in - reloadIfEffectiveContextMoved() + guard !reloadIfEffectiveContextMoved() else { return } + retryFeedIfNeeded() } .onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in reloadIfEffectiveContextMoved() @@ -583,6 +618,17 @@ struct SubscriptionsView: View { LoggingService.shared.debug("Using cached feed, no refresh needed", category: .general) } } + // A server that went away comes back without announcing it, and + // a feed that failed on the way to it is not watching anything + // that would notice — so it asks again on its own, for as long + // as the failure is on screen. + .task(id: feedRetryToken) { + guard feedRetryToken != nil else { return } + try? await Task.sleep(for: .seconds(Self.feedRetryInterval)) + guard !Task.isCancelled else { return } + await loadFeed(forceRefresh: true) + feedRetryAttempt += 1 + } .onChange(of: selectedChannelID) { _, _ in withAnimation { proxy.scrollTo("top", anchor: .top) @@ -708,6 +754,8 @@ struct SubscriptionsView: View { } } } + } else if feedSettledOnAnsweredError { + feedUnavailableView } else if feedCache.hasLoadedOnce { gridEmptyView } else { @@ -751,6 +799,8 @@ struct SubscriptionsView: View { gridErrorView(error) } else if !feedCache.videos.isEmpty { gridFeedContent + } else if feedSettledOnAnsweredError { + feedUnavailableView } else if feedCache.hasLoadedOnce { gridEmptyView } else { @@ -1333,6 +1383,21 @@ struct SubscriptionsView: View { .padding(.vertical, 40) } + /// Terminal state for a feed that stopped on a failure the banner explains. + private var feedUnavailableView: some View { + ContentUnavailableView { + Label(String(localized: "subscriptions.feedUnavailable.title"), systemImage: "wifi.exclamationmark") + } description: { + Text(String(localized: "subscriptions.feedUnavailable.description")) + } actions: { + Button(String(localized: "common.retry")) { + Task { await loadFeed(forceRefresh: true) } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 40) + } + /// Empty state shown when Invidious account is not authenticated. private var notAuthenticatedView: some View { ContentUnavailableView { @@ -1377,17 +1442,34 @@ struct SubscriptionsView: View { /// Loads subscriptions asynchronously from the current provider. /// For Invidious, this fetches from the API and creates temporary Subscription objects for UI. - private func reloadIfEffectiveContextMoved() { - guard appEnvironment?.dataContextValidator.effectiveContext != loadedContext else { return } - reloadForContextChange() + @discardableResult + private func reloadIfEffectiveContextMoved() -> Bool { + guard appEnvironment?.dataContextValidator.effectiveContext != loadedContext else { return false } + reloadForEffectiveContextChange() + return true } /// Drops what is on screen and reloads it for whatever context is current. private func reloadForContextChange() { - loadedContext = appEnvironment?.dataContextValidator.effectiveContext feedCache.handleAccountChange() subscriptions = [] subscriptionsLoaded = false + reload() + } + + /// Reloads for a move the user did not make — a status lifting or falling, + /// an instance switched off — where the selection itself is unchanged. + /// + /// Keeps what is on screen: the cached feed and the channel list still + /// belong to the account that was selected before and still is, and they + /// are the fallback the banner is telling the user they are looking at. + private func reloadForEffectiveContextChange() { + feedCache.handleEffectiveContextChange() + reload() + } + + private func reload() { + loadedContext = appEnvironment?.dataContextValidator.effectiveContext Task { guard let appEnvironment else { return } @@ -1396,6 +1478,17 @@ struct SubscriptionsView: View { } } + /// Tries the feed again when something happened that may have changed the + /// answer, and it is sitting on a failure. + /// + /// The account statuses lift on their own, but a feed that already failed + /// has nothing watching for that — it waited for a Retry nobody should have + /// to press to find out the server came back. + private func retryFeedIfNeeded() { + guard case .error = feedCache.feedLoadState, !feedCache.isLoading else { return } + Task { await loadFeed(forceRefresh: true) } + } + private func loadSubscriptionsAsync() async { guard let subscriptionService, let appEnvironment else { return }