Commit Graph

581 Commits

Author SHA1 Message Date
Arkadiusz Fal
03037e6769 Let a refused host count as unreachable and the switcher own every login
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".
2026-09-12 17:10:58 +02:00
Arkadiusz Fal
40d6f4e71f Let a status flap fence the feed instead of throwing it away
MAC3-R1 from the fix verification: refreshCachesForEffectiveContext purged
on every effective-context move, so the cache the Subscriptions fix meant
to keep was already empty when its keep-path ran. A move under an unchanged
selection now fences the feed cache non-destructively; only a genuine
selection move still purges. Also settles the destination loader's
observer callback onto the main actor without a capture-of-var warning.
2026-09-11 21:14:53 +02:00
Arkadiusz Fal
c0d38834a6 Let rows update their counts, drop fake dates, and own failed lookups
MAC6: a snapshot mutation carries its video count back to the list cache.
MAC8: playlist rows and cards are Buttons with the context menu attached,
so macOS accessibility can reach Edit and Delete.
MAC10: a published date stamped with the request's own time is dropped at
both API sentinels, and formattedPublishedDate refuses zero-interval output.
MAC11: a failed history hydration shows Details unavailable with a Try
Again action instead of keeping the raw video ID forever; an empty 200
body is a legible failure.
2026-09-11 20:28:35 +02:00
Arkadiusz Fal
3281840a8e Let the login sheet respect its edges and the account footer tell the truth
MAC2/MAC15: the macOS login form is grouped like its Edit Source parent, so
labels sit inside the sheet and the add-flow step stops clipping; footers
wrap instead of truncating.
MAC12: the account section footer consults account statuses before claiming
the user is logged in.
2026-09-11 19:36:50 +02:00
Arkadiusz Fal
94b2dff75b Let Subscriptions settle on an answer and heal when the server returns
MAC3: an error the context banner answers now reaches a terminal Feed
Unavailable view instead of the spinner branch; an effective-context move
keeps the cached feed instead of purging it; the view retries on appear,
on activation, on a status lift, and every minute while a network error
is on screen.
2026-09-11 19:08:25 +02:00
Arkadiusz Fal
b0b95195b6 Let Subscribe On know its memberships and refuse a doomed destination
MAC5: membership lives in an observable loader started from ChannelView's
task, outside the NSMenu content SwiftUI never runs lifecycle for; rows are
Toggles so the mark renders on macOS, and writes toast Subscribed or
Unsubscribed with the destination name.
MAC13: a destination whose context fails validation is disabled, the same
gate the plain Subscribe button uses.
2026-09-11 08:20:24 +02:00
Arkadiusz Fal
e661635079 Let every menu mark the current context and a disabled source admit it
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.
2026-09-11 08:02:26 +02:00
Arkadiusz Fal
cf07236cf7 Let a private playlist wait for its account instead of asking in public
When the credential identity scope drops to unresolved — which it
does on its own on every activation without a ubiquity token — the
account list is briefly empty, and the detail view answered "nobody
can be named" as "nobody owns it": it refetched through the public
endpoint, which replies 404 "Playlist does not exist." for a private
playlist that was on screen a moment ago (TV9). An account-loaded
playlist now waits out the unresolved window, and the reload guard
remembers that an account once read it here, so the notification that
credentials are readable again recovers the view instead of being
dropped for lack of anything left on screen.
2026-09-10 23:55:05 +02:00
Arkadiusz Fal
9562510c67 Let a failed detect offer a retry instead of a dead Add Source
After detection failed, Add Source stayed enabled and tapping it just
re-ran detection into the same error — an enabled-looking button that
visibly did nothing, forever (TV8). The error state now shows the
button that can help, Retry Detection, holds a perceptible detecting
beat even when the host fails instantly, and hides the display-name
section that made the form read as submittable. Add Source itself now
requires a detected type, so it can no longer render enabled without
one.
2026-09-10 23:50:24 +02:00
Arkadiusz Fal
b69fb7fb1f Let the switcher icon say how the account is doing
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.
2026-09-10 22:40:44 +02:00
Arkadiusz Fal
026b5ffc90 Let Home claim there are no sources only when there are none
The tvOS empty check asked whether any visible Home section had rows
— and the tvOS defaults show only Continue Watching, so every quiet
launch wore the first-run "Add a media source" welcome despite three
configured sources (TV4). Emptiness now consults the sources the
Sources screen itself reads: no sources keeps the welcome, sources
with nothing to show say so plainly, and a still-loading cache shows
a spinner instead of flashing either. Instance sections also stopped
counting as content while disabled or empty, which used to hide the
empty state behind a blank scroll view.
2026-09-10 22:02:22 +02:00
Arkadiusz Fal
0eee1d5d73 Let Edit Source account actions be rows focus can reach on tvOS
The account rows hung everything off a contextMenu, which tvOS can
only open on a focused element — and a bare Form row is not one, so
focus skipped from Password to Add Account and the actions were
unreachable (TV2). The Accounts screen already solved this by
emitting the actions as sibling rows on tvOS; the section now shares
that shape, keeping the context menu everywhere else.
2026-09-10 20:42:07 +02:00
Arkadiusz Fal
011ffee53c Let the words fit the things they describe
tvOS UX campaign polish (TV1, TV3, TV5, TV6):
- Invidious sign-in asks for a Username, which is what it verifies;
  the wire field is still called email, noted where it is sent
- Video counts pluralize ("1 video"), via catalog plural variations;
  the two-argument count-and-duration key uses fully positional
  substitutions, since a mixed format made CFRetain fault on the
  integer argument (TV10)
- Character counters render their limits verbatim ("0/1000"), here
  and in the preset-name and bookmark-note counters with the same bug
- Deleting a playlist, local or remote, names the playlist the way
  removing an account already named the account
2026-09-10 20:34:46 +02:00
Arkadiusz Fal
b67b3472f6 Let a purge be answered once and a re-seed be answered until it sticks
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.
2026-09-09 23:59:59 +02:00
Arkadiusz Fal
a3e395fe4c Let a wiped zone be reseeded instead of crashing the messenger
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.
2026-09-09 23:59:58 +02:00
Arkadiusz Fal
0c6c7763db Let an untouched Save keep the name it was already showing 2026-09-09 23:59:57 +02:00
Arkadiusz Fal
7417866537 Let one account's wait for a credential leave another's outage alone 2026-09-09 23:59:56 +02:00
Arkadiusz Fal
2e1f411cc8 Let the label the account syncs be one the user can set 2026-09-09 23:52:54 +02:00
Arkadiusz Fal
2e6a464575 Let a login fixed on one device lift the rejection on the other 2026-09-09 21:36:08 +02:00
Arkadiusz Fal
bb6e98f9d2 Say what a timestamp tie actually does to the toggle
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.
2026-09-08 23:49:43 +02:00
Arkadiusz Fal
e0f6b5e6d4 Let the newer channel notification toggle win its conflict 2026-09-08 23:27:06 +02:00
Arkadiusz Fal
0bfacaa497 Say why a matching secret can only be the one that was adopted 2026-09-08 23:10:27 +02:00
Arkadiusz Fal
bb46ac3e32 Settle a secret before handing over the account it belongs to 2026-09-08 23:09:57 +02:00
Arkadiusz Fal
f1e034111c Prune a record only once nothing of it is left behind 2026-09-08 22:23:30 +02:00
Arkadiusz Fal
92a8777e0a Let a signed-out import be driven without an Apple account 2026-09-08 19:41:43 +02:00
Arkadiusz Fal
87ab014467 Let the account that signs in claim what was made signed out 2026-09-07 23:46:12 +02:00
Arkadiusz Fal
250fc239c6 Say a refused login where the sources are browsed 2026-09-07 23:39:29 +02:00
Arkadiusz Fal
02efc71e7c Tell the accounts apart, and let a disabled source come back 2026-09-07 18:53:21 +02:00
Arkadiusz Fal
5ac6b7188d Ask what a backend supports instead of what it is 2026-09-07 18:48:50 +02:00
Arkadiusz Fal
dca8e2fbe4 Let a row show the metadata it was handed 2026-09-07 07:30:00 +02:00
Arkadiusz Fal
69aed50012 Keep a replay out of the playback that took its place 2026-09-07 06:41:54 +02:00
Arkadiusz Fal
cfc0547588 End the session with the watch that finished 2026-09-06 23:36:27 +02:00
Arkadiusz Fal
6bdcd51416 Let a replay resume only the watch it started 2026-09-06 23:12:13 +02:00
Arkadiusz Fal
ee4f458efe Let a replay start the watch it says it is 2026-09-06 22:47:48 +02:00
Arkadiusz Fal
5072fb0dce Start a session where the watch actually begins 2026-09-06 22:21:15 +02:00
Arkadiusz Fal
0e289ae920 Show a history from the account that recorded it 2026-09-06 16:23:28 +02:00
Arkadiusz Fal
7bcc711c40 Mirror a watch into the account it was watched under 2026-09-06 11:06:15 +02:00
Arkadiusz Fal
3dc129dcfa Let the banner speak only for what it answers
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.
2026-09-05 23:49:10 +02:00
Arkadiusz Fal
6bf3942529 Send a creation where the button said it would go
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.
2026-09-05 23:41:25 +02:00
Arkadiusz Fal
d41c6938f2 Let go of a playlist before deleting it
Deleting a local playlist from its own detail view took the app down every
time: the model is gone the moment the context saves, dismissal is animated
so the view renders at least once more, and the summary line under the
title reads the playlist's total duration. Reading a deleted model traps in
SwiftData with "context is missing", which is a fatal error rather than a
nil.

The state is cleared before the deletion now, so the renders that follow
have nothing to reach for. Everything else the view shows about a local
playlist is a copy — the rows are `Video` values built at load time — so
letting the model go costs the dismissal nothing.

Predates the accounts revamp; the same three lines are on `main`.
2026-09-05 23:38:06 +02:00
Arkadiusz Fal
5abd314c88 Let an incoming context settle what a repair cannot argue with
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.
2026-09-05 23:30:40 +02:00
Arkadiusz Fal
ccf5c51460 Keep an automatic repair from ever reaching the server
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.
2026-09-05 23:06:10 +02:00
Arkadiusz Fal
ca73b68ad9 Let activation know the accounts before it repairs the selection
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.
2026-09-05 22:27:21 +02:00
Arkadiusz Fal
f44c1d91c3 Refuse to repair a context that is not this identity's
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.
2026-09-04 23:59:27 +02:00
Arkadiusz Fal
fbeae586b0 Only a deletion the app can point at moves the selection
`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.
2026-09-04 23:56:39 +02:00
Arkadiusz Fal
69179c9fe9 Give every login a screen, including the ones with nowhere to go
An account whose source is gone was listed nowhere: `orphanedAccounts` had
no readers, and the credential behind it could not be reached at all —
`AppEnvironment.accountCredentialsManager(for:)` resolves the store from
the account's instance, and an orphan has none. The new accounts screen
lists them and removes them by account ID, asking every account-keyed
store because the backend is no longer knowable. The pre-revamp
instance-keyed item is deliberately left behind: identifying it needs the
instance too.

Deleting a source has always signed out and removed the accounts on it.
Both confirmations now say so, and name them.

The sign-in that files a credential against an account is now one
implementation instead of a private method on the source editor, so the
accounts screen and the new optional sign-in step after adding a source
attach a login exactly as the editor does. Extracted verbatim — the
identity-scope guard, the account re-resolve, the write-then-read-back
and the rollback are what keep a half-written login from looking real.

Signing out from the per-instance section now asks first, and both
sign-out paths run one cascade rather than two copies of it.
2026-09-04 23:35:51 +02:00
Arkadiusz Fal
0c5745e4fb Let a subscribe name the account it lands on
Subscribing went wherever the selected account happened to be, and the
button offering it read the local store first — so under a server account
it claimed a channel was subscribed when only the device knew about it.
The button now asks the active provider and nothing else, except under a
local context, where the local store is that provider and can answer
without a hop.

The channel menu gains a destination list: local, plus every account
whose backend does subscriptions, whose instance is switched on, and
which has a credential on this device. A row names its own target, so it
deliberately does not care whether the selected context can be written
to — that is precisely the case where naming somewhere else is the only
way to subscribe at all. The main button is disabled instead, with the
reason beside it. tvOS reaches the same list through its own button and a
full-screen picker, because a menu cannot be driven with a remote.

Membership is asked for only once the rows are on screen: an unwarmed
provider answers by fetching that account's entire subscription list, and
asking every destination up front is a burst nobody requested. Answers
carry the round they were asked under, and "not asked yet" is kept apart
from "asked, not subscribed" — a checkmark the user acts on must not be a
guess.

SubscriptionService grows the destination-parameterized subscribe,
unsubscribe and membership calls the menu writes through, with the
context-implicit ones left as wrappers over the current context. A
membership answer that outlived its login now says nothing rather than
"no". Building a provider moves behind SubscriptionProviderFactory, which
is what lets a test see which destination a write was routed to without a
server behind it.

The unsubscribe-only screens are untouched: they act on the account being
browsed, which is the account whose list they are showing.
2026-09-04 23:18:04 +02:00
Arkadiusz Fal
3d87aeeaf1 Show the selected account's playlists on the playlists screen
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.
2026-09-04 22:50:29 +02:00
Arkadiusz Fal
f4fcf97a27 Read Piped's -1 and its 200-with-an-error-body for what they are
Piped answers with "views": -1 and "uploaded": -1 wherever it has no
number, and playlist rows are full of them. Mapped straight through, a
row read "-0B views • 56 years ago" — -1 millisecond is a date in 1969.
Every Piped-to-Video conversion now runs both fields through
PipedSentinel, which reads a negative as an absent value; the epoch
milliseconds the field really is stay milliseconds.

Piped also refuses a write with HTTP 200 and {"error": "..."} — adding a
video it cannot fetch, or removing an index that moved, both come back
that way. Decoding straight into the success shape failed, and the toast
showed a decoding-error string instead of the server's own sentence. All
authenticated writes now read their body through decodeWriteResponse,
which checks the error shape first (a body carrying error may carry
message beside it) and throws APIError.serverError with the message, so
the existing toasts say what actually happened. HTTPClient's status-code
handling is untouched, so no other backend changes behaviour.
2026-09-04 19:31:47 +02:00
Arkadiusz Fal
175b98097a Put the account switcher where the data is, and retire the old setting
Switching accounts was buried in Subscriptions settings behind a picker
and a confirmation dialog, and it wrote two stores that could disagree.
One shared switcher now sits in the Subscriptions and Playlists
toolbars, the macOS sidebar footer and the Settings root, and every path
goes through AppEnvironment.switchDataContext(to:) so a switch behaves
the same wherever it is made. Switching is one tap: it is reversible, so
there is nothing to confirm.

Rows come from DataContextValidator.availableContexts, name themselves
through displayName(for:), and carry a status suffix when the account is
not signed in — plus the instance host when two accounts share a name.
tvOS gets the same rows as a full-screen picker.

Subscriptions also explains itself when the selected account cannot
answer: a thin banner names the cause and says the data below is local.
This is the first consumer of the validator's unavailability, and
correctIfContextRemoved() now runs on foreground activation, where
SubscriptionAccountValidator used to auto-correct any selection it could
not immediately verify.

With the switcher reading the context directly, the legacy
subscriptionAccount setting has no readers left: the setting, its
validator, the mirror that kept it in step and the feed cache's
instance-keyed fallbacks all go. The cache invalidation the mirror also
carried stays, under its own name. AccountsManager still reads the raw
legacy key for the one-way context migration.
2026-09-03 23:59:32 +02:00