Files
yattee/Yattee/Services/Navigation/SidebarManager.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

507 lines
19 KiB
Swift

//
// SidebarManager.swift
// Yattee
//
// Manages sidebar content by loading user data (subscriptions, playlists)
// and generating sidebar items for the TabSection-based navigation.
//
import Foundation
import Combine
/// Manages sidebar state and content generation.
@Observable @MainActor
final class SidebarManager {
// MARK: - Published Items
/// 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 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] = []
/// Instance items for the Sources section.
private(set) var instanceItems: [SidebarItem] = []
/// All source items (instances + media sources) combined and sorted.
/// This is the primary property to use for displaying sources in a unified list.
private(set) var sortedSourceItems: [SidebarItem] = []
/// Whether there are no source items at all.
var hasNoSources: Bool {
instanceItems.isEmpty && mediaSourceItems.isEmpty
}
// MARK: - Dependencies
private weak var dataManager: DataManager?
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)
private var cachedSubscriptions: [Subscription] = []
private var cachedPlaylists: [LocalPlaylist] = []
// MARK: - Initialization
init() {
setupNotificationObservers()
}
/// Configure the manager with dependencies.
func configure(
dataManager: DataManager,
settingsManager: SettingsManager,
mediaSourcesManager: MediaSourcesManager? = 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()
}
// MARK: - Data Loading
/// Loads subscriptions, playlists, media sources, and instances.
func loadData() {
loadChannels()
loadPlaylists()
loadSources()
}
/// 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:)
cachedSubscriptions = dataManager.subscriptions()
let limitEnabled = settingsManager?.sidebarChannelsLimitEnabled ?? true
let maxChannels = settingsManager?.sidebarMaxChannels ?? SettingsManager.defaultSidebarMaxChannels
let sortOrder = settingsManager?.sidebarChannelSort ?? .lastUploaded
// Sort subscriptions
let sortedSubscriptions: [Subscription]
switch sortOrder {
case .alphabetical:
sortedSubscriptions = cachedSubscriptions.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .recentlySubscribed:
sortedSubscriptions = cachedSubscriptions.sorted { $0.subscribedAt > $1.subscribedAt }
case .lastUploaded:
sortedSubscriptions = cachedSubscriptions.sorted { sub1, sub2 in
let date1 = sub1.lastVideoPublishedAt ?? .distantPast
let date2 = sub2.lastVideoPublishedAt ?? .distantPast
return date1 > date2
}
case .custom:
// For custom, we'd need additional ordering data - for now fallback to alphabetical
sortedSubscriptions = cachedSubscriptions.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
// Apply limit (if enabled) and convert to sidebar items
if limitEnabled {
localChannelItems = sortedSubscriptions
.prefix(maxChannels)
.map { SidebarItem.from(subscription: $0) }
} else {
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 }
// Cache playlists for use in thumbnailURL(for:)
cachedPlaylists = dataManager.playlists()
let sortOrder = settingsManager?.sidebarPlaylistSort ?? .alphabetical
let limitEnabled = settingsManager?.sidebarPlaylistsLimitEnabled ?? false
let maxPlaylists = settingsManager?.sidebarMaxPlaylists ?? SettingsManager.defaultSidebarMaxPlaylists
// Sort playlists
let sortedPlaylists: [LocalPlaylist]
switch sortOrder {
case .alphabetical:
sortedPlaylists = cachedPlaylists.sorted(by: { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending })
case .lastUpdated:
sortedPlaylists = cachedPlaylists.sorted(by: { $0.updatedAt > $1.updatedAt })
}
// Apply limit if enabled
if limitEnabled {
localPlaylistItems = sortedPlaylists
.prefix(maxPlaylists)
.map { SidebarItem.from(playlist: $0) }
} else {
localPlaylistItems = sortedPlaylists
.map { SidebarItem.from(playlist: $0) }
}
fetchRemotePlaylistsIfNeeded()
}
/// Loads instance and media source items with sorting and limiting.
/// Builds a unified sortedSourceItems list for display.
private func loadSources() {
let sortOrder = settingsManager?.sidebarSourceSort ?? .name
let limitEnabled = settingsManager?.sidebarSourcesLimitEnabled ?? false
let maxSources = settingsManager?.sidebarMaxSources ?? SettingsManager.defaultSidebarMaxSources
// Get raw data
let instances = instancesManager?.enabledInstances ?? []
let mediaSources = mediaSourcesManager?.enabledSources ?? []
// Build combined list with sort keys
struct SourceEntry {
let item: SidebarItem
let name: String
let date: Date
let typeOrder: Int // For type sorting: instances (0-99), media sources (100-199)
}
var entries: [SourceEntry] = []
// Add instances
for instance in instances {
let item = SidebarItem.from(instance: instance)
// Type order: group by instance type (invidious=0, piped=1, peertube=2, yatteeServer=3)
let typeOrder: Int
switch instance.type {
case .invidious: typeOrder = 0
case .piped: typeOrder = 1
case .peertube: typeOrder = 2
case .yatteeServer: typeOrder = 3
}
entries.append(SourceEntry(item: item, name: instance.displayName, date: instance.dateAdded, typeOrder: typeOrder))
}
// Add media sources (type order 100 to come after instances when sorting by type)
for source in mediaSources {
let item = SidebarItem.from(mediaSource: source)
// All media sources use same typeOrder (100) to sort alphabetically together
entries.append(SourceEntry(item: item, name: source.name, date: source.dateAdded, typeOrder: 100))
}
// Sort combined list
switch sortOrder {
case .name:
entries.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .type:
// Sort by type order, then by name within each type
entries.sort { a, b in
if a.typeOrder != b.typeOrder {
return a.typeOrder < b.typeOrder
}
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
}
case .lastAdded:
entries.sort { $0.date > $1.date }
}
// Apply limit if enabled
if limitEnabled {
entries = Array(entries.prefix(maxSources))
}
// Update the unified sorted list
sortedSourceItems = entries.map { $0.item }
// Also update legacy separate lists for backwards compatibility
instanceItems = sortedSourceItems.filter { $0.isInstance }
mediaSourceItems = sortedSourceItems.filter { $0.isMediaSource }
}
// MARK: - Notification Observers
private func setupNotificationObservers() {
NotificationCenter.default.publisher(for: .subscriptionsDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadChannels()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .playlistsDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadPlaylists()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .mediaSourcesDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadSources()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .instancesDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadSources()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .sidebarSettingsDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
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
/// Yattee Server URL for avatar fallback
private var yatteeServerURL: URL? {
instancesManager?.enabledYatteeServerInstances.first?.url
}
/// Returns avatar URL for a channel sidebar item.
/// Uses AvatarURLBuilder for Yattee Server fallback when direct URL is unavailable.
/// Uses cached subscriptions to avoid repeated DB queries during layout.
func avatarURL(for item: SidebarItem) -> URL? {
guard case .channel(let channelID, _, _) = item else { return nil }
let directURL: URL?
if case .account = effectiveContext {
directURL = remoteChannelAvatars[channelID]
} else {
directURL = cachedSubscriptions.first { $0.channelID == channelID }?.avatarURL
}
return AvatarURLBuilder.avatarURL(
channelID: channelID,
directURL: directURL,
serverURL: yatteeServerURL,
size: 22 // Matches SidebarChannelIcon size
)
}
// MARK: - Playlist Data Access
/// Returns thumbnail URL for a playlist sidebar item.
/// Uses cached playlists to avoid repeated DB queries during layout.
func thumbnailURL(for item: SidebarItem) -> URL? {
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
}
}
}
// MARK: - Channel Sort Order
/// Defines how channels are sorted in the sidebar.
enum SidebarChannelSort: String, Codable, CaseIterable, Identifiable {
case alphabetical
case recentlySubscribed
case lastUploaded
case custom
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .alphabetical:
return String(localized: "sidebar.sort.alphabetical")
case .recentlySubscribed:
return String(localized: "sidebar.sort.recentlySubscribed")
case .lastUploaded:
return String(localized: "sidebar.sort.lastUploaded")
case .custom:
return String(localized: "sidebar.sort.custom")
}
}
}
// MARK: - Playlist Sort Order
/// Defines how playlists are sorted in the sidebar.
enum SidebarPlaylistSort: String, Codable, CaseIterable, Identifiable {
case alphabetical
case lastUpdated
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .alphabetical:
return String(localized: "sidebar.playlist.sort.alphabetical")
case .lastUpdated:
return String(localized: "sidebar.playlist.sort.lastUpdated")
}
}
}
// MARK: - Source Sort Order
/// Defines how sources (instances + media sources) are sorted in the sidebar.
enum SidebarSourceSort: String, Codable, CaseIterable, Identifiable {
case name
case type // Remote server vs files server
case lastAdded
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .name:
return String(localized: "sidebar.source.sort.name")
case .type:
return String(localized: "sidebar.source.sort.type")
case .lastAdded:
return String(localized: "sidebar.source.sort.lastAdded")
}
}
}