A tvOS Label sets its plus glyph nearly against the text at this size;
the row spells the gap out with an HStack there, shared by the local and
remote sections. Other platforms keep the plain Label.
A tvOS menu picker shows only its value, so the collapsed control read
as a bare account name. The menu's label is now the whole labelled row -
'Account' leading, the selection trailing, the same shape as the
settings switcher row - with the choices as a checkmarked picker inside
the menu. Other platforms keep the plain menu picker, which already
carries its label.
The tvOS branch pinned the picker to .inline over a fear that a menu
would dismiss the sheet it sits in — that is true of the hardware Menu
button and a fullScreenCover, not of a menu-style picker in a sheet. The
inline rows meanwhile filled the card with every account before the
first playlist. One .menu picker on every platform, device-verified:
picking another account swaps the section in place.
The in-flight gate now remembers which row it closed for, and that row
trades its plus for a small spinner until the checkmark - or the error
toast - takes over. Local adds are instant and keep going straight to
the check.
The tapped row stays on screen for the half second the checkmark shows -
and for a remote add, the whole round-trip - so quick repeated taps each
started another add and the video landed in the playlist several times.
One gate covers both paths; a failed remote add reopens it, since
failing is the one way the sheet stays up to retry from.
A picker at the top - seeded from the selected data context, private to
the sheet from then on - replaces the stacked per-account sections, so
picking where a video goes no longer scrolls past every login and never
switches what the app is browsing as.
Three things live testing found, all of them where a playlist list or a
playlist is empty.
The picker said "This playlist has no videos" when there were no
playlists to pick — copy about a playlist's contents standing in for
copy about the list of them. Each section now says it has none, scoped
to where it is: the device, or that account. An account whose fetch came
back empty said nothing at all and left a bare gap.
The browse view's Playlists tab fetched into its own state, so a
playlist created through the picker — which goes through the shared
cache — never appeared, and nothing refetched on coming back to the tab.
It reads the cache now, and selecting the tab always refetches with the
rows already there kept on screen. The cache is keyed by account, which
is the discard on an account change the private copy needed a generation
for.
Removing the last video from a playlist took away the whole action row,
so an owned server-side playlist could no longer be renamed or deleted
from the app at all. The menu stays; only what needs videos to act on —
play, download, save to library — goes.
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.
The app could show a server-side playlist and nothing else. Editing was
never the missing part on its own: nothing on screen knew whose playlist
it was looking at, and without an owner there is no credential to fetch it
with, no row identity to remove by, and nobody to send a change to.
So the destination carries the account. Only Invidious ids hint at
ownership — the IVPL prefix — and that heuristic stays for links that
arrive from elsewhere; a Piped playlist looks exactly like any other, so
the places that already know whose list a row came out of now say so. An
owned playlist is then fetched authenticated, which is both the only way
to see a private one and the only way to learn what the server calls its
rows. A playlist read from the public endpoint has no row identities at
all, and that is precisely why nothing about it may be changed: the edit
affordances appear over the authenticated snapshot or not at all.
Rows are paired with entries by walking the two lists together rather
than by index. Entries are a subsequence — a row the server declined to
name is still displayed, it just can never be a target — and matching on
the video alone would be wrong too, since a playlist may hold the same
video twice and each copy is its own row.
Every change goes through the cache, so the refetch that reconciles it
cannot be skipped, and what the view shows afterwards is only ever what
came back. That includes the failures: a removal aimed at a row that has
since moved says so specifically, over a list that has already been
refreshed.
Adding a video now offers every account that can be written to, on every
instance rather than the selected one alone — this is not a browsing
decision. Those rows never claim the video is already there: the list
fetch does not carry a playlist's videos, so membership is unknowable
from here, and a duplicate is the server's business. Capabilities decide
throughout, never the instance type, which is what finally lets a Piped
login list and edit its playlists in the browse tab beside an Invidious
one — and what keeps the visibility picker away from a backend that has
no such setting.
Handoff leaves the account out of its payload deliberately. It assembles
its userInfo key by key, so the added value changes nothing about the
coding either way, but an account is only usable on a device holding its
credential — the receiving device resolves ownership for itself.
Sheet dismiss buttons rendered as an iOS-style circular glass "X" on
macOS 26, which looks foreign on the desktop. Add a shared
sheetCloseToolbarItem helper that renders a native text "Close" button
on macOS while keeping the compact icon-only xmark on iOS/tvOS, and
replace the ~14 duplicated inline close buttons across sheets with it.
The sheet collapsed to an empty strip because a List inside a sheet
has no intrinsic height on macOS. Add an explicit minimum frame like
other sheets, gate presentationDetents to iOS, and replace the
prominent xmark toolbar button with a native Close button on macOS.
On tvOS, ContentUnavailableView inside a Group doesn't expand to fill
available space — it sizes to content and aligns top-leading. Add
.frame(maxWidth: .infinity, maxHeight: .infinity) to all instances
so they center correctly in their parent containers.