Files
yattee/Yattee/Services/Navigation/SidebarManager.swift
Arkadiusz Fal 3d87aeeaf1 Show the selected account's playlists on the playlists screen
The screen read local playlists and nothing else, so selecting an account
moved the subscriptions and the feed but left this list showing the
device's own playlists with no way to tell. It now follows the effective
context: local as before, or the account's list read live out of
RemotePlaylistsCache — never copied into state, because every change to a
server-side playlist goes through that cache and reading it is what makes
a rename or a delete show up.

Rows carry both the account's instance and the account's id, which have
to agree or UnifiedPlaylistDetailView drops ownership and the playlist
loses its edit and delete. Editing refetches first, exactly as the detail
view does: the form saves every field at once, and one started from a row
fetched minutes ago would put that row back over anything changed since.
Deleting from a server asks first; deleting a local playlist stays
unconfirmed as it always was.

Row identity, item mapping and the search filter move into
PlaylistListItem, where they can be tested — the transition id stays
separate from the row identity because it has to match what
PlaylistSource produces for the destination.

Local behaviour is unchanged throughout: the same rows, the same swipe
actions, the same unconfirmed delete, and the same synchronous read on
appear so the empty state never flashes.
2026-09-04 22:50:29 +02:00

369 lines
13 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.
private(set) var channelItems: [SidebarItem] = []
/// 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] = []
/// 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 var cancellables = Set<AnyCancellable>()
// 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
) {
self.dataManager = dataManager
self.settingsManager = settingsManager
self.mediaSourcesManager = mediaSourcesManager
self.instancesManager = instancesManager
loadData()
}
// MARK: - Data Loading
/// Loads subscriptions, playlists, media sources, and instances.
func loadData() {
loadChannels()
loadPlaylists()
loadSources()
}
/// Loads channel items from subscriptions.
private func loadChannels() {
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 {
channelItems = sortedSubscriptions
.prefix(maxChannels)
.map { SidebarItem.from(subscription: $0) }
} else {
channelItems = sortedSubscriptions
.map { SidebarItem.from(subscription: $0) }
}
}
/// Loads playlist items from local playlists.
private func loadPlaylists() {
guard let dataManager else { return }
// Cache playlists for use in videoCount(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 {
playlistItems = sortedPlaylists
.prefix(maxPlaylists)
.map { SidebarItem.from(playlist: $0) }
} else {
playlistItems = sortedPlaylists
.map { SidebarItem.from(playlist: $0) }
}
}
/// 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)
}
// 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 = 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 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
}
/// 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
}
}
// 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")
}
}
}