The built-in presets now use the corner layout, which still falls back
to stacked where it doesn't fit (iPhone, iPad portrait, narrow windows).
In the corner layout the landscape panels open visible unless the preset
says otherwise, on devices that can show it: iPad, macOS, and foldable
iPhones, whose hinge is now remembered once reported. Custom presets and
presets saved without panel settings keep the stacked layout.
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
Route YouTube search results links to search and nocookie embeds to the
video, match YouTube hosts exactly, and report YouTube pages without a
video as unsupported instead of sending them to yt-dlp. A watch link
inside a playlist shows both the video and the playlist when loaded, and
plays the video with the playlist queued around it. URL scheme play,
queue and bookmark actions accept playlist links.
yattee://x-callback-url/{verb}?… runs the same action as yattee://{verb}?…
and then opens x-success with the result's values appended, x-error with
errorCode/errorMessage, or x-cancel when a confirmation is declined (x-error
with errorCode=cancelled when no x-cancel is given). Unknown verbs report
unsupportedAction. Callbacks to file:, javascript: and data: URLs are ignored.
Adds yattee://player/state, which reports what is playing (title, videoID,
channel, playing, position, duration, rate) without changing anything.
yattee://play|play-next|queue|download|bookmark|subscribe|unsubscribe?url={link}
and yattee://player/play|pause|toggle|next|previous|close|seek|rate run through
one LinkActionPerformer that reports a LinkActionResult, ready for
x-callback-url. An unencoded url= keeps its own query; only the action's own
parameters end it.
Actions that change saved state ask first through a confirmation sheet
presented by the top-most sheet host, unless the new 'Run Link Actions
Without Asking' setting is on. Download skips the question when the quality
picker will show anyway. Player commands leave sheets and the player alone.
- Route legacy yattee://watch?v=, yattee://youtube.com/..., yattee://https://...
links like their web equivalents (only natively handled destinations)
- Unwrap yattee://open?url= inside route() so every caller keeps the inner
URL's own query items
- Never route file:// URLs as direct media
- Support source/instance on yattee://playlist and PeerTube /w/p/ and
/video-playlists/ URLs
- Leave open sheets alone and show a toast for links that go nowhere
Both were DEBUG-only Developer settings entries used while building
the accounts revamp and are no longer needed. The simulator's
account-sync suspension barrier in AccountSyncCoordinator existed only
to keep invented scopes away from the real zone, so it goes with it.
In the split-view sidebar layout (macOS, and now iPad and the iPhone
Duo), the detail NavigationStack that a selection change replaces
writes an empty path through its binding on the way out, so going to
another section and back always landed on the section's root. macOS
already behaved this way before the router.
Only the selected section's stack is on screen and can pop, so path
writes for any other section are ignored.
The tab bar and sidebar layouts each kept their own navigation paths and
selection, so swapping between them (resizing an iPad Stage Manager
window across the size-class breakpoint) lost every pushed screen and
re-applied the startup tab.
NavigationRouter now holds the selected section, a typed stack per
section keyed by SidebarItem.id, the search text and the sidebar
manager. Both layouts select with SidebarItem and render the router's
state; navigate(to:) pushes onto it directly instead of going through
an observed pendingNavigation, so a request made before a layout
appears is not lost, and a destination pushed before startup keeps its
section. When a layout can't show the selection (a sidebar channel in
the tab bar, a section hidden by customization) it lands on the section
that can host it with that screen pushed first.
This also fixes keyboard section commands in the tab bar layout and the
coordinator's stale tab on iPad. AppTab, pendingNavigation,
selectedSidebarItem and the unused SheetDestination/path API are gone,
and the iOS 18 and 26.1 root views are merged into AdaptiveTabView.
The corner layout's below-video host is the portrait info panel, not a
page host: it can render only Info, plus Comments as the panel's pill on
iOS. The page editor still offered "Below Video" for every page, so
assigning Related there was accepted, stored, listed as such, and then
silently rendered in the side column.
- The Layout section gains a "Below Video" picker in corner mode:
Nothing / Info / Info and Comments (macOS offers the first two). It
writes the info/comments regions; the per-page Location section and
its strings are gone.
- PanelSettings.normalizedPages clamps regions to what the host can
render (only info/comments below, comments only together with info),
so older presets self-correct. effectiveRegion(for:) resolves the
platform-specific placement and is used by both the pages list labels
and CornerPlayerLayout, replacing its own region checks.
- The layout schematic drops the below-video block for "Nothing"; the
corner-mode footer explains the constraint.
- Tests cover the picker round-trip and the clamping.
Pages list
- Info is pinned first and always enabled in every layout: normalized
pages force it to the front and enabled (older presets self-correct),
disabling it is a no-op, and any move that would displace it snaps
back. The iOS/macOS list renders it outside the reorderable ForEach
(no drag handle, nothing drops above it) and the editors hide its
enabled toggle; tvOS also hides its move buttons. The "at least one
page must stay enabled" guard and its strings are gone since Info
always counts.
- iPhone never renders queue or comments as pages, so its Sections list
hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes
through moveVisiblePages, which keeps hidden entries in their stored
slots. Comments get their own group on the Details Panel screen
(pill mode + avatars) and the overlay hosts pass the avatars flag.
Default visibility
- "When Player Opens" is now configured per orientation
(portraitDefaultVisibility / landscapeDefaultVisibility). The legacy
single value is still decoded and seeds both until set individually;
presets that never set it default to portrait Visible, landscape
Hidden. macOS shows one picker (no portrait layout).
- "Remember Last" no longer leaks across orientations: separate
remembered flags, written by the portrait toggles and by the wide and
corner layouts respectively, with the old shared flag read once as a
fallback. The corner layout seeded from the portrait resolution
before; it now uses landscape.
Section options (iOS inline-sections hosts)
- Chapters and Related editors gain "Expanded by Default" (on by
default). The Info editor gains a Description group with "Expanded by
Default" (off) and a "Collapsed Lines" stepper (1–10, default 3).
Applied when the panel appears and on each video change.
- Expanding a collapsed section scrolls its header to the top of the
panel: PanelPageScrollView can hand its scroll proxy to page content
and each inline section is wrapped in one anchored container.
- The portrait panel's bottom fade sized its touch blocker from a stale
"expanded comments pill" answer that ignored collapse on scroll, so a
collapsed section header (the last item) sat under it and could not
be tapped. The predicate now tracks the pill's actual collapse state.
iPhone has no page switcher: the portrait panel already rendered one info
page with chapters and related videos as collapsible inline sections. The
landscape floating panel now renders the same panel (no page bar, comments
stay on the pill/overlay), and the settings screen calls the list
"Sections" there, with the page bar labels toggle hidden.
The inline sections were hardcoded to chapters then related and ignored
the configured order, enablement, and thumbnail toggles. The info page now
receives the enabled section pages from PanelSettings.inlineSections(in:)
and renders them in list order with their own settings.
The quality selector sheet already covers quality, audio, subtitles and
rate, so the embedded tracks page duplicated it. Drop the page type, its
per-page settings, the editor toggles, and the visibleSections plumbing
in QualitySelectorView.
Phase 5 of the player panels redesign:
- Per-page element toggles now rendered and editable for every page:
comments (author avatars), related (thumbnails), chapters (thumbnails),
queue (previously played section), tracks (video/audio/subtitles/speed
sections)
- Page bar labels toggle (icons-only bar shows the selected page's name)
- Layout mode schematic diagram in the details panel settings
- NEW badge machinery for future page types (versionAdded on PanelPageType)
- Typed per-page settings accessors on PanelPageConfiguration and VM update
helpers for each page kind
- Unit tests for per-page settings round-trip and accessor fallbacks
The standalone quality selector sheet is unaffected: section gating defaults
to everything visible and only the embedded tracks page passes preset
settings through.
Preset-selected two-region layout: video in the top-left, a resizable
side column, and an optional region below the video. Pages are assigned
to regions per preset (Location picker in the page editor); the layout
falls back to the stacked widescreen layout below 900pt width, so Split
View and narrow windows degrade automatically.
- CornerPlayerLayout hosts the shared playerAreaForWidescreen builder,
so the MPV hosting path is identical to the stacked layout
- Layout resolver in ExpandedPlayerSheet picks corner per layout pass;
NavigationCoordinator.isCornerLayoutActive feeds the macOS window
manager, which drops the aspect-ratio lock and auto-fit while corner
is active (and restores them on exit, incl. fullscreen replay and
window re-mounts)
- Side column width persisted via new cornerPanelColumnWidth setting
(280-560pt, video column kept >= 480pt)
- Settings: layout mode picker (iPad/macOS) with automatic-fallback
footer, per-page region picker when corner is on, region shown in
the pages list
Phase 2 of the player panels redesign:
- New "Details Panel" screen in Player Controls settings: page
reorder/enable list with per-page editor (info element toggles),
and a default panel visibility picker (hidden/visible/remember).
- PlayerControlsSettingsViewModel gains panelSettings bindings with
the usual debounced-save flow; the last enabled page cannot be
disabled.
- Built-in presets bumped to v9 with explicit default panelSettings.
- The preset's default visibility now drives the wide-layout panel
on player open and on entering landscape, replacing the hard
hidden resets; "remember" restores the new detailsPanelLastVisible
setting recorded on user toggles. Applied once per expanded player
session (flag on NavigationCoordinator survives re-mounts).
- WideScreenPlayerLayout picks up external visibility writes so the
async-loaded default reaches the mounted layout.
- Unit tests for PanelSettings Codable compatibility (round-trip,
old JSON, unknown page types/discriminators/enum values); removed
stale PlayerInfoTab tests left from Phase 1.
Debrid and CDN download links (TorBox, Real-Debrid, presigned S3) hide the
file behind an opaque path, so the extension-based direct media check missed
them and every one was sent to Yattee Server for yt-dlp extraction, failing
outright when no server is configured.
Add DirectMediaProbe: a HEAD request classifies the response by Content-Type
and Content-Disposition, and when the type is generic or HEAD is refused, a
ranged GET sniffs the first bytes for container signatures (Matroska/WebM,
ISO BMFF, Ogg, FLAC, MP3, RIFF, FLV, ASF, MPEG-PS/TS, HLS and DASH
manifests). Anything playable goes straight to mpv with the server-reported
filename as the title; everything else falls back to extraction as before.
The probe runs ahead of the Yattee Server path in ExternalVideoView, both
OpenLinkSheet flows and the deep-link download path, and PlayerService
rebuilds direct_media streams itself so replays from history no longer
depend on a server either.
Extracted media-source video IDs are full file paths, so closing a
long-named WebDAV video built a watch record name longer than 255
characters and CKRecord.ID threw a fatal CKException.
Scoped record names now fall back to a SHA-256 digest of the ID when
the verbatim form would exceed the limit. The sync engine's local
lookups for materializing pending saves and applying remote deletions
recognise the digest form and match it against all entities of that
type instead of the indexed ID lookup. Short IDs keep their existing
names, so records already in iCloud are unaffected.
Picking a sign-in-required account landed on the local fallback with a
banner — the switch achieved nothing, and the fix lives in Settings.
isSwitchable now refuses a rejected or missing credential the same way
it already refused a switched-off source: the row stays listed with its
status in the title, disabled.
The line runs between states the user must act on and states that heal
on their own. Unreachable stays switchable — while the device itself is
offline every account reads unreachable, and refusing those would lock
the whole switcher — and so do checking and instance-pending, which are
refused nothing they may turn out to deserve.
Reaches every presentation through the shared rows, and withholds
"Use This Account" on the accounts screen through availableContexts.
The tests sign their usable accounts in now that a fresh account under
a resolved scope defaults to credentialMissing and is refused.
Residual round after the macOS fix verification, five closures:
A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.
Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.
The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.
The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.
The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
MAC1: switcher rows are Toggles on macOS so the checkmark really renders,
and the settings row names its context via LabeledContent with a tooltip.
MAC4: accessible names are words, never raw SF Symbol identifiers.
MAC14: new displayStatus(for:) surfaces instanceDisabled on account rows,
badges, and the switcher tooltip without disturbing status(for:) consumers.
MAC16: disambiguated labels guard the username branch and fall back to the
source name only when rows would still collide.
Also carries the new Localizable strings for the whole macOS fix batch.
Only the non-tvOS toolbar menu computed a health-aware glyph; the
settings row and the tvOS button hardcoded the plain outline, so on
tvOS the icon never changed no matter what broke (TV7). One shared
icon now serves all three: outline for Local, filled for a signed-in
account, and an orange exclamation badge when the context needs a
person — credentials rejected, instance disabled, or unreachable.
Transient states like checking stay unbadged so launch doesn't flash
a false alarm.
The two answers to a deleted zone were sharing one lifecycle, and only one of
them deserved it. A re-seed only adds, so it is held open until the engine has
actually serialized the work and redone for as long as it has not. A purge
removes, and replaying it removes more than it was asked to — the account added
while the app kept running, the category switched back on, the reset that runs
its own upload afterwards. It is done the moment its durable half is, and the
comment that called both idempotent now says which is which.
The note itself was never scoped to an identity. A marker written under one
Apple account replayed against the next one's namespace, answering a wipe that
never happened there, and a purge would have taken that account's staged records
and its outbox with it. The account change forgets it now, the way it forgets
everything else it holds.
Retiring the marker trusted whichever state update arrived next, which is not
the same as one that carries the work: an update handed over before the recovery
registered anything can still reach the main actor after it, describing a state
in which none of it exists. Deliveries are counted where the engine hands them
over, and only a later one can retire what was armed.
A refused re-seed waited for the next launch. The accounts it could not speak
for announce themselves when they load, and the recovery is answered then —
outside the callback tree that announcement comes from.
A purge left two ways back for what it dropped: a pending zone save would have
recreated the zone on the next send, and the pre-engine pending-change keys
would have been rediscovered on a later launch. It clears both, and it now runs
after everything that puts work into the engine rather than before.
The account half fences before it destroys, like its upload twin, so a purge
arriving during the identity reload gap answers with nothing rather than with
somebody else's records.
And the reset that promises a re-upload is refused while sync is off, where
nothing would have performed one.
Re-entering the engine from inside its own callback is what started all of this,
and nothing in the code said so. A task-local marks the callback tree and the
two entry points assert on it in debug builds — a plain Task inherits it, which
is the mistake; Task.detached escapes it, which is the fix.
A zone deleted from another device — a reset, an iCloud data purge — arrives
as an event on every other device, and the handler answered it by calling back
into the sync engine from inside the engine's own callback. CloudKit traps on
that, in release, so the app died a second after the push; and because the
handler destroyed the persisted sync state before doing the work, the relaunch
saw a zone that existed again, no local sign anything was lost, and never
noticed the wipe. Both devices then reported being in sync with an empty zone.
The handler now only writes a durable marker and mutates engine state: the zone
is recreated through pending database changes — which also gets past the zone
manager's memo — and the re-upload is registered, never sent. The engine sends
on its own schedule. The marker is written before any of it and retired only
after the engine serializes the state the recovery registered, so a crash
anywhere in the middle is redone on the next launch.
Accounts lead the re-seed now instead of trailing nine categories behind it.
They carry the logins, the tombstones and the selected context, and going last
is how a wipe took them with it.
A purge is not a reset: CloudKit requires that data deleted that way is not
sent back, so those reasons drop what was queued rather than republish it.
Local data stays — it is this app's store, not a cache of the zone.
And the reset that promises "your local data has been re-uploaded" now does it.
The tie test claimed the local record only repeated the server value, but
it fixtures opposing toggles stamped at the same instant, so name it for
the rule it proves. Note beside the strategy why that tie needs no
deterministic tiebreaker: no writer stamps these settings with a fixed
epoch date, unlike accounts and data contexts.
Standing the feed error down under the context banner was aimed at one
sentence and hit every one. The fallback to local reads asks for a Yattee
Server nobody configured, and that question the banner already answers —
but a local refresh that failed on the way out answers nothing, and it was
being swallowed too. With the banner up, an offline refresh left the last
feed on screen with no explanation and no retry: the state said error, the
view said nothing.
The decision now names the errors the banner covers. `yatteeServerRequired`
and `notAuthenticated` are the account the banner is already talking about
and stand down; a `networkError` is news the banner does not carry and
keeps its own message and its retry button. It lives on `FeedLoadState` as
`displayedError(contextBannerVisible:)` so the rule is one function rather
than a guard in a view property, and so it can be tested.
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.
A repair is a fallback, not a choice: it borrows the timestamp of the
selection it replaced so that a later choice still beats it, and it is
never published. But that borrowed rank also won arguments it had no
business winning. An incoming explicit context stamped at the same instant
that loses the `DataContext.tieBreakKey` tie was neither adopted — the
repair was not outranked — nor pushed back, because a repair pushes
nothing. Neither side moved, and the fallback stood against the server's
explicit value indefinitely. The tie is not theoretical: migrated contexts
are all stamped at the epoch, and a repair keeps the timestamp of whatever
it replaced, so two migrated-era values meet as equals.
A stored repair now never outranks an incoming context — it is replaced by
whatever arrived, older or tied, and nothing is ever enqueued for that.
Adopting is safe whatever the incoming value names: one naming an account a
tombstone dominates has already been turned back into a repair before the
comparison, so it stays unpublished and lands on the same target this
device's repair did.
Two more from the same trace:
- Staged records are applied by category, and contexts already come last so
that a repair reads the accounts and the instance a dominated record left
behind rather than falling to local past a signed-in sibling. That
ordering was load-bearing and unremarked; it is now written down, along
with what it does not cover — a context applied in an earlier batch than
the account record that says where the deleted account lived still
repairs without it, which the next explicit choice anywhere settles.
- An install that repaired its context before the repair bit existed reads
back as having chosen it, so that one repair can still be published once.
Accepted and documented at the load site: no shipped release ever
persisted a data context, the window is interim builds only, and the next
explicit choice heals it.
A repair carries the timestamp of the selection it replaced, which is what
stops it outranking a deliberate choice made elsewhere — but it also makes
every ordering comparison read it as that choice. A fetch holding the
selected account's tombstone and an older server context applied them in
that order: the tombstone repaired the selection, and the older context
then lost to it, so the "the server holds the stale copy" path marked the
repair dirty and uploaded an automatic fallback under the deleted choice's
timestamp, over an explicit choice. The stored context now remembers
whether it is a repair, and a repair that wins an ordering pushes nothing
back: the server's context stands remotely, the fallback stays local, and
the next explicit choice on any device re-converges everyone. The bit is
persisted with the context, or a relaunch would forget and publish it on
the first stale record it read.
Three more from the same trace:
- The repair prefers a signed-in login on the same server, decided off
statuses, and it commits — once the selection no longer names the deleted
account there is nothing left for a later status to move. Foreground
activation refreshes statuses ahead of it, but a deletion delivered by a
silent push arrives outside that sequence, and a credential that reached
this device over iCloud Keychain while it was in the background announces
nothing. The refresh is now asked for by the repair itself, which closes
every entry point rather than each fetch trigger having to remember one.
- A device that meets a tombstone before the account it kills cannot record
which instance that account lived on, and the account record arriving
afterwards was discarded — with the only copy of that fact still in it.
Every later repair of a context naming it then fell to local past a
usable sibling. The association is kept as the record is dropped.
- Removing an orphaned account deleted its credential before recording the
deletion, so a crash in between brought the account back on the next
launch as one nobody can sign into, owing no deletion to any device. The
outbox goes first, here and in the sign-out and instance cascades; the
scoped credential key needs only the account's ID, and the store is
resolved through the instance before anything is removed.
Foreground activation started identity resolution, the deletion repair and
the CloudKit fetch as three things racing each other. With a tombstone for
the selected account already on disk and a sibling login on the same server
whose status had not been read back yet, the repair ran against `checking`
— which it will not repair onto — and landed on local. It commits, so the
status refresh arriving a moment later changed nothing: the selection no
longer named an account for a second repair to act on.
The steps now run as one sequence in both platform paths, because each one
is what makes the next correct: the identity says whose accounts these are,
the status refresh says which of them this device can use, the repair reads
those statuses, and the fetch goes last so a deletion it delivers repairs
against the same refreshed picture.
Foreground activation repairs a selection whose account this device
knows was deleted. The check for whether that is safe was the identity
scope being resolved, which it already is in the window between an Apple
account change moving the namespace and the reload refilling memory —
so the repair weighed the previous identity's selection against the
previous identity's tombstones and announced the result to a user who
had not been shown an account yet. Fenced on the loaded namespace, the
same way saves are.
Three smaller things around the same theme:
- The destination rows forgot their membership answers on a credential
or account change but not on an instance change, which adds a row to
the picker while it is open. Unknown reads as unsubscribed, so that
row offered to subscribe a channel the account already has.
- The subscriptions settings count applied whatever the fetch returned.
A selection change is fenced by the service's provider generation, but
the fallback to local is driven by availability and announces nothing,
so the context is captured before the fetch and rechecked after it.
- Deleting a source from the browser runs the same cascade as deleting
it from Settings, and was the one dialog left that did not say the
logins go with it.
`correctIfContextRemoved` read a locally missing account as a deleted one
and reset the context with `setContext(.local)` — a new timestamp and a
pending upload. Now that foreground activation calls it, it ran before
the fetch that delivers the account record: a perfectly good remote
choice was reset and then published over everybody else's. It now asks
`AccountsManager` for a repair keyed on the deletion tombstone, which is
the same repair a deletion performs — a signed-in login on the same
server when there is one, the replaced choice's timestamp, nothing
uploaded. The toast names where the selection actually landed.
Adopting a synced context that named a deleted account repaired it but
kept whatever upload the previous choice still owed, so the sync engine
published the automatic fallback under that choice's timestamp — exactly
what not stamping it "now" exists to prevent. The adoption clears the
flag, and only on adoption: a local choice that outranks the repair keeps
its own upload.
The channel button's subscription lookup is fenced to the channel and the
round it was asked in, so an answer that lands after the user has walked
on — or after the account behind it changed — no longer writes itself
into the button on screen. Context and credential changes ask again. A
destination toggle is compared against the effective context rather than
the raw selection: the menu offers Local precisely when the selected
account cannot be written to, and that toggle has to be the one the
button then shows.
The destination rows forget their checkmarks when the logins change. The
answers describe credentials that have been replaced, and the tvOS picker
deliberately stays open across exactly that.
The subscriptions settings screen counts and exports what it is actually
reading from. The fetch behind both already followed the effective
context while the branch and the footer followed the selection, so an
unavailable account meant local data described as the server's.
The legacy `subscriptionAccount` migration refused to adopt a context
whose account had not synced yet and was never retried, leaving a fresh
install on local until the next launch. A synced account arriving now
retries it; the migration's own guards keep it a one-way move that never
overrides a choice made in the meantime.
The remaining negative-able Piped counts — likes, channel subscribers and
videos, playlist video counts — go through `PipedSentinel.count` like the
views and upload dates already did.