From 82ef5267c4ebae8f7cf2b9a53fc926346b5bd5c1 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 14 Sep 2026 07:11:28 +0200 Subject: [PATCH] Let the sidebar's channels and playlists follow the data context --- Yattee/Models/Navigation/SidebarItem.swift | 34 +++- .../Services/Navigation/SidebarManager.swift | 192 ++++++++++++++++-- .../InvidiousSubscriptionProvider.swift | 4 + .../LocalSubscriptionProvider.swift | 12 +- .../PipedSubscriptionProvider.swift | 4 + .../Subscriptions/SubscriptionProvider.swift | 4 + Yattee/Views/Navigation/UnifiedTabView.swift | 39 +++- .../SubscriptionDestinationTests.swift | 4 + 8 files changed, 264 insertions(+), 29 deletions(-) diff --git a/Yattee/Models/Navigation/SidebarItem.swift b/Yattee/Models/Navigation/SidebarItem.swift index fe155a6f..11c21df1 100644 --- a/Yattee/Models/Navigation/SidebarItem.swift +++ b/Yattee/Models/Navigation/SidebarItem.swift @@ -24,6 +24,11 @@ enum SidebarItem: Hashable, Identifiable { // MARK: - Dynamic Playlist Items case playlist(id: UUID, title: String) + /// A server-side playlist of the account the data context is on. + /// `accountID` names the account it was listed for, which is what makes + /// the detail view treat it as owned (editable). + case remotePlaylist(id: PlaylistID, title: String, accountID: UUID) + // MARK: - Dynamic Media Source Items case mediaSource(id: UUID, name: String, type: MediaSourceType) @@ -61,6 +66,8 @@ enum SidebarItem: Hashable, Identifiable { return "channel-\(source.provider)-\(channelID)" case .playlist(let id, _): return "playlist-\(id.uuidString)" + case .remotePlaylist(let id, _, let accountID): + return "remote-playlist-\(accountID.uuidString)-\(id.id)" case .mediaSource(let id, _, _): return "mediasource-\(id.uuidString)" case .instance(let id, _, _): @@ -104,6 +111,8 @@ enum SidebarItem: Hashable, Identifiable { return name case .playlist(_, let title): return title + case .remotePlaylist(_, let title, _): + return title case .mediaSource(_, let name, _): return name case .instance(_, let name, _): @@ -143,7 +152,7 @@ enum SidebarItem: Hashable, Identifiable { return "antenna.radiowaves.left.and.right" case .channel: return "person.circle" - case .playlist: + case .playlist, .remotePlaylist: return "list.bullet.rectangle" case .mediaSource(_, _, let type): return type.systemImage @@ -179,6 +188,9 @@ enum SidebarItem: Hashable, Identifiable { return .channel(channelID, source) case .playlist(let id, let title): return .playlist(.local(id, title: title)) + case .remotePlaylist(let id, let title, let accountID): + // No instance here: the detail view resolves it from the account. + return .playlist(.remote(id, instance: nil, title: title, accountID: accountID)) case .mediaSource(let id, _, _): return .mediaSource(id) case .instance: @@ -221,8 +233,10 @@ enum SidebarItem: Hashable, Identifiable { /// Whether this is a dynamic playlist item. var isPlaylist: Bool { - if case .playlist = self { return true } - return false + switch self { + case .playlist, .remotePlaylist: return true + default: return false + } } /// Whether this is a dynamic media source item. @@ -255,6 +269,20 @@ extension SidebarItem { .playlist(id: playlist.id, title: playlist.title) } + /// Creates a SidebarItem from a server account's Channel. + static func from(channel: Channel) -> SidebarItem { + .channel( + channelID: channel.id.channelID, + name: channel.name, + source: channel.id.source + ) + } + + /// Creates a SidebarItem from a server account's Playlist. + static func from(remotePlaylist playlist: Playlist, accountID: UUID) -> SidebarItem { + .remotePlaylist(id: playlist.id, title: playlist.title, accountID: accountID) + } + /// Creates a SidebarItem from a MediaSource. static func from(mediaSource: MediaSource) -> SidebarItem { .mediaSource(id: mediaSource.id, name: mediaSource.name, type: mediaSource.type) diff --git a/Yattee/Services/Navigation/SidebarManager.swift b/Yattee/Services/Navigation/SidebarManager.swift index b9e1ed80..04b3ea06 100644 --- a/Yattee/Services/Navigation/SidebarManager.swift +++ b/Yattee/Services/Navigation/SidebarManager.swift @@ -14,15 +14,37 @@ import Combine final class SidebarManager { // MARK: - Published Items - /// Channel items for the Channels section. - private(set) var channelItems: [SidebarItem] = [] + /// Channel items for the Channels section, following the data context the + /// same way the Subscriptions screen does: local subscriptions under the + /// local context, the account's server subscriptions under an account one. + var channelItems: [SidebarItem] { + if case .account = effectiveContext { return remoteChannelItems } + return localChannelItems + } - /// Playlist items for the Collections section. - /// - /// Local playlists only, whatever data context is selected — the playlists - /// screen follows the context but the sidebar does not. A known - /// inconsistency, kept for now rather than half-solved. - private(set) var playlistItems: [SidebarItem] = [] + /// Playlist items for the Playlists section, following the data context the + /// same way the Playlists screen does. Remote lists are read live from the + /// cache so a create/rename/delete made anywhere shows up here too. + var playlistItems: [SidebarItem] { + if case let .account(accountID) = effectiveContext { + guard var playlists = remotePlaylistsCache?.playlists(for: accountID) else { return [] } + if settingsManager?.sidebarPlaylistsLimitEnabled ?? false { + let maxPlaylists = settingsManager?.sidebarMaxPlaylists ?? SettingsManager.defaultSidebarMaxPlaylists + playlists = Array(playlists.prefix(maxPlaylists)) + } + return playlists.map { SidebarItem.from(remotePlaylist: $0, accountID: accountID) } + } + return localPlaylistItems + } + + /// The local halves behind the computed sections above. + private var localChannelItems: [SidebarItem] = [] + private var localPlaylistItems: [SidebarItem] = [] + + /// The current account's server subscriptions, rebuilt from the provider + /// cache — providers are not observable, so this is notification-driven. + private var remoteChannelItems: [SidebarItem] = [] + private var remoteChannelAvatars: [String: URL] = [:] /// Media source items for the Media Sources section. private(set) var mediaSourceItems: [SidebarItem] = [] @@ -45,7 +67,22 @@ final class SidebarManager { private weak var settingsManager: SettingsManager? private weak var mediaSourcesManager: MediaSourcesManager? private weak var instancesManager: InstancesManager? + private weak var accountsManager: AccountsManager? + private weak var subscriptionService: SubscriptionService? + private weak var remotePlaylistsCache: RemotePlaylistsCache? + private weak var contextValidator: DataContextValidator? private var cancellables = Set() + + /// The context the sections follow — the same fallback the data screens + /// apply, so an unavailable account leaves the sidebar on local data too. + private var effectiveContext: DataContext { + contextValidator?.effectiveContext ?? accountsManager?.currentContext ?? .local + } + + /// One fetch per account per visit: a cold cache triggers a load, a failed + /// one stays empty until the context is switched again. + private var attemptedChannelFetches: Set = [] + private var attemptedPlaylistFetches: Set = [] // MARK: - Cached Data (to avoid repeated DB queries during layout) @@ -63,12 +100,20 @@ final class SidebarManager { dataManager: DataManager, settingsManager: SettingsManager, mediaSourcesManager: MediaSourcesManager? = nil, - instancesManager: InstancesManager? = nil + instancesManager: InstancesManager? = nil, + accountsManager: AccountsManager? = nil, + subscriptionService: SubscriptionService? = nil, + remotePlaylistsCache: RemotePlaylistsCache? = nil, + contextValidator: DataContextValidator? = nil ) { self.dataManager = dataManager self.settingsManager = settingsManager self.mediaSourcesManager = mediaSourcesManager self.instancesManager = instancesManager + self.accountsManager = accountsManager + self.subscriptionService = subscriptionService + self.remotePlaylistsCache = remotePlaylistsCache + self.contextValidator = contextValidator loadData() } @@ -81,8 +126,14 @@ final class SidebarManager { loadSources() } - /// Loads channel items from subscriptions. + /// Loads channel items from subscriptions — the local store always, plus + /// the account provider's cache when an account context is selected. private func loadChannels() { + loadLocalChannels() + refreshRemoteChannels() + } + + private func loadLocalChannels() { guard let dataManager else { return } // Cache subscriptions for use in avatarURL(for:) @@ -111,15 +162,84 @@ final class SidebarManager { // Apply limit (if enabled) and convert to sidebar items if limitEnabled { - channelItems = sortedSubscriptions + localChannelItems = sortedSubscriptions .prefix(maxChannels) .map { SidebarItem.from(subscription: $0) } } else { - channelItems = sortedSubscriptions + localChannelItems = sortedSubscriptions .map { SidebarItem.from(subscription: $0) } } } + /// Rebuilds the remote channel items from the current account provider's + /// cache, fetching once when the cache is cold. + private func refreshRemoteChannels() { + guard case let .account(accountID) = effectiveContext, + let subscriptionService, + let provider = subscriptionService.provider(for: .account(accountID)) + else { + remoteChannelItems = [] + remoteChannelAvatars = [:] + return + } + + guard let channels = provider.cachedSubscriptions else { + remoteChannelItems = [] + remoteChannelAvatars = [:] + fetchRemoteChannelsOnce(accountID: accountID) + return + } + + // Alphabetical regardless of the sidebar sort setting: server + // subscriptions carry neither a subscribed-at nor a last-upload date. + var sorted = channels.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + + if settingsManager?.sidebarChannelsLimitEnabled ?? true { + let maxChannels = settingsManager?.sidebarMaxChannels ?? SettingsManager.defaultSidebarMaxChannels + sorted = Array(sorted.prefix(maxChannels)) + } + + remoteChannelItems = sorted.map { SidebarItem.from(channel: $0) } + remoteChannelAvatars = channels.reduce(into: [:]) { map, channel in + map[channel.id.channelID] = channel.thumbnailURL + } + } + + private func fetchRemoteChannelsOnce(accountID: UUID) { + guard let subscriptionService, + !attemptedChannelFetches.contains(accountID) else { return } + attemptedChannelFetches.insert(accountID) + + Task { [weak self] in + // The service guards the fetch with its own generation counter, so + // a result landing after a context switch is discarded there. + _ = try? await subscriptionService.fetchSubscriptions() + guard let self, case .account(accountID) = self.effectiveContext else { return } + self.refreshRemoteChannels() + } + } + + /// Warms the remote playlists cache for the current account once; the + /// computed `playlistItems` reads the cache live, so nothing to apply here. + private func fetchRemotePlaylistsIfNeeded() { + guard case let .account(accountID) = effectiveContext, + let remotePlaylistsCache, + remotePlaylistsCache.playlists(for: accountID) == nil, + let accountsManager, + let account = accountsManager.account(id: accountID), + let instance = accountsManager.instance(for: account), + instance.type.accountCapabilities.supportsPlaylistReads, + !attemptedPlaylistFetches.contains(accountID) + else { return } + attemptedPlaylistFetches.insert(accountID) + + Task { + try? await remotePlaylistsCache.refreshList(for: account) + } + } + /// Loads playlist items from local playlists. private func loadPlaylists() { guard let dataManager else { return } @@ -141,13 +261,15 @@ final class SidebarManager { // Apply limit if enabled if limitEnabled { - playlistItems = sortedPlaylists + localPlaylistItems = sortedPlaylists .prefix(maxPlaylists) .map { SidebarItem.from(playlist: $0) } } else { - playlistItems = sortedPlaylists + localPlaylistItems = sortedPlaylists .map { SidebarItem.from(playlist: $0) } } + + fetchRemotePlaylistsIfNeeded() } /// Loads instance and media source items with sorting and limiting. @@ -258,6 +380,18 @@ final class SidebarManager { self?.loadData() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .dataContextDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + // A fresh visit deserves a fresh attempt, even at a cache that + // failed to fill last time. + self?.attemptedChannelFetches.removeAll() + self?.attemptedPlaylistFetches.removeAll() + self?.refreshRemoteChannels() + self?.fetchRemotePlaylistsIfNeeded() + } + .store(in: &cancellables) } // MARK: - Channel Data Access @@ -273,7 +407,12 @@ final class SidebarManager { func avatarURL(for item: SidebarItem) -> URL? { guard case .channel(let channelID, _, _) = item else { return nil } - let directURL = cachedSubscriptions.first { $0.channelID == channelID }?.avatarURL + let directURL: URL? + if case .account = effectiveContext { + directURL = remoteChannelAvatars[channelID] + } else { + directURL = cachedSubscriptions.first { $0.channelID == channelID }?.avatarURL + } return AvatarURLBuilder.avatarURL( channelID: channelID, @@ -288,16 +427,29 @@ final class SidebarManager { /// Returns video count for a playlist sidebar item. /// Uses cached playlists to avoid repeated DB queries during layout. func videoCount(for item: SidebarItem) -> Int { - guard case .playlist(let id, _) = item else { return 0 } - - return cachedPlaylists.first { $0.id == id }?.videoCount ?? 0 + switch item { + case .playlist(let id, _): + return cachedPlaylists.first { $0.id == id }?.videoCount ?? 0 + case .remotePlaylist(let id, _, let accountID): + return remotePlaylistsCache?.playlists(for: accountID)? + .first { $0.id == id }?.videoCount ?? 0 + default: + return 0 + } } /// Returns thumbnail URL for a playlist sidebar item. /// Uses cached playlists to avoid repeated DB queries during layout. func thumbnailURL(for item: SidebarItem) -> URL? { - guard case .playlist(let id, _) = item else { return nil } - return cachedPlaylists.first { $0.id == id }?.thumbnailURL + switch item { + case .playlist(let id, _): + return cachedPlaylists.first { $0.id == id }?.thumbnailURL + case .remotePlaylist(let id, _, let accountID): + return remotePlaylistsCache?.playlists(for: accountID)? + .first { $0.id == id }?.thumbnailURL + default: + return nil + } } } diff --git a/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift b/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift index 812ed57c..30853219 100644 --- a/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift @@ -139,6 +139,10 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { cachePopulated ? cachedChannels.count : nil } + var cachedSubscriptions: [Channel]? { + cachePopulated ? cachedChannels : nil + } + // MARK: - Private Helpers /// Gets the authenticated Invidious instance and session ID from account settings. diff --git a/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift b/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift index e0fd989e..1d27b41c 100644 --- a/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift @@ -26,7 +26,12 @@ final class LocalSubscriptionProvider: SubscriptionProvider { // MARK: - SubscriptionProvider func fetchSubscriptions() async throws -> [Channel] { - // Convert Subscription models to Channel models + subscriptionChannels() + } + + /// Converts Subscription models to Channel models, synchronously — the + /// store is local, so there is nothing to await. + private func subscriptionChannels() -> [Channel] { dataManager.subscriptions().map { subscription in Channel( id: ChannelID(source: subscription.contentSource, channelID: subscription.channelID), @@ -67,4 +72,9 @@ final class LocalSubscriptionProvider: SubscriptionProvider { var cachedSubscriptionCount: Int? { dataManager.subscriptionCount } + + var cachedSubscriptions: [Channel]? { + // Local data needs no cache warm-up — the store is always readable. + subscriptionChannels() + } } diff --git a/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift b/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift index 01128e4d..311468fa 100644 --- a/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift @@ -139,6 +139,10 @@ final class PipedSubscriptionProvider: SubscriptionProvider { cachePopulated ? cachedChannels.count : nil } + var cachedSubscriptions: [Channel]? { + cachePopulated ? cachedChannels : nil + } + // MARK: - Private Helpers /// Gets the authenticated Piped instance and auth token from account settings. diff --git a/Yattee/Services/Subscriptions/SubscriptionProvider.swift b/Yattee/Services/Subscriptions/SubscriptionProvider.swift index 95c4a7c4..7ab9d368 100644 --- a/Yattee/Services/Subscriptions/SubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/SubscriptionProvider.swift @@ -40,6 +40,10 @@ protocol SubscriptionProvider: Sendable { /// Returns nil when the provider hasn't populated its cache yet. var cachedSubscriptionCount: Int? { get } + /// The subscribed channels known without a network fetch. + /// Returns nil when the provider hasn't populated its cache yet. + var cachedSubscriptions: [Channel]? { get } + /// Refreshes the local cache of subscriptions from the remote source. /// For local provider, this is a no-op. func refreshCache() async throws diff --git a/Yattee/Views/Navigation/UnifiedTabView.swift b/Yattee/Views/Navigation/UnifiedTabView.swift index 1deb00e5..cef507e9 100644 --- a/Yattee/Views/Navigation/UnifiedTabView.swift +++ b/Yattee/Views/Navigation/UnifiedTabView.swift @@ -26,6 +26,7 @@ struct UnifiedTabView: View { @State private var searchPath = NavigationPath() @State private var channelPaths: [String: NavigationPath] = [:] @State private var playlistPaths: [UUID: NavigationPath] = [:] + @State private var remotePlaylistPaths: [String: NavigationPath] = [:] @State private var mediaSourcePaths: [UUID: NavigationPath] = [:] @State private var instancePaths: [UUID: NavigationPath] = [:] @State private var bookmarksPath = NavigationPath() @@ -355,6 +356,7 @@ struct UnifiedTabView: View { @State private var searchPath = NavigationPath() @State private var channelPaths: [String: NavigationPath] = [:] @State private var playlistPaths: [UUID: NavigationPath] = [:] + @State private var remotePlaylistPaths: [String: NavigationPath] = [:] @State private var mediaSourcePaths: [UUID: NavigationPath] = [:] @State private var instancePaths: [UUID: NavigationPath] = [:] @State private var bookmarksPath = NavigationPath() @@ -549,7 +551,7 @@ struct UnifiedTabView: View { } case .channel: channelContent(for: selection) - case .playlist: + case .playlist, .remotePlaylist: playlistContent(for: selection) case .mediaSource, .instance: sourceContent(for: selection) @@ -583,6 +585,7 @@ struct UnifiedTabView: View { @State private var searchPath = NavigationPath() @State private var channelPaths: [String: NavigationPath] = [:] @State private var playlistPaths: [UUID: NavigationPath] = [:] + @State private var remotePlaylistPaths: [String: NavigationPath] = [:] @State private var instancePaths: [UUID: NavigationPath] = [:] @State private var mediaSourcePaths: [UUID: NavigationPath] = [:] @State private var bookmarksPath = NavigationPath() @@ -675,6 +678,8 @@ struct UnifiedTabView: View { channelPaths[channelID] = NavigationPath() case let .playlist(id, _): playlistPaths[id] = NavigationPath() + case .remotePlaylist: + remotePlaylistPaths[item.id] = NavigationPath() case let .instance(id, _, _): instancePaths[id] = NavigationPath() case let .mediaSource(id, _, _): @@ -934,7 +939,11 @@ extension UnifiedTabView { dataManager: appEnvironment.dataManager, settingsManager: appEnvironment.settingsManager, mediaSourcesManager: appEnvironment.mediaSourcesManager, - instancesManager: appEnvironment.instancesManager + instancesManager: appEnvironment.instancesManager, + accountsManager: appEnvironment.accountsManager, + subscriptionService: appEnvironment.subscriptionService, + remotePlaylistsCache: appEnvironment.remotePlaylistsCache, + contextValidator: appEnvironment.dataContextValidator ) } @@ -951,6 +960,8 @@ extension UnifiedTabView { channelPaths[selection.id, default: NavigationPath()].append(destination) case .playlist(let id, _): playlistPaths[id, default: NavigationPath()].append(destination) + case .remotePlaylist: + remotePlaylistPaths[selection.id, default: NavigationPath()].append(destination) case .mediaSource(let id, _, _): mediaSourcePaths[id, default: NavigationPath()].append(destination) case .instance(let id, _, _): @@ -1047,20 +1058,38 @@ extension UnifiedTabView { @ViewBuilder func playlistContent(for item: SidebarItem) -> some View { - if case .playlist(let id, _) = item { + switch item { + case .playlist(let id, _): NavigationStack(path: playlistPathBinding(for: id)) { UnifiedPlaylistDetailView(source: .local(id)) .withNavigationDestinations() } .id(item.id) + case .remotePlaylist(let id, let title, let accountID): + NavigationStack(path: remotePlaylistPathBinding(for: item)) { + // No instance passed: the detail view resolves it from the + // account, same as SidebarItem.navigationDestination(). + UnifiedPlaylistDetailView(source: .remote(id, instance: nil, title: title, accountID: accountID)) + .withNavigationDestinations() + } + .id(item.id) + default: + EmptyView() } } + func remotePlaylistPathBinding(for item: SidebarItem) -> Binding { + Binding( + get: { remotePlaylistPaths[item.id] ?? NavigationPath() }, + set: { remotePlaylistPaths[item.id] = $0 } + ) + } + @ViewBuilder func playlistLabel(for item: SidebarItem) -> some View { - if case .playlist(_, let title) = item { + if item.isPlaylist { Label { - Text(title) + Text(item.title) } icon: { SidebarPlaylistIcon(url: sidebarManager.thumbnailURL(for: item)) } diff --git a/YatteeTests/SubscriptionDestinationTests.swift b/YatteeTests/SubscriptionDestinationTests.swift index 500a5472..9e160488 100644 --- a/YatteeTests/SubscriptionDestinationTests.swift +++ b/YatteeTests/SubscriptionDestinationTests.swift @@ -73,6 +73,10 @@ private final class ScriptedSubscriptionProvider: SubscriptionProvider { } var cachedSubscriptionCount: Int? { subscribed.count } + + var cachedSubscriptions: [Channel]? { + subscribed.map { Channel(id: .global($0), name: $0) } + } } /// Hands out the scripted providers instead of building ones that would need a