Files
yattee/Yattee/Views/Home/PlaylistListItem.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

77 lines
2.5 KiB
Swift

//
// PlaylistListItem.swift
// Yattee
//
// One row of the playlists list, whichever context filled it.
//
import Foundation
/// What identifies a row of the playlists list.
///
/// A server-side playlist id is only unique within the account that holds it,
/// which is all this has to be: the list shows one context's playlists, and a
/// context is the device's own data or a single account — never both, and never
/// two accounts at once.
enum PlaylistListItemID: Hashable {
case local(UUID)
case remote(PlaylistID)
}
/// A playlist as the list shows it, from either kind of context.
///
/// The two sides stay apart rather than being flattened into a common shape: a
/// local playlist is a SwiftData model with a duration to show, a server-side
/// one is a fetched value with an author, and each keeps rendering through the
/// row view built for it.
enum PlaylistListItem {
case local(LocalPlaylist)
case remote(Playlist)
var id: PlaylistListItemID {
switch self {
case let .local(playlist): .local(playlist.id)
case let .remote(playlist): .remote(playlist.id)
}
}
var title: String {
switch self {
case let .local(playlist): playlist.title
case let .remote(playlist): playlist.title
}
}
/// The description a search matches against — named differently on each
/// side, which is the whole reason this exists.
var playlistDescription: String? {
switch self {
case let .local(playlist): playlist.playlistDescription
case let .remote(playlist): playlist.description
}
}
/// The zoom transition source, which is deliberately *not* the row
/// identity: it has to be the value ``PlaylistSource/transitionID`` produces
/// for the destination, or the transition has nothing to land on.
var transitionID: AnyHashable {
switch self {
case let .local(playlist): playlist.id
case let .remote(playlist): playlist.id
}
}
/// Whether a search query matches this playlist.
func matches(_ query: String) -> Bool {
let query = query.lowercased()
return title.lowercased().contains(query)
|| (playlistDescription?.lowercased().contains(query) ?? false)
}
/// The rows a search leaves, in the order they were given in.
static func filtered(_ items: [PlaylistListItem], searchText: String) -> [PlaylistListItem] {
guard !searchText.isEmpty else { return items }
return items.filter { $0.matches(searchText) }
}
}