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.
Error details were declared in the video info view and the expanded
player, and Open Link three times: at the app root for deep links and
for the File menu command, and on Home. All of them are now AppSheets;
the deep link and menu requests go through the global request, so a
deep link that arrives while the player is expanded no longer opens the
sheet behind it.
The four sign-in modals that were a full-screen cover on tvOS and a
sheet elsewhere, each written out twice, use a platformSheet helper.
tvOS full screen covers are transparent, so each one filled itself with
a hardcoded black — invisible black-on-black text under the light color
scheme (e.g. the View All Comments page). A shared
tvFullScreenCoverBackground() modifier now picks black or light gray
from the color scheme; applied to the comments cover, subscription
destination picker, instance login, edit source, iCloud progress, and
legacy accounts import covers.
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.
Three things live testing found, all of them where a playlist list or a
playlist is empty.
The picker said "This playlist has no videos" when there were no
playlists to pick — copy about a playlist's contents standing in for
copy about the list of them. Each section now says it has none, scoped
to where it is: the device, or that account. An account whose fetch came
back empty said nothing at all and left a bare gap.
The browse view's Playlists tab fetched into its own state, so a
playlist created through the picker — which goes through the shared
cache — never appeared, and nothing refetched on coming back to the tab.
It reads the cache now, and selecting the tab always refetches with the
rows already there kept on screen. The cache is keyed by account, which
is the discard on an account change the private copy needed a generation
for.
Removing the last video from a playlist took away the whole action row,
so an owned server-side playlist could no longer be renamed or deleted
from the app at all. The menu stays; only what needs videos to act on —
play, download, save to library — goes.
Ten review findings in the remote-playlist work, all of them the same
shape: something acted on what it remembered instead of what the server
says now.
- A confirmed-gone playlist is forgotten whole — snapshot, list entry,
and both ordering fences — so a refresh that started before the delete
can no longer republish it, and a list refetch that fails offline no
longer leaves it on screen.
- `.accountsDidChange` discards account-scoped content when the account
it belonged to is no longer the effective one, as the status
notification already did.
- A tab that stops being offered no longer stays selected: the browse
view moves to the first available one, which reloads by itself.
- A Handoff account id is accepted only when that account is on the
instance the playlist lives on.
- Composite writes moved up into `AccountService`, over a transport whose
every method is exactly one request: create-with-privacy, rename, and
set-description. The credential is rechecked between each pair, so a
sign-out between the two halves of a create or an edit can no longer
send the second half under the previous user's secret.
- Editing a playlist no longer forces it private. Invidious serialises
visibility as `isListed` — `privacy.public?`, so unlisted and private
are indistinguishable — which the snapshot now carries; the picker
starts there, and sends nothing at all unless it was moved. Invidious
keeps the stored visibility when the PATCH omits it (measured), so an
untouched picker leaves it exactly as it was.
- Tapping Edit on a server-side playlist awaits an authenticated refetch
first and populates the form from it; a failed refetch shows an error
and no sheet.
- Playlist rows are keyed by row rather than by video id, which two rows
of the same video share.
- The add-to-playlist sheet offers only accounts whose instance is
enabled and that hold a credential, alongside the write capability.
Remote playlist entries carried the display row number as their server
position, so a row the app could not parse shifted every removal after it
by one, and rows were paired back to entries by video id, which cannot
tell two copies of the same video apart.
- Piped entry positions are now the raw stream index; entries carry a
separate displayRow for the row they are drawn as, filled in by both
snapshot builders and used by the detail view's row map
- an emptied description clears a server-side playlist instead of being
read as "leave it alone"
- a delete that reported failure forgets a playlist the refetch 404s on,
and drops it from the account's cached list
- the browse view discards account-scoped content when a status change
moves which account the instance resolves to
- every mutation re-reads the credential immediately before it is sent,
which matters most after Piped's verify-before-remove fetch
- Handoff carries the owning account id, so a Piped playlist keeps its
ownership on the receiving device
A handful of call sites built their own InvidiousAPI or PipedAPI over the
shared HTTP client, which quietly opted them out of everything an instance
may require of a request: the reverse-proxy basic auth, the certificate
policy, and the User-Agent some instances whitelist and answer 401 without.
The feed on an instance behind a proxy could load in one place and fail in
another for no reason the user could see. They now all go through the
service that already configures a client per instance.
Login was the site that had noticed. It reproduced the basic-auth handling
itself, including the header Invidious' login needs passed explicitly
because that POST runs on its own session to read the SID out of a cookie.
That logic moves into the service verbatim rather than being dropped: the
view now asks for a credential and is told nothing about how it was
obtained.
Paging is the one thing the service could not already express. Invidious
feeds have pages and Piped feeds do not, so the paged variant says so in
its return value instead of leaving each caller to know.
The browse view's subscriptions arrive as channels now rather than as raw
Invidious rows, so the avatar enrichment works over channels — a fetched
or cached avatar still wins over whatever the listing carried, which is
the precedence the conversion applied before.
The app could show a server-side playlist and nothing else. Editing was
never the missing part on its own: nothing on screen knew whose playlist
it was looking at, and without an owner there is no credential to fetch it
with, no row identity to remove by, and nobody to send a change to.
So the destination carries the account. Only Invidious ids hint at
ownership — the IVPL prefix — and that heuristic stays for links that
arrive from elsewhere; a Piped playlist looks exactly like any other, so
the places that already know whose list a row came out of now say so. An
owned playlist is then fetched authenticated, which is both the only way
to see a private one and the only way to learn what the server calls its
rows. A playlist read from the public endpoint has no row identities at
all, and that is precisely why nothing about it may be changed: the edit
affordances appear over the authenticated snapshot or not at all.
Rows are paired with entries by walking the two lists together rather
than by index. Entries are a subsequence — a row the server declined to
name is still displayed, it just can never be a target — and matching on
the video alone would be wrong too, since a playlist may hold the same
video twice and each copy is its own row.
Every change goes through the cache, so the refetch that reconciles it
cannot be skipped, and what the view shows afterwards is only ever what
came back. That includes the failures: a removal aimed at a row that has
since moved says so specifically, over a list that has already been
refreshed.
Adding a video now offers every account that can be written to, on every
instance rather than the selected one alone — this is not a browsing
decision. Those rows never claim the video is already there: the list
fetch does not carry a playlist's videos, so membership is unknowable
from here, and a duplicate is the server's business. Capabilities decide
throughout, never the instance type, which is what finally lets a Piped
login list and edit its playlists in the browse tab beside an Invidious
one — and what keeps the visibility picker away from a backend that has
no such setting.
Handoff leaves the account out of its payload deliberately. It assembles
its userInfo key by key, so the added value changes nothing about the
coding either way, but an account is only usable on a device holding its
credential — the receiving device resolves ownership for itself.
Pagination carried the login it was requested for, but the load that
fills the tab in the first place did not. Its subscriptions, feed and
playlist requests all resolve well after the credential behind them can
have been replaced, and cancelling the previous task does not settle it:
a request that already came back races the cancellation and installs the
previous person's private videos over the purge that was meant to remove
them. Popular and trending stay unfenced — they are unauthenticated, and
discarding them on a change of login would cost a fetch for nothing.
Discarding the account-scoped content now also forgets how far the feed
had been read. The high-water mark belongs to the feed that was thrown
away; left behind, it gates pagination against a page the next login
never loaded, and that login cannot ask for a second page until its
first exceeds a count it knows nothing about.
A home-feed disk load that was already suspended when the context or
Apple account moved no longer installs what it brings back: the
in-memory cache is empty by then, so the purge finds nothing to drop and
the previous login's private feeds would quietly reappear.
Instance-browser pagination carries the login it was requested for, so a
page still in flight cannot be appended to the next account's feed, and
the pagination state is reset with the content it belongs to.
The stateless-server refresh stops before touching anything when its
result is stale, rather than only rejecting the videos and going on to
set loading state and start polling for subscriptions nobody is showing.
Signing in re-resolves its target first. The round trip leaves room for
another device to delete the account, and the credential would have been
filed under one that no longer exists — reported as success. A
provisional account is rolled back when its backend cannot be resolved
at all, which is what a vanished instance looks like.
Signing in again as a different person on the same server changes
neither the account ID, the context, nor usually the status — so nothing
the views observe moved, and the previous user's subscriptions and feed
stayed on screen while actions went out under the new credential. There
is now a notification for exactly that, and the subscriptions view and
the instance browser discard and reload on it.
An account deleted after its instance disappeared retires its legacy
Keychain item too. Identifying that item needs the instance, but
checking it does not: the adoption marker is specific to service,
account, scope and the exact secret, so asking both managers retires it
if either owns it and does nothing otherwise.
A feed failure recorded its verdict before checking whether anyone was
still waiting for it, and the verdict resolves the *currently* selected
account — so a 401 from a credential the app had already moved off could
mark its replacement, or a different account, as rejected indefinitely.
The instance browser drops its feed, subscriptions and playlists when
the selected login changes. Nothing else there would notice: the
instance, the tab and the "already has data" shortcut are all unchanged
between two accounts on the same server.
Caches are no longer discarded while the identity is unresolved. With
iCloud Drive off the scope drops to unresolved on every activation and
comes back the same, and the context reads as local in between — which
looked like a move and threw the caches away several times a day.
Local is always in the account picker again. Its feed needs a Yattee
Server and reports that itself; with correction no longer moving away
from local, leaving it out of the list made it unreachable once left.
And the subscriptions view reloads when an instance is switched off,
which moves reads to local without touching the context or any status.
The credential digests are persisted. A login replaced on another device
while this one was not running left the Apple account, the context and
the status all unchanged, so nothing noticed on the next launch that the
cached feed belonged to whoever was signed in before.
Deleting an instance is remembered too. Its accounts may still be in
transit — CloudKit and the instance list travel separately — and one
arriving afterwards was installed as a permanent orphan, credential and
account record intact, with no screen yet that could remove it. Late
arrivals are now deleted like the rest.
The source editor and the instance browser recompute their signed-in
state when a status changes, not only when the account list does: a
credential arriving over iCloud Keychain moves nothing else, and the
authenticated imports and the Feed tab stayed hidden until the view was
rebuilt.
Five findings against the accounts layer, all real.
A fresh login created a random-UUID account and then flipped
usesAccountLogin, which posts instancesDidChange and re-ran legacy
reconciliation immediately. That guard only looked for an account whose ID
equalled the instance ID, so it synthesized a second, credential-less
account alongside the real one. Signing in now marks the instance migrated,
and reconciliation treats any account on an instance as evidence its login
is already represented.
save() set hasStoredContext, so the save inside reconcileLegacyAccounts
marked the still-default .local context as explicitly chosen and the
legacy-context migration that ran immediately after did nothing. Upgrading
users with a selected server account silently lost it. Only setContext,
applySyncedContext and the migration itself record a choice now.
An Apple Account change re-resolved the credential scope but never told
AccountsManager to reload its identity-namespaced storage - the only reload
was a one-time launch task - so the previous identity's accounts, context
and outbox stayed live under the new one, as did cached per-account
subscription providers. The scope now posts when its resolved value
changes; the accounts store reloads and the provider cache is dropped.
Fourteen call sites still read the pre-revamp instance-keyed Keychain item,
which a new login no longer writes: reopening the source screen showed the
account as signed out, and feed, browse, home and import operations treated
it as unauthenticated. They now resolve the credential through the account
layer, preferring the selected context's account and otherwise any
signed-in account on that instance.
Signing in while the identity scope was still unresolved created the
account in a temporary namespace that reloadForCurrentIdentity later
discards, while credential storage silently refused the write and the UI
reported success. Login is gated on a resolved scope and verifies the
credential actually stored before reporting success, rolling back a
newly-created account if it did not.
The content type segmented picker rendered as a Liquid-Glass pill instead
of a native macOS segmented control. Wrapping a `.segmented` Picker in
`.opacity`/`.disabled`/`.accessibilityHidden`/`.fixedSize` (used to keep
toolbar items mounted-but-invisible for stable layout) inserts a hosting
layer that makes the toolbar fall back to the pill rendering.
- SearchView: filters button and content type picker are now always
visible on macOS, rendered bare so `.segmented` keeps native styling.
- SearchView: don't reset the content type to .video on submit on macOS,
so a pre-selected type (e.g. Playlists) is preserved (iOS unchanged).
- InstanceBrowseView: same pill fix, rendering the tab / content type
pickers bare via conditional `if` instead of opacity-masking.
- ViewOptionsSheet / MediaBrowserViewOptionsSheet: use `.formStyle(.grouped)`
for native macOS form styling in the options popover.
Place the view options control on the leading (`.navigation`) edge on
macOS so it is consistently the first/leftmost toolbar button across all
views, instead of sitting on the right next to the search field in
search views and on the left in non-search views.
Each affected view gains a computed `viewOptionsPlacement` (`.navigation`
on macOS, `.primaryAction` elsewhere) so iOS/iPadOS/tvOS keep the
button trailing. Search-style views (Search, InstanceBrowse) declare the
view options item first so it renders left of the search-filters button;
their flexible spacer is kept to right-pin the search field. Channel's
fixed spacer between view options and the channel menu is now iOS-only.
The Popular/Trending tab picker now lives in the toolbar (principal
placement) instead of inline above the content. Once a search is
submitted it swaps to the search content type picker, with the search
filters button alongside it as a popover, matching the global Search
view. Items stay mounted invisible on macOS 26 so the toolbar layout
stays stable while typing.
Match the global Search view by pinning the searchable field and
trailing buttons to the right edge on macOS 26 via a flexible
ToolbarSpacer at .primaryAction. Applied to History, Bookmarks,
Manage Channels, Downloads, Instance Browse, and PeerTube Explore.
In History, the clear-history item moves from .automatic to
.primaryAction so it groups with the other trailing controls.
Plain-Form sheets rendered as columns-style content floating inside
oversized fixed frames on macOS. Anchor these panels to their toolbar
buttons as popovers instead, sized to content; iOS/tvOS keep sheets.
- Search and PeerTube explore filters apply live (Cancel never
reverted anything anyway) and dismiss on outside click
- Subscriptions/Manage Channels "Subscriptions Data" opens as a sheet
from the popover's onDisappear via a pending flag, since presenting
while the popover dismisses gets swallowed
- Media browser view options drop navigation chrome on macOS
The horizontal channel chip strip looks awkward on tvOS and the focus
interaction is clunky. Drop it from the tvOS branch of InstanceBrowseView;
other platforms keep it.
Match iOS behavior by gating the type switcher and filters menus on an
active query, and drop the .caption font so they render with the same
default button font as View Options.
Reorganize the tvOS search UI into a single header row with search
field, type filter menu, combined filters menu (sort/date/duration
with reset), and view options button. Removes the separate filter
strip between search and results on tvOS.
Unifies the filter strip on tvOS so all filters (sort, date, duration,
content type) use the same inline Menu style instead of mixing menus
with a segmented picker.
The filters sheet is too small and awkward on tvOS. Replace the filter
button with inline Menu pickers for Sort By, Upload Date, and Duration
directly in the filter strip. Applied to both SearchView and
InstanceBrowseView.
Renames YatteeServerCredentialsManager → BasicAuthCredentialsManager so the
same Keychain-backed username/password storage can be reused for any instance
type that sits behind a reverse proxy requiring HTTP Basic Auth. Adds a
one-time migration that moves existing items from the legacy
'com.yattee.yatteeserver' Keychain service to 'com.yattee.basicauth',
preserving the iCloud-sync attribute. No behavior change for end users.
On tvOS, ContentUnavailableView inside a Group doesn't expand to fill
available space — it sizes to content and aligns top-leading. Add
.frame(maxWidth: .infinity, maxHeight: .infinity) to all instances
so they center correctly in their parent containers.
The filter strip was passing the Invidious instance URL as serverURL to
AvatarURLBuilder, which built a Yattee Server-style /avatar/ path that
doesn't exist on Invidious. Now passes the actual Yattee Server URL
(matching SubscriptionsView pattern) and enriches channels from
CachedChannelData as a fallback when the API doesn't return thumbnails.
When a cancelled load task fell through to `isLoading = false`, it
created a 1-frame gap where the empty view rendered before the
replacement task set `isLoading` back to `true`. Return early on
cancellation so the surviving task controls loading state.
Move .refreshable from the outer GeometryReader onto the ScrollView
itself so SwiftUI can properly coordinate the scroll offset bounce-back.
The ScrollView was inside an .overlay() which doesn't participate in
the parent's layout system, breaking the offset reset.
Closes#917