When enabled (the default), share links to YouTube videos point to the
active source's web watch page instead of youtube.com. Invidious serves
its frontend at the API URL; Piped uses its configured frontend URL.
Videos the active source can't serve (PeerTube, extracted sources) and
sources without a web frontend keep their original links.
The toggle lives in Settings > Layout & Navigation > Sharing on iOS and
macOS, and is shown only when an enabled source has a web watch page.
The three-dots menu now offers Refresh, an accounts entry (sign in when
nobody is, otherwise a submenu that switches the data context, adds a
login, or signs out the active account with the shared removal
confirmation), and Copy URL / Open in Browser on iOS and macOS.
Login and sign-out reuse InstanceLoginView, AccountSignInHandler, and
the same confirmation strings as the edit sheet's accounts section.
The toolbar (and tvOS inline) button now opens a menu with Edit and
Remove actions for the browsed instance above the existing View Options
entry. Edit opens the shared EditSourceView; Remove confirms with the
same account-deletion disclosure as Settings and pops the view.
Opening a members-only video from a history or feed row got a 500 from
the videos API and left the detail view blank: the retry error view only
covered the videoID init mode, while the details fetch for a loaded
video failed silently.
Record the details-fetch error and route it into the same error view
when the displayed video is a bare placeholder (no author, no
thumbnails), show a spinner while that fetch is in flight, and make
retry re-run both loaders. Videos with real partial metadata keep
rendering as before.
ImportSubscriptionsView wrote every imported channel straight into the
local SwiftData store via dataManager.subscribe(to:), regardless of the
selected account context. Imports now go through
SubscriptionService.subscribe(to:in:) aimed at a destination the user
picks — the local store or any logged-in account that supports
subscriptions, minus the source instance's own account, the same roster
the subscribe menus offer.
The already-subscribed marks are asked per destination through a new
SubscriptionService.subscribedChannelIDs(in:) helper (cold server
destinations fetch their list once, generation-fenced like the other
reads) and reload when the destination changes. Individual adds show a
per-row spinner while the server write is in flight, alreadySubscribed
counts as success, and Add All runs serially with an added/failed tally.
Also drop the "Invidious" wording from the import empty state — the
view serves Piped sources too.
The row's accessibilityLabel was the debug string video.row.<id>, so
VoiceOver read a raw YouTube ID for every row. Build the label from the
DeArrow-aware display title, author name, and duration instead; the
accessibilityIdentifier stays unchanged for UI tests.
A tvOS Label sets its plus glyph nearly against the text at this size;
the row spells the gap out with an HStack there, shared by the local and
remote sections. Other platforms keep the plain Label.
A tvOS menu picker shows only its value, so the collapsed control read
as a bare account name. The menu's label is now the whole labelled row -
'Account' leading, the selection trailing, the same shape as the
settings switcher row - with the choices as a checkmarked picker inside
the menu. Other platforms keep the plain menu picker, which already
carries its label.
The tvOS branch pinned the picker to .inline over a fear that a menu
would dismiss the sheet it sits in — that is true of the hardware Menu
button and a fullScreenCover, not of a menu-style picker in a sheet. The
inline rows meanwhile filled the card with every account before the
first playlist. One .menu picker on every platform, device-verified:
picking another account swaps the section in place.
"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)".
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.
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.
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.
The in-flight gate now remembers which row it closed for, and that row
trades its plus for a small spinner until the checkmark - or the error
toast - takes over. Local adds are instant and keep going straight to
the check.
The tapped row stays on screen for the half second the checkmark shows -
and for a remote add, the whole round-trip - so quick repeated taps each
started another add and the video landed in the playlist several times.
One gate covers both paths; a failed remote add reopens it, since
failing is the one way the sheet stays up to retry from.
A picker at the top - seeded from the selected data context, private to
the sheet from then on - replaces the stacked per-account sections, so
picking where a video goes no longer scrolls past every login and never
switches what the app is browsing as.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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
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.