Read a playlist back before writing over it

Ten review findings in the remote-playlist work, all of them the same
shape: something acted on what it remembered instead of what the server
says now.

- A confirmed-gone playlist is forgotten whole — snapshot, list entry,
  and both ordering fences — so a refresh that started before the delete
  can no longer republish it, and a list refetch that fails offline no
  longer leaves it on screen.
- `.accountsDidChange` discards account-scoped content when the account
  it belonged to is no longer the effective one, as the status
  notification already did.
- A tab that stops being offered no longer stays selected: the browse
  view moves to the first available one, which reloads by itself.
- A Handoff account id is accepted only when that account is on the
  instance the playlist lives on.
- Composite writes moved up into `AccountService`, over a transport whose
  every method is exactly one request: create-with-privacy, rename, and
  set-description. The credential is rechecked between each pair, so a
  sign-out between the two halves of a create or an edit can no longer
  send the second half under the previous user's secret.
- Editing a playlist no longer forces it private. Invidious serialises
  visibility as `isListed` — `privacy.public?`, so unlisted and private
  are indistinguishable — which the snapshot now carries; the picker
  starts there, and sends nothing at all unless it was moved. Invidious
  keeps the stored visibility when the PATCH omits it (measured), so an
  untouched picker leaves it exactly as it was.
- Tapping Edit on a server-side playlist awaits an authenticated refetch
  first and populates the form from it; a failed refetch shows an error
  and no sheet.
- Playlist rows are keyed by row rather than by video id, which two rows
  of the same video share.
- The add-to-playlist sheet offers only accounts whose instance is
  enabled and that hold a credential, alongside the write capability.
This commit is contained in:
Arkadiusz Fal
2026-09-02 23:59:31 +02:00
parent 24b43ac866
commit b4855f5be9
12 changed files with 599 additions and 132 deletions

View File

@@ -446,19 +446,16 @@ struct InstanceBrowseView: View {
}
// A credential can arrive over iCloud Keychain, or an account be added,
// while this stays on screen — the Feed tab depends on it and would
// otherwise stay missing until the view was rebuilt. A status change can
// also move *which* account is effective here, since the resolution
// falls back to a sibling login on the same instance, so the content
// loaded for the previous one has to go with it.
// otherwise stay missing until the view was rebuilt. Either notification
// can also move *which* account is effective here: a status change
// because the resolution falls back to a sibling login on the same
// instance, an add or a remove because it changes what there is to fall
// back to. Content loaded for the previous one has to go with it.
.onReceive(NotificationCenter.default.publisher(for: .accountStatusesDidChange)) { _ in
let accountChanged = discardAccountScopedContentIfAccountChanged()
refreshLoginState()
if accountChanged {
Task { await startContentLoad() }
}
handleEffectiveAccountChange()
}
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
refreshLoginState()
handleEffectiveAccountChange()
}
// Selecting the other login on this instance changes whose feed and
// playlists these are. Nothing else here would notice: the instance,
@@ -466,14 +463,18 @@ struct InstanceBrowseView: View {
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
discardAccountScopedContent()
refreshLoginState()
Task { await startContentLoad() }
if !resetSelectedTabIfUnavailable() {
Task { await startContentLoad() }
}
}
// Same reason, without the context moving: the credential behind this
// instance's account was replaced.
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
discardAccountScopedContent()
refreshLoginState()
Task { await startContentLoad() }
if !resetSelectedTabIfUnavailable() {
Task { await startContentLoad() }
}
}
.onChange(of: selectedTab) { _, _ in
isLoading = true
@@ -1255,6 +1256,37 @@ struct InstanceBrowseView: View {
return true
}
/// Handles a change to which account this instance resolves to, whatever
/// moved it: a status, or an account being added or removed.
private func handleEffectiveAccountChange() {
let accountChanged = discardAccountScopedContentIfAccountChanged()
refreshLoginState()
// Moving off a tab that no longer exists reloads by itself.
guard !resetSelectedTabIfUnavailable() else { return }
if accountChanged {
Task { await startContentLoad() }
}
}
/// Moves off a tab that has just stopped being offered.
///
/// The Feed and Playlists tabs exist only while an account backs them. When
/// one goes away the picker drops it, and a selection left pointing at it
/// leaves an unhighlighted tab strip over content nobody can reach.
///
/// - Returns: whether the selection moved, which reloads through
/// `onChange(of: selectedTab)`.
@discardableResult
private func resetSelectedTabIfUnavailable() -> Bool {
let tabs = availableTabs
guard !tabs.contains(selectedTab), let first = tabs.first else { return false }
selectedTab = first
return true
}
private func refreshLoginState() {
guard instance.supportsAuthentication else { return }
isLoggedIn = appEnvironment?.isSignedIn(to: instance) ?? false

View File

@@ -14,9 +14,13 @@ struct PlaylistFormSheet: View {
/// Editing a playlist that lives on a server.
///
/// Server-side playlists are plain values rather than models, so what to
/// pre-fill is passed in. `privacy` is optional because the fetch does
/// not always say what it is — Invidious returns no visibility with the
/// playlist, so the picker falls back to its conservative default.
/// pre-fill is passed in. `privacy` is optional because the fetch cannot
/// always say what it is: Invidious reports visibility as a single
/// "listed" flag, which is exact for a public playlist and ambiguous
/// between unlisted and private for every other one. The picker then
/// starts at the conservative end — and an untouched picker sends
/// nothing at all, so a visibility that could not be read back is left
/// alone rather than overwritten with a guess.
case editRemote(title: String, description: String?, privacy: PlaylistPrivacy?)
static func == (lhs: Mode, rhs: Mode) -> Bool {
@@ -45,7 +49,9 @@ struct PlaylistFormSheet: View {
/// the value rather than inventing it.
let showsPrivacy: Bool
private let onSave: (String, String?, PlaylistPrivacy) -> Void
/// - Parameter privacy: `nil` means "leave whatever the playlist has",
/// which only an edit can ask for.
private let onSave: (String, String?, PlaylistPrivacy?) -> Void
/// Local playlists have no visibility, so their callers never see one.
init(mode: Mode, onSave: @escaping (String, String?) -> Void) {
@@ -60,7 +66,7 @@ struct PlaylistFormSheet: View {
mode: Mode,
showsPrivacy: Bool,
privacy: PlaylistPrivacy = .private,
onSaveWithPrivacy: @escaping (String, String?, PlaylistPrivacy) -> Void
onSaveWithPrivacy: @escaping (String, String?, PlaylistPrivacy?) -> Void
) {
self.mode = mode
self.showsPrivacy = showsPrivacy
@@ -73,6 +79,10 @@ struct PlaylistFormSheet: View {
/// Private by default: the conservative choice, and the one Invidious makes.
@State private var privacy: PlaylistPrivacy = .private
/// What the picker was pre-filled with when editing something that already
/// exists, so a selection left untouched can be told from one that was made.
@State private var prefilledPrivacy: PlaylistPrivacy?
private let maxDescriptionLength = 1000
private var isEditing: Bool {
@@ -136,6 +146,7 @@ struct PlaylistFormSheet: View {
// Only when the fetch actually said so — otherwise whatever the
// caller preselected stands.
if let remotePrivacy { privacy = remotePrivacy }
prefilledPrivacy = privacy
}
}
#if os(iOS)
@@ -193,7 +204,7 @@ struct PlaylistFormSheet: View {
} header: {
Text(String(localized: "playlist.privacy"))
} footer: {
Text(String(localized: "playlist.privacy.footer"))
Text(privacyFooter)
}
}
}
@@ -250,7 +261,7 @@ struct PlaylistFormSheet: View {
} header: {
Text(String(localized: "playlist.privacy"))
} footer: {
Text(String(localized: "playlist.privacy.footer"))
Text(privacyFooter)
}
}
@@ -274,6 +285,18 @@ struct PlaylistFormSheet: View {
// MARK: - Privacy
/// What the picker means here — which is not the same sentence when editing.
///
/// Creating applies the selection outright. Editing applies it only if it
/// was changed, because the value it starts on is the server's answer as
/// far as the server gives one.
private var privacyFooter: String {
if case .editRemote = mode {
return String(localized: "playlist.privacy.footer.edit")
}
return String(localized: "playlist.privacy.footer")
}
private var privacyPicker: some View {
Picker(String(localized: "playlist.privacy"), selection: $privacy) {
ForEach(PlaylistPrivacy.allCases, id: \.self) { option in
@@ -299,10 +322,22 @@ struct PlaylistFormSheet: View {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(trimmedTitle, savedDescription(trimmedDescription), privacy)
onSave(trimmedTitle, savedDescription(trimmedDescription), savedPrivacy)
dismiss()
}
/// The visibility to send, or `nil` to leave the playlist's alone.
///
/// An existing playlist keeps whatever it has unless the picker was
/// actually moved: the value it was pre-filled with is a best guess — the
/// server can say a playlist is not listed without saying whether it is
/// unlisted or private — and sending a guess back would change a setting
/// nobody asked to change. Creating is the opposite: there is nothing to
/// preserve, and Invidious refuses a creation without a visibility.
private var savedPrivacy: PlaylistPrivacy? {
privacy == prefilledPrivacy ? nil : privacy
}
/// What an emptied description field means to the caller.
///
/// Editing a server-side playlist is the one case where the difference

View File

@@ -99,6 +99,12 @@ struct UnifiedPlaylistDetailView: View {
/// The account this playlist belongs to, when it belongs to one.
@State private var owningAccount: Account?
/// Whether the server lists this playlist publicly, as of the last snapshot.
@State private var remoteIsListed: Bool?
/// Whether the refetch that has to land before the edit sheet opens is out.
@State private var isPreparingEdit = false
// MARK: - Local-only State
@State private var localPlaylist: LocalPlaylist?
@@ -152,6 +158,18 @@ struct UnifiedPlaylistDetailView: View {
return appEnvironment?.accountsManager.capabilities(for: owningAccount)?.supportsPlaylistPrivacy == true
}
/// The playlist's visibility, as far as the server said it.
///
/// Invidious answers with a single "listed" flag, so `true` is exactly
/// public and `false` is unlisted *or* private with no way to tell which.
/// The form starts the picker at the conservative reading of `false` and
/// only sends a visibility the user actually chose, so the ambiguity never
/// turns into a change nobody asked for.
private var knownRemotePrivacy: PlaylistPrivacy? {
guard let remoteIsListed else { return nil }
return remoteIsListed ? .public : .private
}
/// Deleting a server-side playlist takes it off the server, which is a very
/// different sentence from deleting a local one.
private var deleteConfirmationTitle: String {
@@ -232,10 +250,12 @@ struct UnifiedPlaylistDetailView: View {
loadLocalPlaylist()
}
} else if canEditRemotePlaylist {
// The fetch carries no visibility, so the picker starts at the
// conservative default rather than pretending to know.
PlaylistFormSheet(
mode: .editRemote(title: title, description: descriptionText, privacy: nil),
mode: .editRemote(
title: title,
description: descriptionText,
privacy: knownRemotePrivacy
),
showsPrivacy: remoteSupportsPrivacy
) { newTitle, newDescription, newPrivacy in
Task {
@@ -515,10 +535,11 @@ struct UnifiedPlaylistDetailView: View {
// account owns.
if (isLocal && localPlaylist != nil) || canEditRemotePlaylist {
Button {
showingEditSheet = true
beginEdit()
} label: {
Label(String(localized: "playlist.edit"), systemImage: "pencil")
}
.disabled(isPreparingEdit)
Button(role: .destructive) {
showingDeleteConfirmation = true
@@ -679,13 +700,20 @@ struct UnifiedPlaylistDetailView: View {
// account owns.
if (isLocal && localPlaylist != nil) || canEditRemotePlaylist {
Button {
showingEditSheet = true
beginEdit()
} label: {
Label(String(localized: "playlist.edit"), systemImage: "pencil")
.frame(maxWidth: .infinity, minHeight: 50)
.font(.headline)
Group {
if isPreparingEdit {
ProgressView()
} else {
Label(String(localized: "playlist.edit"), systemImage: "pencil")
}
}
.frame(maxWidth: .infinity, minHeight: 50)
.font(.headline)
}
.buttonStyle(.bordered)
.disabled(isPreparingEdit)
Button {
showingDeleteConfirmation = true
@@ -740,7 +768,11 @@ struct UnifiedPlaylistDetailView: View {
let indexColumnWidth: CGFloat = 32
#endif
return LazyVStack(spacing: 0) {
ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in
// Keyed by row rather than by video: a playlist may hold the same
// video twice, and two rows sharing an identity leave SwiftUI free
// to pair a row's view with the other one's index — which is what
// the removal closure is addressed by.
ForEach(Array(videos.enumerated()), id: \.offset) { index, video in
VideoListRow(
isLast: index == videos.count - 1,
rowStyle: .regular,
@@ -916,8 +948,14 @@ struct UnifiedPlaylistDetailView: View {
private func resolveOwningAccount(playlistID: PlaylistID, instance: Instance) -> Account? {
guard let appEnvironment else { return nil }
// Only if that account is on *this* instance. An id that came in over
// Handoff was minted on another device, where it may name an account
// this one has on a different server entirely — and resolving it would
// send the authenticated fetch somewhere the playlist does not live,
// with a credential that has nothing to do with it.
if let accountID = source.accountID,
let account = appEnvironment.accountsManager.account(id: accountID) {
let account = appEnvironment.accountsManager.account(id: accountID),
appEnvironment.accountsManager.instance(for: account)?.id == instance.id {
return account
}
@@ -941,6 +979,7 @@ struct UnifiedPlaylistDetailView: View {
apply(snapshot.playlist)
entries = snapshot.entries
entriesByRow = Self.rowEntries(for: snapshot)
remoteIsListed = snapshot.isListed
}
/// Pairs each displayed row with the entry that can name it to the server.
@@ -1077,7 +1116,53 @@ struct UnifiedPlaylistDetailView: View {
}
}
private func saveRemoteEdit(title newTitle: String, description: String?, privacy: PlaylistPrivacy) async {
/// Opens the edit form — over the playlist as the server has it *now*, when
/// the playlist is one.
///
/// The form saves every field at once, so an edit started from a snapshot
/// taken minutes ago would put that snapshot's title and description back
/// over anything another client changed since. A local playlist is the only
/// copy there is and needs none of this.
private func beginEdit() {
guard !isLocal else {
showingEditSheet = true
return
}
Task { await beginRemoteEdit() }
}
private func beginRemoteEdit() async {
guard let appEnvironment,
let account = owningAccount,
let playlistID = remotePlaylistID
else { return }
isPreparingEdit = true
defer { isPreparingEdit = false }
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, exactly as on load.
guard let snapshot = appEnvironment.remotePlaylistsCache.snapshot(
id: playlistID,
for: account.id
) else { return }
apply(snapshot)
showingEditSheet = true
} catch {
// No sheet: editing from data known to be stale is the thing this
// refetch exists to prevent.
appEnvironment.toastManager.showError(
String(localized: "playlist.remote.refresh.error"),
subtitle: error.localizedDescription
)
}
}
private func saveRemoteEdit(title newTitle: String, description: String?, privacy: PlaylistPrivacy?) async {
guard let appEnvironment,
let account = owningAccount,
let playlistID = remotePlaylistID

View File

@@ -44,10 +44,19 @@ struct PlaylistSelectorSheet: View {
/// Every account that can be added to, on every instance — not just the one
/// currently selected as the data context. Adding a video to a playlist is
/// not a browsing decision, and the user may well mean the other login.
///
/// The same three things the write layer insists on, asked here so an
/// account that could only ever fail is not offered as somewhere to put a
/// video: the backend must do playlist writes, the instance must be one
/// this app still talks to, and there must be a credential to talk with.
/// Account *status* deliberately is not among them — a stale "rejected"
/// would hide a login that works, and a failed add says so with a toast.
private var remoteDestinations: [Account] {
guard let appEnvironment else { return [] }
return appEnvironment.accountsManager.accounts.filter {
appEnvironment.accountsManager.capabilities(for: $0)?.supportsPlaylistWrites == true
return appEnvironment.accountsManager.accounts.filter { account in
appEnvironment.accountsManager.capabilities(for: account)?.supportsPlaylistWrites == true
&& appEnvironment.accountsManager.instance(for: account)?.isEnabled == true
&& appEnvironment.accountCredentialsManager(for: account)?.isLoggedIn(for: account) == true
}
}
@@ -302,7 +311,7 @@ struct PlaylistSelectorSheet: View {
on account: Account,
title: String,
description: String?,
privacy: PlaylistPrivacy
privacy: PlaylistPrivacy?
) async {
guard let appEnvironment else { return }