mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Creating a playlist while an Invidious account was the selected context wrote it to the device. No request left the app, the row never appeared in the account's list, and the form had come up without its visibility picker — the two symptoms of the same thing: the sheet took the local branch. The destination was decided correctly and then read wrongly. The button resolved the account and put it in a companion `@State`, and the sheet's content closure — created by an earlier body evaluation — read that property as it stood *then*, which was nil. Measured on device: the create resolved the account and validity fine, and one millisecond later the sheet rendered the local form while every live value around it still named the account. Whether the stale read is corrected depends on whether anything re-renders the view before the form is saved, which is why the same steps against Piped had landed on the server. The destination now travels with the presentation as `PlaylistCreationTarget`, so the sheet is handed the account rather than looking one up. One resolver answers for both the create affordances and the row menus, and it is where the rule lives: a context reading an account's data creates on that account or creates nothing at all — a toolbar item can outlive the condition that put it there, and a write with nowhere to go is not a write to somewhere else. A context that fell back to local reads is the one case that does create locally, and it is honest: those are the rows on screen, with the banner above them saying why. Two more from the same pass: - Subscriptions stopped giving two answers at once. Under a fallback the local feed asks for a Yattee Server nobody has configured, so "Add a subscriptions account in Settings" was stacking under a banner saying this account needs signing in again. The banner names the cause, so the feed error stands down while it is up. - The playlists banner no longer lands on the search field. A navigation bar carrying a search field keeps its full height over content that cannot scroll, and the empty, loading and error states could not — they scroll now, which also gives pull to refresh somewhere to live.
933 lines
33 KiB
Swift
933 lines
33 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 playlistToEdit: LocalPlaylist?
|
|
#if os(tvOS)
|
|
@FocusState private var focusedPlaylistID: PlaylistListItemID?
|
|
@State private var didSetInitialFocus = false
|
|
#endif
|
|
|
|
// MARK: - Remote state
|
|
|
|
/// The creation being collected, and where it goes — captured when the
|
|
/// button was pressed.
|
|
///
|
|
/// The destination travels with the presentation instead of standing beside
|
|
/// it in a second `@State`: a companion value is read by the sheet's own
|
|
/// closure one update behind the button that set it, so a creation on an
|
|
/// account took the local branch — writing to the device and losing the
|
|
/// visibility picker on the way.
|
|
@State private var creation: PlaylistCreationTarget?
|
|
@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)
|
|
}
|
|
|
|
/// Where a playlist created right now would go, or `nil` when there is
|
|
/// nowhere to put one.
|
|
///
|
|
/// The single answer both the create affordances and the row actions are
|
|
/// gated on, so the button that offers a write and the write it performs
|
|
/// can never have decided differently.
|
|
private var creationTarget: PlaylistCreationTarget? {
|
|
guard let appEnvironment else { return nil }
|
|
let account = contextAccount
|
|
|
|
return PlaylistCreationTarget.resolve(
|
|
effectiveContext: appEnvironment.dataContextValidator.effectiveContext,
|
|
account: account,
|
|
allowsServerMutations: appEnvironment.dataContextValidator.allowsServerMutations,
|
|
capabilities: account.flatMap { appEnvironment.accountsManager.capabilities(for: $0) }
|
|
)
|
|
}
|
|
|
|
/// 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 case let .account(account) = creationTarget else { return nil }
|
|
return account
|
|
}
|
|
|
|
/// Whether the create affordances have somewhere to create.
|
|
private var canCreatePlaylist: Bool {
|
|
creationTarget != 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(item: $creation) { creation in
|
|
if let account = creation.account {
|
|
PlaylistFormSheet(
|
|
mode: .create,
|
|
showsPrivacy: supportsPrivacy(account)
|
|
) { title, description, privacy in
|
|
Task {
|
|
await createRemotePlaylist(
|
|
title: title,
|
|
description: description,
|
|
privacy: privacy,
|
|
on: account
|
|
)
|
|
}
|
|
}
|
|
} 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 {
|
|
scrollableState {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
} else if let remoteErrorMessage, items.isEmpty {
|
|
scrollableState {
|
|
remoteErrorView(remoteErrorMessage)
|
|
}
|
|
} else if filteredItems.isEmpty {
|
|
scrollableState {
|
|
emptyView
|
|
}
|
|
} else {
|
|
switch layout {
|
|
case .list:
|
|
listContent
|
|
case .grid:
|
|
gridContent
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A state with nothing to scroll, put in a scroll view anyway.
|
|
///
|
|
/// Two things need one. Pull to refresh has nowhere to live without it —
|
|
/// and a navigation bar carrying a search field keeps its full height over
|
|
/// content that cannot scroll, which is what the banner was landing under.
|
|
private func scrollableState(@ViewBuilder _ state: () -> some View) -> some View {
|
|
ScrollView {
|
|
state()
|
|
.containerRelativeFrame(.vertical)
|
|
}
|
|
}
|
|
|
|
#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
|
|
|
|
/// Opens the form over the destination as it stands at this moment.
|
|
///
|
|
/// Silent when there is nowhere to create: a toolbar item can outlive the
|
|
/// condition that put it there, and a write with nowhere to go is not a
|
|
/// write to somewhere else.
|
|
private func beginCreate() {
|
|
creation = creationTarget
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|