Round two of review on the accounts layer.
The settings picker mapped its legacy instance-keyed selection straight
onto DataContext(legacy:), which turns an instance UUID into an account
UUID. That identity only holds for migrated accounts - a login created
after the revamp has a random ID - so selecting a freshly added account
named one that does not exist and left the provider nil. Selections now
resolve through AccountsManager to an actual account on that instance,
preferring a signed-in one. The picker still lists one entry per instance
and so cannot show a second login; the Phase 3 switcher replaces it.
BackgroundFeedRefresher was missed in the previous sweep because it
resolves credentials differently: it routed on the legacy setting and
looked up "first enabled Invidious instance" with an instance-keyed
credential. After any new-style login that silently found nothing and
skipped the refresh. It now routes on the selected context and takes the
credential from that account.
Subscription reads used the raw stored context, so an account waiting on
its instance or credential rendered empty instead of falling back to local
data as the tri-state design specifies. Reads go through the validator's
effective context; writes deliberately do not, so a subscribe fails
visibly rather than landing in local data the user did not choose.
Account statuses are cleared when the identity namespace reloads, but
nothing recomputed them once a deferred scope resolution succeeded - a
synced account kept showing "sign in required" until relaunch. The scope
change now triggers a refresh, deferred by one turn so it cannot depend on
observer registration order relative to the store's own reload.
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.
SubscriptionService held one provider per backend and each resolved its
instance from settingsManager.subscriptionAccount, falling back to "the
first enabled Invidious instance" when that was unset. With several logins
possible on one instance that model breaks twice: two accounts would share
a single in-memory subscription cache, and the fallback could send an
account's request to a server it holds no credential for.
Providers are now built per account and cached by account ID, and each
resolves its instance and credential from the account it was built for -
no fallback. The protocol's accountType becomes a DataContext, which is
what actually identifies a provider now.
Views that branched on "is the subscription account local" read the context
instead, and the settings picker writes both stores. Keeping the legacy
setting in step is deliberate for now: several views still read it, and
letting the two diverge would show one account in the UI while serving
another's data. The Phase 3 switcher removes the last readers.
Signing out discards the account's provider so its cached subscriptions
cannot be served after the account is gone.
Yattee 2 could hold at most one login per instance: a Keychain item keyed by
the instance UUID and a Bool on the instance. This adds the account layer
the rest of the revamp builds on.
DataContext (.local or .account) replaces SubscriptionAccount as the thing
that selects a data set. The legacy value maps onto it as an identity
mapping, because migrated accounts deliberately reuse their instance UUID.
Contexts carry an app-managed changedAt, and a migrated one is stamped at
the epoch so any explicit choice on any device outranks it whatever order
they arrive in.
AccountCapabilities makes what a backend supports a single switch in
InstanceType.accountCapabilities, verified against each API surface -
Invidious has history and playlist privacy, Piped has neither. Call sites
ask for capabilities instead of switching on instance type, which is the
seam the planned per-user Yattee Server accounts land on.
AccountsManager owns accounts and the selected context, stored per identity
scope so a switch of Apple Account cannot show or upload the previous one's
list. Legacy single-logins become accounts through a repeatable
reconciliation rather than a one-shot at launch, because instances arrive
over iCloud whenever they arrive; a permanent marker set is what makes
re-running safe, since usesAccountLogin stays true on the instance forever
and would otherwise recreate a deliberately deleted account. A dirty-flag
outbox records what still needs uploading so an offline change survives a
crash. The legacy subscriptionAccount selection is migrated one way,
reading local defaults first and then the raw KVS value - the previous
release published that key to iCloud but never read it back, so on a fresh
install it exists only there.
DataContextValidator replaces SubscriptionAccountValidator, which corrected
any selection it could not immediately verify. That was safe when a context
was just "an instance I am logged into", but with credentials arriving
asynchronously it would discard the user's choice during a slow Keychain
sync. Validity is now tri-state: anything that might still resolve keeps
the selection and falls back to local for reads only, with server mutations
disabled rather than silently retargeted at local data; only an actual
deletion corrects the stored context.
EditSourceView's single log in/out pair becomes a list of accounts with
per-row status, sign-in-again and sign-out-and-remove, plus the explicit
"use saved login from this device" action - the one-tap adoption that
replaced automatic adoption after the Keychain provenance measurement. On
tvOS a credential-less account offers a local sign-in instead, since Apple
never syncs app Keychain items there and the state will not resolve on its
own.
InstanceLoginView now returns the identifier alongside the credential so
the caller can file it in the Keychain beside the secret, keeping login
identifiers out of synced metadata.
Fifth review pass, which prompted measuring whether SecItemDelete can be
conditioned on a value. It cannot: a delete issued with a deliberately
wrong kSecValueData still returns 0 and removes the item, so there is no
compare-and-delete and every check-then-delete is a race by construction.
That measurement decides both fixes here.
Adoption no longer rolls back its own write when the verification read
comes back empty. The rollback could only ever be a blind delete of
whatever occupied the key by then - including a credential synced from
another device in the gap - and it would propagate. Nothing is deleted; no
marker is written, so the account stays signed out and an ordinary sign-in
recovers it.
Legacy deletion re-reads immediately before deleting and aborts if the
value moved, shrinking the window to a single call gap and catching a
replacement that landed while the fingerprint was being computed. A
residual race remains and is documented at the call site: closing it is not
possible with these APIs, and never deleting would leave a credential the
user explicitly removed sitting in their iCloud Keychain.
The debug provenance report repeated the same nil-token ambiguity the scope
logic was fixed for, reporting "no Apple account signed in" when iCloud
Drive is merely off. It now reports the token as unavailable and shows
CloudKit's account status separately, so a re-run cannot draw a false
conclusion from it.
Yattee 2 keys credentials by instance UUID with no notion of which Apple
account they belong to, and reads them back with SynchronizableAny. Apple
documents that Keychain data may stay on device when a user signs out and
chooses to keep it, so whoever signs in next would be served the previous
user's credential - guaranteed for migrated accounts, since local instances
survive the switch and the migrated account UUID is deterministically the
instance UUID.
Credentials are now keyed "<identityScope>|<accountID>", where the scope is
the CloudKit user record (identical across a user's devices, so iCloud
Keychain sync still works). Usernames get the same scope and live beside the
credential, keeping login identifiers out of synced metadata.
The legacy unscoped item is never adopted automatically. A strict
kSecAttrSynchronizable == true query was the candidate discriminator for
"this secret belongs to the current Apple account", but measured on iPadOS
27.0 a retained item keeps the attribute and still matches that query with
no Apple account signed in at all - the flag records what was once requested,
not live iCloud Keychain membership. Reads therefore never fall back to the
legacy key; adoption is an explicit user action that copies the item,
verifies the scoped read-back, then records a marker bound to the identity
and a salted fingerprint of the secret. The legacy item is retained, because
deleting a synchronizable item propagates and would log out devices still on
the previous release mid-rollout. Deleting it later needs all three of: a
marker on this device, matching identity, and the item still holding the
adopted secret.
The instance-keyed path is untouched, so no shipping call site changes
behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth
belongs to the instance, not to a user account.
While the scope is unresolved (first launch under a new Apple account,
CloudKit unreachable) reads report "can't tell yet" via isScopeResolved
rather than "signed out", and writes are refused rather than guessing a
scope. Login state is cached per scope so one identity's cache can never
answer for another.
Also adds the debug-only harness the measurement was taken with, under
Advanced > Developer > Experiments.
The fullscreen button routes through a portrait-video check (toggling
the details panel instead of rotating), but the double-tap gesture
called onToggleFullscreen directly and always rotated to landscape.
Extract the shared decision into PlayerControlsActions.performFullscreenTap()
and use it from both the button and the tap gesture handler.
Timed links arrive wrapped as yattee://open?url=... from the share
extension, but the timestamp was parsed from the wrapper URL, where a
single-? form like youtu.be/ID?t=N hides t inside the url query item.
With no startTime the player fell back to saved watch progress.
- Add URLRouter.unwrapped(_:) resolving the wrapper to the inner URL
(raw remainder after ?url=, since the share extension does not encode
& and URLComponents would drop &t= parts) and use it in handleDeepLink
- Thread forceStartTime through openVideo/playPreferringDownloaded/play
so explicit link timestamps beat the 90%-watched restart threshold,
clamped to just before the end; resume flows keep the old behavior
- Carry the parsed timestamp through OpenLinkSheet's play action, which
dropped it entirely
- Cover timestamp parsing and wrapper unwrapping in NavigationTests
When queue mode was set to repeat one, the "Playing in N seconds"
countdown still appeared after a video ended, showing the next queued
video - then restarting the current one anyway. The video-end path
never consulted the queue mode; only playNext() did, after the
countdown had already run.
- PlayerService.backendDidFinishPlaying: restart immediately in repeat
one mode, regardless of queue contents, player visibility, background,
or PiP. This also fixes repeat one never looping with an empty queue.
- ExpandedPlayerSheet.isAutoPlayEnabled: false in repeat one mode, which
gates all countdown triggers (start on ended, overlay render, re-arm
on appear) on iOS/macOS.
- TVPlayerView.handleVideoEnded: early-return in repeat one mode so
tvOS shows neither the countdown nor the replay controls.
List/grid layout toggle with row size and grid columns options,
persisted per-view. PlaylistRowView now scales with VideoRowStyle;
new LocalPlaylistCardView provides the grid card with edit/delete
context menu.
The remote control loadVideo command only carried the raw video ID and
an instance URL, so extracted videos (Twitch live streams and other
yt-dlp sites) failed on the receiver: it tried to fetch the ID from the
/api/v1/videos endpoint, which rejects non-YouTube IDs.
The command now carries the full ContentSource and title. The receiver
rebuilds the VideoID from the source and opens a placeholder Video -
the player then re-extracts streams and full metadata from the original
URL, same as when opening the video locally. Media-source extractors
(WebDAV/SMB/local) keep using the existing UUID:path branch.
Both fields are optional for protocol compatibility: old receivers
ignore them, commands from old senders keep the legacy behavior.
Also stop sending a start time for live streams - there is no shared
timeline, the receiver joins at the live edge.
The previous fix rebuilt the quality chain when converting persisted
models back to videos, but many consumers discarded it again by
rendering bestThumbnail (usually a maxresdefault.jpg that 404s for
older videos) as a single URL with no fallback.
Extract the retry logic from VideoThumbnailView into FallbackLazyImage
and use it at every in-app site that can iterate: player thumbnails
(mini bar, expanded sheet loaders, autoplay previews, tvOS audio-mode
artwork - previously a silent black screen), video info card and tvOS
header, and the tvOS playlist cover.
Sites that fetch or send exactly one URL are rewritten to the
always-available hqdefault variant via Thumbnail.reliableURL (exposed
as Video.reliableThumbnailURL): Now Playing artwork, Top Shelf
snapshots, remote control state, the frozen transition thumbnail,
blurred info background, navigation covers, and playlist covers
derived from a video's first thumbnail in Invidious/Yattee Server
responses.
Also invert RecentPlaylist's upgrade helper, which rewrote covers *to*
maxresdefault, walk the quality chain when caching download thumbnails
for offline artwork instead of giving up after one 404, and expand the
remaining single-thumbnail Piped conversions into full chains.
With "Partially Watched Videos" set to Ask, opening a live stream showed
the resume sheet with a playback timestamp. A live position is relative
to the live edge, not a fixed timeline, so it is meaningless as a
playhead.
Live views are still recorded in history, but carry no resume position:
- WatchEntry gains a persisted isLive flag plus recordLiveWatch(), which
stores no position and clears state a drifting HLS duration may have
left behind. progress is always 0 and toVideo() propagates the flag so
history rows render the LIVE badge.
- from(video:) clamps duration, keeping Piped's -1 live sentinel out of
storage.
- DataManager routes live saves through recordLiveWatch() and clears the
flag on the non-live path (before updateProgress, so the auto-finish
check is not suppressed by the hard-0 live progress), letting an entry
heal once the stream becomes a VOD.
- PlayerService skips completion saves, always starts at the live edge,
and drops any startTime passed for a live video.
- The resume prompt, the "Continue at" button label, thumbnail progress
bars, "X remaining", Continue Watching and tvOS Top Shelf all exclude
live entries.
- CloudKit syncs isLive with the rest of the watch state: the conflict
resolver carries it with watchedSeconds/isFinished and the sync engine
applies it when merging into an existing entry. It is read leniently,
so the schema version stays at 2 and older clients keep parsing these
records.
Adds a "Load subtitle from file…" row to the Subtitles section on iOS
(document picker) and macOS (open panel), available when playing files
from local folder, SMB, or WebDAV sources. The picked file is copied
into the per-video temp subtitle directory, registered as a selectable
Caption (displayed by filename), and activated through the existing
loadCaption flow. The Subtitles tab and captions control button now
also appear for media-source files without any subtitles.
Media-source streams from WebDAV/SMB were invisible in the Video list:
the filter required remote URLs to carry a resolution, which
MediaFile.toStream never sets. Include non-adaptive remote streams
without resolution (only media sources produce those), extend the
mpv-track display enrichment to the currently playing stream regardless
of URL scheme, and classify metadata-less streams as recommended — a
nil codec previously counted as software-decoded, hiding the row behind
advanced mode with a spurious warning. The warning now uses the
enriched codec.
Streams built for local folder files carry no resolution/codec/fps
metadata (unknowable before demux), so the quality selector labeled
them "Unknown". Use the video track mpv reports (demux-w/h/fps, codec)
to fill those fields into a display-only copy of the stream — the
Video row and summary now show e.g. "720p · 30fps" with a codec badge.
Selection and tap handling keep using the original stream.
Files with multiple embedded tracks (e.g. MKV from media sources or
downloads) previously exposed none of them: mpv's track-list was never
read, aid was never set, and sid was only ever no/auto. The quality
selector showed no Audio or Subtitles tab for such files.
MPVClient now observes track-list/aid/sid and delivers a coalesced,
leniently decoded [MPVTrack] snapshot; selection state is derived from
the reported "selected" flags. Embedded tracks are switched live via
aid/sid (no reload), surfaced in the existing Audio/Subtitles sections
alongside external captions on iOS, tvOS and macOS. External sub-add/
audio-add tracks are filtered out to avoid double-listing. User picks
are sticky across same-video reloads (quality switch, audio mode,
retries) and preferred audio/subtitle languages auto-select a matching
embedded track once per load, with external captions taking precedence.
Text color, border color, background color, border size, and bottom
margin were rendered as static rows on tvOS (ColorPicker and Slider are
unavailable there), so the focus engine skipped over them and they could
never be changed.
Replace them with menu pickers following the existing font size pattern:
- Text/border/background color: 9 preset colors with a swatch in the
row label; stored values snap to the nearest preset
- Background opacity: 25-100% steps (iOS parity, where ColorPicker
supports opacity for the background)
- Border size: 0.0-5.0 in 0.5 steps
- Bottom margin: 0-50% in 5% steps
Verified on tvOS simulator: rows are focusable, selections persist and
are rendered by MPV during playback.
When an Invidious/Piped account is active, several views read the local
SwiftData subscription store instead of the account, so counts
contradicted each other (Home tile said 10 while the Channels list
showed the server's 2):
- Home Channels tile now uses the provider's count via new
SubscriptionService.cachedSubscriptionCount (fetched once in the
background when the server cache isn't populated yet)
- Export footer and export content use the active account's list;
export runs async with a spinner and surfaces fetch errors as a toast
- CSV/OPML import routes through SubscriptionService.importSubscriptions,
subscribing on the server for server accounts instead of silently
writing to the invisible local store
- ChannelView subscribe-state is corrected via the provider after the
optimistic local-store read; unused *Sync write helpers removed
- Server-account subscribe/unsubscribe/import now post
subscriptionsDidChange so other views refresh
- New "Delete Local Subscription Data" section in Subscriptions
settings (visible with a server account) clears the local store and
queues CloudKit deletions so iCloud doesn't restore it
Aspect-driven window sizing (contentAspectRatio + setFrame) ran even while
the separate player window was in native fullscreen, making AppKit fit and
center the content inside the fullscreen space with black bars around it.
Defer the aspect lock and auto-resize while fullscreen by parking the ratio,
then replay it in windowDidExitFullScreen so the window is ratio-locked (and
auto-fitted when that setting is on) once back in windowed mode.
Sideloading tools re-sign the app with a team that cannot register the
iCloud.stream.yattee.app container, and CKContainer(identifier:) fatally
traps when the entitlement is missing. Detect availability by parsing
the embedded provisioning profile, skip creating any CloudKit objects
when the entitlement is absent, and show an explanation in iCloud
settings instead of the sync toggle.
Guard the deinit detach by superview so a container whose render view
was stolen by a newer, not-yet-windowed container cannot rip the shared
view out of it. Narrow the loading-gate heal to .ready only: the old
video still playing during the details/streams fetch can emit .playing,
which would open the gate to stale time updates and break the
stale-error suppression in play()'s catch.
After reinstalling the app and importing sources from iCloud, remote
server instances with Keychain-only credentials showed no indication
that login is needed, unlike WebDAV/SMB sources.
- Show the orange key icon for Yattee Server instances without stored
basic auth credentials (auth is always required for this type)
- Persist usesBasicAuth/usesAccountLogin flags on Instance (synced via
iCloud) so missing Invidious/Piped/PeerTube credentials are detectable
too; maintained on credential save/delete, login/logout, and legacy
migration
- Centralize the check in AppEnvironment.needsCredentials(for:) used by
SourcesListView, MediaSourcesView, and SourceRow
- Backfill the flags at launch from credentials currently in the
Keychain so existing setups publish them without a re-login
Fixes four bugs behind yattee#956 on Apple TV HD (AppleTV5,3, A8,
tvOS 26.5), where video played audio only with a permanently black
picture, never autoplayed, the clock stayed at 0:00, and navigating
during/after playback could freeze the whole app.
1. A8 GL driver deadlock (device-specific). glTexImage2D uploading
float32 data into a float16 texture hangs in libGLImage's
glgProcessPixelsWithProcessor (a dispatch_group_wait that never
returns), permanently wedging mpv_render_context_render. mpv 0.37
(commit 703f1588) turned on dithering (dither-depth=auto) and
LUT-based scalers by default; on the A8 (no GL_EXT_texture_norm16)
both upload float weights via that fatal conversion, and per-frame
CPU plane uploads (videotoolbox-copy / software decode) hit the same
path. Yattee 1.x shipped mpv 0.36 with these off, which is why the
device worked there. Worked around on AppleTV5,3 only: dither-depth=no,
hwdec=videotoolbox (zero-copy via IOSurface), and bilinear scalers.
Confirmed by two on-device lldb backtraces.
2. Swift cooperative-pool starvation (all platforms). MPVClient's event
loop ran as Task.detached and blocked a cooperative-pool thread for
the client's entire lifetime. With two clients alive (active +
pre-warmed) on a 2-core A8, both pool threads were held and every
await in the app starved: the load pipeline froze at its first
Task.sleep, killing autoplay, Now Playing and progress saving. Moved
the event loop to a dedicated Thread; EAGLContext creation now bridges
through GCD instead of Task.detached.
3. Leaked time-update gate (all platforms). PlayerService dropped all
time updates while loadingVideoID was set; when a load task died
mid-flight the flag leaked and the clock stayed at 0:00 during
playback. Heal the gate on the ready/playing transition and leak-proof
play() with a defer; log the previously silent load cancellation.
4. Orphaned player view (all platforms). Detach the shared render view in
MPVContainerView.deinit when no successor container exists, and skip
framebuffer recreation while the view is windowless, to stop a 100% CPU
SwiftUI trait-update loop when opening a settings detail during playback.
On tvOS a Form row is one focusable unit, so the legacy account/source
cards - header, credential fields, and both buttons stacked in a single
row - focused as a whole and every click fired the first button (Remove),
making credentials and Import unreachable.
- Split the tvOS rows into individually focusable rows (info header,
fields, Import, Remove), keeping the card layout on iOS/macOS
- Wrap the tvOS entry points in TVSidebarDetailContainer and drop the
tvOS navigationTitle that rendered as a ghost behind the form
- Present the review screen from the first-launch alert as a
fullScreenCover on tvOS: sheets render as a small transparent card
that cannot fit the sidebar layout (Menu button dismisses the cover)
- Dim disabled TVSettingsButtonStyle buttons
- Widen the row icon frame for tvOS glyph sizes and skip the caption
when it would repeat the host shown as the title
Hide the toggle on macOS, where apps are never suspended and playback
always continues regardless of the setting. Pause playback on iOS when
backgrounding with the setting disabled — the audio background mode
previously kept mpv playing, making the off state a no-op.
Move the continue-playing policy into PlayerService and make the
backend's background render pause unconditional (PiP-gated only):
it is a GPU optimization that should not depend on the setting.
Also ungate the player sheet visibility handlers, which manage
foreground mini player rendering unrelated to background playback.
With auto-resize disabled the separate player window opened at a
computed 16:9 default and was never corrected, so non-16:9 videos sat
letterboxed until a manual resize. Persist the window frame via
NSWindow frame autosave and restore it on open when auto-resize is
off, so the window reopens exactly as the user last left it.
The navigationBarDrawer search field on iPad added a resting scroll
position above the banner on iOS 27, letting the view scroll past the
banner top with the mirrored banner filling the gap. iPhone keeps the
drawer placement since its header reserves space for the search bar.
iOS 27 changes the default scroll edge effect style to hard, which
draws a sharp cutoff line over the channel banner and video info
gradient extending under the toolbar.
Audio-only streams never report a video size, so the aspect ratio of
the last played video stuck to the player area indefinitely once audio
mode was enabled. Reset it in play() when the selected stream is
audio-only, and let the macOS player window fall back to 16:9 instead
of keeping the previous lock.
The content type picker in the center .principal toolbar slot is what
constrains the .searchable field to the trailing edge. Dropping the
picker when a search was submitted let the toolbar reflow and the search
field re-center. Keep the picker in place and disable it while searching
instead.
Rest the quality/settings sheet at a 0.6 fraction instead of .medium on
iOS so the general section (playback speed / audio mode / lock) at the
bottom of the scroll view is reachable without dragging up to .large.
Persistent per-platform mode that plays only the audio track to save
bandwidth and speed up loads. When enabled, stream selection picks the
best audio-only stream (preferred language, codec, bitrate) and never
fetches the video URL; toggling mid-video reloads at the current
position using the quality-switch path (live vid=no toggling is avoided
due to A/V desync).
- Toggle row in the player quality/settings sheet on iOS, macOS and
tvOS, next to lock controls
- New audioMode control button type (red when active) for player
controls and mini player layouts
- Picking a video quality explicitly turns the mode off; audio track
picks keep it on and now work while audio-only is playing
- Stream URL refresh preserves audio-only playback instead of
resurrecting video
- Queue preloads and history entries resolved before a toggle are
discarded so selection re-runs with the current mode
- tvOS player shows the video thumbnail instead of a black screen
during audio-only playback
- PiP button hidden for audio-only streams
Ordering out a window in native fullscreen skips the exit transition and
strands its black fullscreen space on screen. Route all dismissal paths
(stop/collapse, PiP hide, window close) through exitFullScreenIfNeeded,
which leaves fullscreen first and defers hiding until the transition
completes, with a timeout fallback if AppKit refuses the exit.
Replace the centered avatar/name toolbar title with the
About/Videos/Shorts/Streams/Playlists segmented picker. The channel
avatar and name move to the leading edge next to the view options
button, in their own glass capsule (sharedBackgroundVisibility(.hidden)
+ glassEffect on macOS 26). Add spacing between the channel banner and
the content below now that the inline picker is gone.
Live streams have no meaningful duration, so the mini player's bottom
progress line collapsed to nothing (or showed a misleading partial fill
on DVR streams). Hide the line for live playback and show a red dot +
LIVE indicator next to the author name instead, matching the expanded
player controls. Applies to both the macOS capsule and iOS overlay
layouts.
The read-only defaults list grouped tvOS with iOS as ao=audiounit, but
MPVClient actually sets ao=avfoundation,audiounit on tvOS (avfoundation
first because audiounit can't open 32-channel HDMI Atmos routes).
Display-only fix; no playback behavior change.
On macOS 27 beta, mpv's coreaudio AO fails to initialize (channel layout
set rejected with paramErr -50). The existing ao=coreaudio,avfoundation
fallback restored audio, but exposed an mpv bug: init() registers a HAL
hotplug listener (AudioObjectAddPropertyListener on the system object,
with the raw ao pointer as user data) before the step that fails, and
the coreaudio_error path never unregisters it. mpv then frees the ao
and falls back to avfoundation, leaving a dangling listener per
playback start. The next audio device change (AirPods connect,
sleep/wake device republish) invokes the listener with the freed
pointer and crashes with a use-after-free SIGSEGV on the
HALC_ProxyNotification Call Listener Queue (observed in build 262;
faulting address was reused "texture_" shader string memory).
Gate the AO order by OS version: on macOS 27+ use
avfoundation,coreaudio so the failing coreaudio init never runs; older
macOS keeps the native coreaudio first with avfoundation as fallback.
Update the read-only defaults mirror in MPV options settings to match.
The NavigationStack-level background wasn't enough: macOS draws its own
translucent material behind pushed navigation pages, and Form/List
containers draw a translucent scroll background on top of anything
placed behind them, so several pages kept the wallpaper-tinted look.
Bake the opaque background into SettingsFormContainer (covers all pages
built on it, pushed or not) and add opaqueSettingsFormBackground(),
which hides the container's scroll background before applying the
opaque one, to every macOS-reachable Form/List settings page: player
controls and its sub-editors, sidebar settings, edit source, legacy
data import, subscription/playlist import, customize home and its
shortcut style page, and the log viewer. Contributors, translators and
the remote control device page get the plain opaque background.
Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
Views without an explicit background sat on the translucent window
material, which picks up a wallpaper tint (bluish in dark mode) and
clashed with views drawing windowBackgroundColor, making the video
info header gradient fade visibly mismatch. Add opaqueWindowBackground()
and apply it centrally in NavigationDestinationHandlerModifier to both
stack roots and pushed destinations, plus VideoInfoView for
presentations outside navigation stacks.
Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
Download row actions were wired only as swipe actions, and
SwipeActionModifier has #if !os(iOS) overloads that return the view
unmodified, so macOS had no way to pause, resume or cancel a download.
The video context menu could not cover it either, since the .downloads
context deliberately hides the built-in cancel item.
Give active download rows their own context menu with pause when queued
or downloading, resume when paused, retry when failed, and a destructive
cancel. Right-click on macOS, long-press on iOS.
Completed rows had a related bug: the delete item called an onDelete
callback that DownloadsView never passed, so it rendered but did nothing
on macOS. Drop the callback and delete via the download manager directly,
which is what already made the same menu work in HomeView.
Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
Adds an option in Appearance settings to use different accent colors
in light and dark mode, with a toggle to keep a single shared color
(the default, preserving existing behavior).
- New synced settings keys: accentColorDark, customAccentColorDark,
useSeparateDarkAccentColor; dark values fall back to the light
selection until explicitly set, so no migration is needed
- resolvedAccentColor returns a dynamic platform color (UIColor trait
provider / NSColor appearance provider) when the toggle is on, so
the root tint and all direct readers adapt to light/dark
automatically without consumer changes
- Accent Color section shows two stacked preset+custom grids with
Light/Dark headers when enabled, extracted into AccentColorGrid;
CustomAccentColorButton now takes bindings so the macOS color panel
edits whichever target was last opened
- New DarkAccentColorTests covering sync contract, default-off state,
fallbacks, and toggle-off resolution
Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o