Commit Graph

3152 Commits

Author SHA1 Message Date
Arkadiusz Fal
f6fde48767 Rebuild the sidebar switcher menu when the context name changes 2026-09-16 21:14:22 +02:00
Arkadiusz Fal
59c4f27bc7 Slim the iPad sidebar switcher pill to icon and account name 2026-09-16 18:27:17 +02:00
Arkadiusz Fal
5d33e4a558 Hide toolbar account switchers where the sidebar already shows one 2026-09-16 18:16:10 +02:00
Arkadiusz Fal
a5ed91620f Back the iPad sidebar switcher with its own pill 2026-09-15 23:57:59 +02:00
Arkadiusz Fal
0aa79221f4 Pin the account switcher under the iPad sidebar like macOS 2026-09-15 23:56:46 +02:00
Arkadiusz Fal
e28ca98831 Give the tvOS New Playlist row air between icon and title
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.
2026-09-15 23:23:47 +02:00
Arkadiusz Fal
ba553162f4 Name the tvOS destination picker in its row
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.
2026-09-15 22:56:10 +02:00
Arkadiusz Fal
62f7e3eeb3 Let the playlist selector's account picker collapse on tvOS
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.
2026-09-15 18:03:04 +02:00
Arkadiusz Fal
753cda9661 Label the local context by where its data actually lives
"Yattee (iCloud)" was hardcoded for the local data context even with
iCloud sync disabled, when the data is purely on-device. The name now
comes from DataContextValidator.localContextName, which checks
iCloudSyncEnabled and falls back to "Yattee (This Device)".
2026-09-15 09:02:18 +02:00
Arkadiusz Fal
50e32dbe5a Make every tvOS switcher a menu and retire the full-screen picker
The header button and the settings row both raised a fullScreenCover to
pick a context; a Menu offers the same rows in place for one click,
matching the pickers elsewhere in Settings. With every presentation now
a menu, the picker view and the rows' inMenu flag had nothing left to
serve. The subscriptions header also cedes its width to the account
name: the view-options button keeps only its icon, since two full
titles wrapped into tall two-line lozenges in the tvOS sidebar.
2026-09-14 18:59:00 +02:00
Arkadiusz Fal
ded53d7962 Fold the tvOS account actions into a press-raised dialog
Each account was four focusable rows tall on tvOS — Use this account,
Sign in again, Rename and Sign out all spelled out as siblings, a lot
of clicking past on a remote. The row itself is now the one focusable
unit: pressing it raises the same actions as a confirmation dialog,
mirroring the context menu the other platforms already attach.
2026-09-14 18:28:28 +02:00
Arkadiusz Fal
b917049243 Keep a fenced feed refresh from stranding the loading flag
The refresh captured its account generation before hopping onto the
detached task it runs in. That hop is a suspension point: a context
handler running in the gap - the wake-time status flap bumps the
generation there - left the request carrying a stale generation. Its
completion was then fenced out, and the fenced return deliberately
leaves isLoading alone, so nothing current owned the flag: it stayed
true, every later refresh skipped itself as already loading, and the
retry paths only watch for errors - the feed sat on a spinner nothing
would ever finish.

The generation and context are now read at dispatch time, so the
request that claims isLoading always carries the generation current at
that moment and its completion is the one that releases it. The
stateless-server poller's terminal writes get the same fence: a stale
poller must not hand a newer request's spinner an early ready.
2026-09-14 09:02:32 +02:00
Arkadiusz Fal
a28346148a Show the tvOS channels sidebar for a single-channel account
The sidebar hid itself below two channels, so an account with one
subscription lost the channel list, the manage-channels entry and the
switcher's persistent home next to View Options - and switching from a
many-channel account read as the sidebar breaking rather than a design
choice. One channel is enough to earn the sidebar; only an empty account
goes without it.
2026-09-14 09:01:26 +02:00
Arkadiusz Fal
82ef5267c4 Let the sidebar's channels and playlists follow the data context 2026-09-14 07:11:28 +02:00
Arkadiusz Fal
f2c4721e5a Leave the macOS toolbars to the sidebar's account switcher 2026-09-13 20:38:26 +02:00
Arkadiusz Fal
69d22cdc98 Withhold every account switcher until an account exists to offer 2026-09-13 20:16:14 +02:00
Arkadiusz Fal
5136b79f49 Let the user hide the account pickers outside Settings 2026-09-13 18:56:18 +02:00
Arkadiusz Fal
ce97aa1031 Let the tapped playlist row spin while its add is on the wire
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.
2026-09-13 14:01:06 +02:00
Arkadiusz Fal
80ed81b0e4 Let the playlist selector accept only one add per presentation
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.
2026-09-13 13:48:25 +02:00
Arkadiusz Fal
a742aee353 Let the playlist selector show one destination at a time
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.
2026-09-13 08:07:06 +02:00
Arkadiusz Fal
b8921387dd Let the accounts screen own the switcher instead of the settings list
The main settings list carried both an Accounts row and a switcher row
beside it — two account controls at the top level, one of them opening
the screen the other belonged on. The switcher row moves to the top of
the accounts screen as a Current Account section, where the selection
it names sits directly above the checkmarked row it corresponds to.

The subscriptions-settings row and the macOS sidebar footer keep
theirs: those are contextual surfaces, not the settings list.
2026-09-12 23:58:32 +02:00
Arkadiusz Fal
b08185652e Let the switcher refuse the states only the user can fix
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.
2026-09-12 23:55:07 +02:00
Arkadiusz Fal
a04b44e207 Let Home carry the account switcher and follow the selected account
The Home tab showed the same device-local numbers whatever data context
was selected, with no way to switch from there. Now it carries the same
DataContextSwitcherMenu as Subscriptions, History and Playlists, and its
data follows the switch:

- The history card and recent-history section resolve by the same
  HistoryDisplayMode rule as the history screen: an account with
  server-side history gets one probe of it (count up to 500, shown as a
  floor past that) and hydrated leading rows; a failed or still-out
  fetch reads as an em dash, never as the device's numbers under an
  account's name. Local writes no longer refetch the server; a posted
  watch does.
- The playlists card reads the remote playlists cache live in an
  account context, kicking the first fetch when the playlists screen
  never did.
- Context, credential and status changes reload the counts and refill
  the feed section the cache clearing just emptied; the channels-count
  fetch drops its result if the context moved while it was out.

Bookmarks, continue watching and downloads stay device-local on
purpose: they have no server counterpart. The notification
subscriptions moved into a dataObservers helper to keep the body within
the type-checker's budget.
2026-09-12 23:50:15 +02:00
Arkadiusz Fal
839de238d3 Let account labels call the source by its title, not its host
An account with no user-set label was named username@host everywhere it
appears — the switcher menu, the settings rows, the playlist selector —
which overflows a menu row the moment the host has a subdomain. The
default name and the switcher's disambiguation suffixes now use the
instance's title when one is set, falling back to the host as before.
2026-09-12 23:44:35 +02:00
Arkadiusz Fal
ea66d8f303 Let the signed-out feed placeholder say something
subscriptions.notAuthenticated.title/.description had no English values, so the Subscriptions ContentUnavailableView rendered raw with empty text when the selected account lacks authentication. Fill them in wording that matches the account status badges ("Sign in required" / "Sign in again").
2026-09-12 19:36:39 +02:00
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