mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
507 lines
19 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|