Let the sidebar's channels and playlists follow the data context

This commit is contained in:
Arkadiusz Fal
2026-09-14 07:11:28 +02:00
parent f2c4721e5a
commit 82ef5267c4
8 changed files with 264 additions and 29 deletions

View File

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

View File

@@ -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<AnyCancellable>()
/// 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<UUID> = []
private var attemptedPlaylistFetches: Set<UUID> = []
// 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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<NavigationPath> {
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))
}

View File

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