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