mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32: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.
893 lines
32 KiB
Swift
893 lines
32 KiB
Swift
//
|
|
// PlaylistsListView.swift
|
|
// Yattee
|
|
//
|
|
// Full page view for listing all playlists.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct PlaylistsListView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@Namespace private var sheetTransition
|
|
@State private var localPlaylists: [LocalPlaylist] = []
|
|
@State private var searchText = ""
|
|
@State private var showingNewPlaylist = false
|
|
@State private var playlistToEdit: LocalPlaylist?
|
|
#if os(tvOS)
|
|
@FocusState private var focusedPlaylistID: PlaylistListItemID?
|
|
@State private var didSetInitialFocus = false
|
|
#endif
|
|
|
|
// MARK: - Remote state
|
|
|
|
/// The account a creation is for, captured when the button was pressed —
|
|
/// the form must not follow a context that moves while it is open.
|
|
@State private var createAccount: Account?
|
|
@State private var remoteEdit: RemotePlaylistEdit?
|
|
@State private var remoteDeletion: RemotePlaylistDeletion?
|
|
/// Why the account's list could not be fetched, when it could not.
|
|
@State private var remoteErrorMessage: String?
|
|
/// The effective context the rows on screen belong to, so a status change
|
|
/// that does not move it does not force a reload.
|
|
@State private var loadedContext: DataContext?
|
|
/// Which load the state belongs to. Cancelling the previous task is not
|
|
/// enough on its own: a request that already came back races the
|
|
/// cancellation, and its failure would be reported for a context nobody is
|
|
/// looking at any more.
|
|
@State private var loadGeneration = 0
|
|
@State private var loadTask: Task<Void, Never>?
|
|
|
|
/// A form opened over a server-side playlist, filled from the refetch that
|
|
/// preceded it rather than from the row that was tapped.
|
|
private struct RemotePlaylistEdit: Identifiable {
|
|
let id = UUID()
|
|
let account: Account
|
|
let playlistID: String
|
|
let title: String
|
|
let description: String?
|
|
let privacy: PlaylistPrivacy?
|
|
}
|
|
|
|
/// A deletion waiting on its confirmation.
|
|
private struct RemotePlaylistDeletion {
|
|
let account: Account
|
|
let playlistID: String
|
|
}
|
|
|
|
// View options (persisted)
|
|
@AppStorage("playlists.layout") private var layout: VideoListLayout = .list
|
|
@AppStorage("playlists.rowStyle") private var rowStyle: VideoRowStyle = .regular
|
|
@AppStorage("playlists.gridColumns") private var gridColumns = 2
|
|
|
|
// UI state
|
|
@State private var showViewOptions = false
|
|
@State private var viewWidth: CGFloat = 0
|
|
|
|
// Grid layout configuration
|
|
private var gridConfig: GridLayoutConfiguration {
|
|
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
|
|
}
|
|
|
|
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
|
|
|
/// List style from centralized settings.
|
|
private var listStyle: VideoListStyle {
|
|
appEnvironment?.settingsManager.listStyle ?? .inset
|
|
}
|
|
|
|
private var viewOptionsSheetContent: some View {
|
|
ViewOptionsSheet(
|
|
layout: $layout,
|
|
rowStyle: $rowStyle,
|
|
gridColumns: $gridColumns,
|
|
maxGridColumns: gridConfig.maxColumns
|
|
)
|
|
}
|
|
|
|
/// View options button lives on the leading edge on macOS, trailing elsewhere.
|
|
private var viewOptionsPlacement: ToolbarItemPlacement {
|
|
#if os(macOS)
|
|
.navigation
|
|
#else
|
|
.primaryAction
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Context
|
|
|
|
/// The account whose playlists are on screen, or `nil` when these are the
|
|
/// device's own.
|
|
///
|
|
/// The *effective* context, so a selected account that cannot be used right
|
|
/// now shows local playlists — with the banner saying why — instead of an
|
|
/// empty list nobody can explain.
|
|
private var contextAccount: Account? {
|
|
guard let appEnvironment,
|
|
case let .account(id) = appEnvironment.dataContextValidator.effectiveContext
|
|
else { return nil }
|
|
|
|
return appEnvironment.accountsManager.account(id: id)
|
|
}
|
|
|
|
/// The account these playlists may be changed on.
|
|
///
|
|
/// Both halves are asked even though reads only ever follow a valid context
|
|
/// today: the write gate is what has to hold if that ever stops being true.
|
|
private var writableAccount: Account? {
|
|
guard let appEnvironment,
|
|
let account = contextAccount,
|
|
appEnvironment.dataContextValidator.allowsServerMutations,
|
|
appEnvironment.accountsManager.capabilities(for: account)?.supportsPlaylistWrites == true
|
|
else { return nil }
|
|
|
|
return account
|
|
}
|
|
|
|
/// Whether the create affordances have somewhere to create.
|
|
private var canCreatePlaylist: Bool {
|
|
contextAccount == nil || writableAccount != nil
|
|
}
|
|
|
|
private var contextUnavailability: DataContextUnavailability? {
|
|
guard appEnvironment?.accountsManager.currentContext.isLocal == false else { return nil }
|
|
return appEnvironment?.dataContextValidator.unavailability
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
/// The account's list as last fetched, or `nil` if it never was.
|
|
///
|
|
/// Read live rather than copied into state: everything that changes a
|
|
/// server-side playlist goes through the cache, so reading it is what makes
|
|
/// a rename or a delete — here or in the detail view — show up.
|
|
private var remotePlaylists: [Playlist]? {
|
|
guard let appEnvironment, let account = contextAccount else { return nil }
|
|
return appEnvironment.remotePlaylistsCache.playlists(for: account.id)
|
|
}
|
|
|
|
private var items: [PlaylistListItem] {
|
|
guard contextAccount != nil else {
|
|
return localPlaylists.map { .local($0) }
|
|
}
|
|
return (remotePlaylists ?? []).map { .remote($0) }
|
|
}
|
|
|
|
/// Playlists filtered by search.
|
|
private var filteredItems: [PlaylistListItem] {
|
|
PlaylistListItem.filtered(items, searchText: searchText)
|
|
}
|
|
|
|
/// Nothing fetched yet and no reason given — the spinner is for the first
|
|
/// load only, a refetch leaves the previous list on screen.
|
|
private var isLoadingRemoteList: Bool {
|
|
contextAccount != nil && remotePlaylists == nil && remoteErrorMessage == nil
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
Group {
|
|
#if os(tvOS)
|
|
tvOSContent
|
|
#else
|
|
content
|
|
#endif
|
|
}
|
|
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
|
|
viewWidth = newWidth
|
|
}
|
|
}
|
|
// Above the list rather than inside it: the rows are local data while
|
|
// this is showing, and nothing else on screen says so.
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
if let contextUnavailability {
|
|
DataContextUnavailableBanner(reason: contextUnavailability)
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.navigationTitle(String(localized: "home.playlists.title"))
|
|
.toolbarTitleDisplayMode(.inlineLarge)
|
|
.searchable(text: $searchText, prompt: Text(String(localized: "playlists.search.placeholder")))
|
|
.refreshable {
|
|
await load()
|
|
}
|
|
.toolbar {
|
|
#if os(macOS)
|
|
// Pin the trailing group (search field + toolbar buttons) to the right edge,
|
|
// matching the global Search view.
|
|
if #available(macOS 26, *) {
|
|
ToolbarSpacer(.flexible, placement: .primaryAction)
|
|
}
|
|
#endif
|
|
ToolbarItem(placement: viewOptionsPlacement) {
|
|
DataContextSwitcherMenu()
|
|
}
|
|
ToolbarItem(placement: viewOptionsPlacement) {
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
.liquidGlassTransitionSource(id: "playlistsViewOptions", in: sheetTransition)
|
|
#if os(macOS)
|
|
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
|
|
viewOptionsSheetContent
|
|
}
|
|
#endif
|
|
}
|
|
if canCreatePlaylist {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
beginCreate()
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#if !os(macOS)
|
|
.sheet(isPresented: $showViewOptions) {
|
|
viewOptionsSheetContent
|
|
.liquidGlassSheetContent(sourceID: "playlistsViewOptions", in: sheetTransition)
|
|
}
|
|
#endif
|
|
.sheet(isPresented: $showingNewPlaylist) {
|
|
if let createAccount {
|
|
PlaylistFormSheet(
|
|
mode: .create,
|
|
showsPrivacy: supportsPrivacy(createAccount)
|
|
) { title, description, privacy in
|
|
Task {
|
|
await createRemotePlaylist(
|
|
title: title,
|
|
description: description,
|
|
privacy: privacy,
|
|
on: createAccount
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
PlaylistFormSheet(mode: .create) { title, description in
|
|
_ = dataManager?.createPlaylist(title: title, description: description)
|
|
loadPlaylists()
|
|
}
|
|
}
|
|
}
|
|
.sheet(item: $playlistToEdit) { playlist in
|
|
PlaylistFormSheet(mode: .edit(playlist)) { newTitle, newDescription in
|
|
dataManager?.updatePlaylist(playlist, title: newTitle, description: newDescription)
|
|
loadPlaylists()
|
|
}
|
|
}
|
|
.sheet(item: $remoteEdit) { edit in
|
|
PlaylistFormSheet(
|
|
mode: .editRemote(
|
|
title: edit.title,
|
|
description: edit.description,
|
|
privacy: edit.privacy
|
|
),
|
|
showsPrivacy: supportsPrivacy(edit.account)
|
|
) { newTitle, newDescription, newPrivacy in
|
|
Task {
|
|
await saveRemoteEdit(
|
|
edit,
|
|
title: newTitle,
|
|
description: newDescription,
|
|
privacy: newPrivacy
|
|
)
|
|
}
|
|
}
|
|
}
|
|
// Taking a playlist off a server is a very different sentence from
|
|
// deleting a local one, which stays unconfirmed as it always was.
|
|
.confirmationDialog(
|
|
String(localized: "playlist.remote.delete.confirm"),
|
|
isPresented: Binding(
|
|
get: { remoteDeletion != nil },
|
|
set: { if !$0 { remoteDeletion = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: remoteDeletion
|
|
) { deletion in
|
|
Button(String(localized: "common.delete"), role: .destructive) {
|
|
Task { await deleteRemotePlaylist(deletion) }
|
|
}
|
|
}
|
|
.onAppear {
|
|
startLoad()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playlistsDidChange)) { _ in
|
|
loadPlaylists()
|
|
}
|
|
// Selecting another account — or local — changes whose playlists these
|
|
// are, and the previous ones may not be shown for a moment longer.
|
|
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
|
|
startLoad()
|
|
}
|
|
// The same, without the context moving: the credential behind the
|
|
// account was replaced, so the list may now be someone else's.
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
|
startLoad()
|
|
}
|
|
// A status, an account or an instance changing moves reads between the
|
|
// account and local without the stored context moving at all.
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountStatusesDidChange)) { _ in
|
|
reloadIfEffectiveContextMoved()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
|
|
reloadIfEffectiveContextMoved()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in
|
|
reloadIfEffectiveContextMoved()
|
|
}
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if isLoadingRemoteList {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let remoteErrorMessage, items.isEmpty {
|
|
remoteErrorView(remoteErrorMessage)
|
|
} else if filteredItems.isEmpty {
|
|
emptyView
|
|
} else {
|
|
switch layout {
|
|
case .list:
|
|
listContent
|
|
case .grid:
|
|
gridContent
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
// MARK: - tvOS Content
|
|
|
|
private var tvOSContent: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 24) {
|
|
TextField(String(localized: "playlists.search.placeholder"), text: $searchText)
|
|
.textFieldStyle(.plain)
|
|
|
|
if canCreatePlaylist {
|
|
Button {
|
|
beginCreate()
|
|
} label: {
|
|
Label(String(localized: "home.playlists.new"), systemImage: "plus")
|
|
}
|
|
}
|
|
|
|
// Pull to refresh has no equivalent here, and a server-side list
|
|
// is the only one that can go stale.
|
|
if contextAccount != nil {
|
|
Button {
|
|
startLoad()
|
|
} label: {
|
|
Label(String(localized: "common.refresh"), systemImage: "arrow.clockwise")
|
|
}
|
|
}
|
|
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
|
|
DataContextSwitcherButton()
|
|
}
|
|
.focusSection()
|
|
.padding(.horizontal, 48)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 20)
|
|
|
|
content
|
|
.focusSection()
|
|
}
|
|
.onChange(of: items.first?.id, initial: true) { _, newValue in
|
|
// Work around tvOS focus bug: set initial focus to first playlist once cells materialize.
|
|
// Only ever the first time cells appear — a refetch, or a switch the
|
|
// user made in the header, must not pull focus out from under them.
|
|
guard !didSetInitialFocus, let newValue else { return }
|
|
didSetInitialFocus = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
focusedPlaylistID = newValue
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Empty View
|
|
|
|
@ViewBuilder
|
|
private var emptyView: some View {
|
|
if !searchText.isEmpty {
|
|
ContentUnavailableView.search(text: searchText)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
noPlaylistsView
|
|
}
|
|
}
|
|
|
|
private var noPlaylistsView: some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "home.playlists.title"), systemImage: "list.bullet.rectangle")
|
|
} description: {
|
|
Text(emptyDescription)
|
|
} actions: {
|
|
if canCreatePlaylist {
|
|
Button {
|
|
beginCreate()
|
|
} label: {
|
|
Label(String(localized: "home.playlists.new"), systemImage: "plus")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var emptyDescription: String {
|
|
contextAccount == nil
|
|
? String(localized: "home.empty.description")
|
|
: String(localized: "playlists.selector.empty.account")
|
|
}
|
|
|
|
private func remoteErrorView(_ message: String) -> some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "playlists.remote.load.error"), systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(message)
|
|
} actions: {
|
|
Button(String(localized: "common.retry")) {
|
|
startLoad()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - List Content
|
|
|
|
private var listContent: some View {
|
|
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
|
|
Spacer()
|
|
.frame(height: 16)
|
|
} content: {
|
|
ForEach(Array(filteredItems.enumerated()), id: \.element.id) { index, item in
|
|
listRow(item: item, index: index, count: filteredItems.count)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func listRow(item: PlaylistListItem, index: Int, count: Int) -> some View {
|
|
let row = VideoListRow(
|
|
isLast: index == count - 1,
|
|
rowStyle: rowStyle,
|
|
listStyle: listStyle,
|
|
contentWidth: rowStyle.thumbnailWidth
|
|
) {
|
|
playlistRow(item: item)
|
|
}
|
|
#if !os(tvOS)
|
|
.swipeActions(actionsArray: swipeActions(for: item))
|
|
#endif
|
|
|
|
// Swipe is iOS-only, so a menu is the only way to reach a server-side
|
|
// playlist's actions on macOS and tvOS. Local rows keep exactly the
|
|
// affordances they had — and an account that may not change its
|
|
// playlists gets no menu rather than an empty one.
|
|
if case .remote = item, writableAccount != nil {
|
|
row.contextMenu {
|
|
rowActions(for: item)
|
|
}
|
|
} else {
|
|
row
|
|
}
|
|
}
|
|
|
|
// MARK: - Grid Layout
|
|
|
|
private var gridContent: some View {
|
|
ScrollView {
|
|
VideoGridContent(columns: gridConfig.effectiveColumns) {
|
|
ForEach(filteredItems, id: \.id) { item in
|
|
playlistCard(item: item)
|
|
}
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.scrollClipDisabled()
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Helper Views
|
|
|
|
@ViewBuilder
|
|
private func playlistRow(item: PlaylistListItem) -> some View {
|
|
#if os(tvOS)
|
|
NavigationLink(value: NavigationDestination.playlist(source(for: item))) {
|
|
rowContent(for: item)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.zoomTransitionSource(id: item.transitionID)
|
|
.focused($focusedPlaylistID, equals: item.id)
|
|
#else
|
|
rowContent(for: item)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
appEnvironment?.navigationCoordinator.navigate(to: .playlist(source(for: item)))
|
|
}
|
|
.zoomTransitionSource(id: item.transitionID)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func rowContent(for item: PlaylistListItem) -> some View {
|
|
switch item {
|
|
case let .local(playlist):
|
|
PlaylistRowView(playlist: playlist, style: rowStyle)
|
|
case let .remote(playlist):
|
|
SearchPlaylistRowView(playlist: playlist, style: rowStyle)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func playlistCard(item: PlaylistListItem) -> some View {
|
|
#if os(tvOS)
|
|
let card = NavigationLink(value: NavigationDestination.playlist(source(for: item))) {
|
|
cardContent(for: item)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.zoomTransitionSource(id: item.transitionID)
|
|
.focused($focusedPlaylistID, equals: item.id)
|
|
|
|
if case .remote = item, writableAccount != nil {
|
|
card.contextMenu {
|
|
rowActions(for: item)
|
|
}
|
|
} else {
|
|
card
|
|
}
|
|
#else
|
|
let card = cardContent(for: item)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.onTapGesture {
|
|
appEnvironment?.navigationCoordinator.navigate(to: .playlist(source(for: item)))
|
|
}
|
|
.zoomTransitionSource(id: item.transitionID)
|
|
|
|
if hasRowActions(item) {
|
|
card.contextMenu {
|
|
rowActions(for: item)
|
|
}
|
|
} else {
|
|
card
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Whether a row has anything to offer in a menu.
|
|
///
|
|
/// Local rows always do. A server-side row needs an account that may change
|
|
/// its playlists — an empty menu is worse than no menu at all.
|
|
private func hasRowActions(_ item: PlaylistListItem) -> Bool {
|
|
switch item {
|
|
case .local: true
|
|
case .remote: writableAccount != nil
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func cardContent(for item: PlaylistListItem) -> some View {
|
|
switch item {
|
|
case let .local(playlist):
|
|
LocalPlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
|
|
case let .remote(playlist):
|
|
PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation
|
|
|
|
/// Where a row leads.
|
|
///
|
|
/// A server-side playlist carries both the account's instance and the
|
|
/// account's id, and they have to agree: the detail view only keeps
|
|
/// ownership — and with it editing and deletion — when the id it is handed
|
|
/// names an account on the instance it was opened for.
|
|
private func source(for item: PlaylistListItem) -> PlaylistSource {
|
|
switch item {
|
|
case let .local(playlist):
|
|
return .local(playlist.id, title: playlist.title)
|
|
case let .remote(playlist):
|
|
let account = contextAccount
|
|
return .remote(
|
|
playlist.id,
|
|
instance: account.flatMap { appEnvironment?.accountsManager.instance(for: $0) },
|
|
title: playlist.title,
|
|
accountID: account?.id
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Row Actions
|
|
|
|
@ViewBuilder
|
|
private func rowActions(for item: PlaylistListItem) -> some View {
|
|
switch item {
|
|
case let .local(playlist):
|
|
Button {
|
|
playlistToEdit = playlist
|
|
} label: {
|
|
Label(String(localized: "playlist.edit"), systemImage: "pencil")
|
|
}
|
|
Button(role: .destructive) {
|
|
dataManager?.deletePlaylist(playlist)
|
|
loadPlaylists()
|
|
} label: {
|
|
Label(String(localized: "playlist.delete"), systemImage: "trash")
|
|
}
|
|
|
|
case let .remote(playlist):
|
|
if let account = writableAccount {
|
|
Button {
|
|
beginRemoteEdit(playlist, on: account)
|
|
} label: {
|
|
Label(String(localized: "playlist.edit"), systemImage: "pencil")
|
|
}
|
|
Button(role: .destructive) {
|
|
remoteDeletion = RemotePlaylistDeletion(
|
|
account: account,
|
|
playlistID: playlist.id.playlistID
|
|
)
|
|
} label: {
|
|
Label(String(localized: "playlist.delete"), systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func swipeActions(for item: PlaylistListItem) -> [SwipeAction] {
|
|
switch item {
|
|
case let .local(playlist):
|
|
return [
|
|
SwipeAction(
|
|
symbolImage: "pencil",
|
|
tint: .white,
|
|
background: .orange
|
|
) { reset in
|
|
playlistToEdit = playlist
|
|
reset()
|
|
},
|
|
SwipeAction(
|
|
symbolImage: "trash.fill",
|
|
tint: .white,
|
|
background: .red
|
|
) { reset in
|
|
dataManager?.deletePlaylist(playlist)
|
|
loadPlaylists()
|
|
reset()
|
|
}
|
|
]
|
|
|
|
case let .remote(playlist):
|
|
guard let account = writableAccount else { return [] }
|
|
return [
|
|
// Closed before anything is awaited: an edit begins with a
|
|
// refetch and a delete with a confirmation, and neither should
|
|
// happen behind a row still standing open.
|
|
SwipeAction(
|
|
symbolImage: "pencil",
|
|
tint: .white,
|
|
background: .orange
|
|
) { reset in
|
|
reset()
|
|
beginRemoteEdit(playlist, on: account)
|
|
},
|
|
SwipeAction(
|
|
symbolImage: "trash.fill",
|
|
tint: .white,
|
|
background: .red
|
|
) { reset in
|
|
reset()
|
|
remoteDeletion = RemotePlaylistDeletion(
|
|
account: account,
|
|
playlistID: playlist.id.playlistID
|
|
)
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
private func supportsPrivacy(_ account: Account) -> Bool {
|
|
appEnvironment?.accountsManager.capabilities(for: account)?.supportsPlaylistPrivacy == true
|
|
}
|
|
|
|
// MARK: - Creating
|
|
|
|
private func beginCreate() {
|
|
createAccount = writableAccount
|
|
showingNewPlaylist = true
|
|
}
|
|
|
|
private func createRemotePlaylist(
|
|
title: String,
|
|
description: String?,
|
|
privacy: PlaylistPrivacy?,
|
|
on account: Account
|
|
) async {
|
|
guard let appEnvironment else { return }
|
|
|
|
do {
|
|
try await appEnvironment.remotePlaylistsCache.createPlaylist(
|
|
title: title,
|
|
description: description,
|
|
privacy: privacy,
|
|
for: account
|
|
)
|
|
} catch {
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "playlist.remote.create.error"),
|
|
subtitle: error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Editing
|
|
|
|
/// Opens the edit form over the playlist as the server has it *now*.
|
|
///
|
|
/// The form saves every field at once, so an edit started from a row fetched
|
|
/// minutes ago would put that row's title and description back over anything
|
|
/// changed since — by another client, or from the detail view. On failure
|
|
/// there is no sheet: editing from data known to be stale is the thing this
|
|
/// refetch exists to prevent.
|
|
private func beginRemoteEdit(_ playlist: Playlist, on account: Account) {
|
|
Task {
|
|
guard let appEnvironment else { return }
|
|
let playlistID = playlist.id.playlistID
|
|
|
|
do {
|
|
try await appEnvironment.remotePlaylistsCache.refreshSnapshot(id: playlistID, for: account)
|
|
|
|
// Nothing came back to edit: the account was cleared while this
|
|
// was in flight and its data went with it.
|
|
guard let snapshot = appEnvironment.remotePlaylistsCache.snapshot(
|
|
id: playlistID,
|
|
for: account.id
|
|
) else { return }
|
|
|
|
remoteEdit = RemotePlaylistEdit(
|
|
account: account,
|
|
playlistID: playlistID,
|
|
title: snapshot.playlist.title,
|
|
description: snapshot.playlist.description,
|
|
privacy: snapshot.isListed.map { $0 ? .public : .private }
|
|
)
|
|
} catch {
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "playlist.remote.refresh.error"),
|
|
subtitle: error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveRemoteEdit(
|
|
_ edit: RemotePlaylistEdit,
|
|
title: String,
|
|
description: String?,
|
|
privacy: PlaylistPrivacy?
|
|
) async {
|
|
guard let appEnvironment else { return }
|
|
|
|
do {
|
|
try await appEnvironment.remotePlaylistsCache.updatePlaylist(
|
|
id: edit.playlistID,
|
|
title: title,
|
|
description: description,
|
|
privacy: privacy,
|
|
for: edit.account
|
|
)
|
|
} catch {
|
|
// A half-applied edit is a real outcome on Piped, where the name and
|
|
// the description are two calls — the cache's refetch is what says
|
|
// which half landed, and the rows are read straight out of it.
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "playlist.remote.edit.error"),
|
|
subtitle: error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
|
|
private func deleteRemotePlaylist(_ deletion: RemotePlaylistDeletion) async {
|
|
guard let appEnvironment else { return }
|
|
|
|
do {
|
|
try await appEnvironment.remotePlaylistsCache.deletePlaylist(
|
|
id: deletion.playlistID,
|
|
for: deletion.account
|
|
)
|
|
} catch {
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "playlist.remote.delete.error"),
|
|
subtitle: error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Loading
|
|
|
|
private func startLoad() {
|
|
loadTask?.cancel()
|
|
loadTask = nil
|
|
|
|
guard let (account, generation) = beginLoad() else { return }
|
|
loadTask = Task { await fetchRemoteList(account, generation: generation) }
|
|
}
|
|
|
|
/// The same load, awaited — for pull to refresh, where the spinner has to
|
|
/// stay up until the fetch is over.
|
|
private func load() async {
|
|
guard let (account, generation) = beginLoad() else { return }
|
|
await fetchRemoteList(account, generation: generation)
|
|
}
|
|
|
|
private func reloadIfEffectiveContextMoved() {
|
|
guard appEnvironment?.dataContextValidator.effectiveContext != loadedContext else { return }
|
|
startLoad()
|
|
}
|
|
|
|
/// Records which context the rows belong to and loads the ones that are
|
|
/// already here, returning the account whose list still has to be fetched.
|
|
///
|
|
/// Local data is read synchronously and stays that way: an await before it
|
|
/// would put the empty state on screen for a frame every time this view
|
|
/// appears.
|
|
private func beginLoad() -> (account: Account, generation: Int)? {
|
|
guard let appEnvironment else { return nil }
|
|
|
|
// Recorded on every load, not only on a context change: the first load
|
|
// of a session has to establish what is on screen, or the first status
|
|
// change would look like a move and reload for nothing.
|
|
loadedContext = appEnvironment.dataContextValidator.effectiveContext
|
|
loadGeneration += 1
|
|
remoteErrorMessage = nil
|
|
|
|
guard let account = contextAccount else {
|
|
loadPlaylists()
|
|
return nil
|
|
}
|
|
|
|
return (account, loadGeneration)
|
|
}
|
|
|
|
private func fetchRemoteList(_ account: Account, generation: Int) async {
|
|
guard let appEnvironment else { return }
|
|
|
|
do {
|
|
try await appEnvironment.remotePlaylistsCache.refreshList(for: account)
|
|
} catch is CancellationError {
|
|
// Another load is taking over — it owns the state now.
|
|
} catch let error as APIError where error == .cancelled {
|
|
// The same, one layer down.
|
|
} catch {
|
|
guard generation == loadGeneration else { return }
|
|
remoteErrorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func loadPlaylists() {
|
|
localPlaylists = (dataManager?.playlists() ?? []).sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
|
}
|
|
}
|