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
SwiftUI's .preferredColorScheme on the root view could not revert from
dark back to light/system while a sheet was presented, and on macOS it
never applied to secondary window scenes such as Settings. The override
is now applied directly to UIKit/AppKit instead.
- SettingsManager.applyTheme sets overrideUserInterfaceStyle on every
window of every connected scene (iOS) or NSApp.appearance (macOS);
the theme setter calls it, so any future code path that changes the
theme outside the setter must do the same
- AppTheme gains userInterfaceStyle / appearance mappings, with .system
resolving to .unspecified / nil so the OS setting takes over again
- Applied on app appear for the initial launch state, and after both
iCloud sync paths, since theme can arrive as a synced change and is
no longer driven by SwiftUI state
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
Add a Custom swatch to the appearance settings accent color grid,
backed by a hex value stored in settings (synced via iCloud like the
rest). iOS uses the native ColorPicker wheel; macOS uses a circular
swatch that opens NSColorPanel, since the SwiftUI color well looks out
of place among the circles. All accent consumers now read the resolved
color through SettingsManager.resolvedAccentColor.
The indigo preset is retired from the grid but kept in the enum, so
users who selected it keep their color until they pick another one.
Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
Load local-account subscriptions synchronously on appear so the channels
sidebar is present in the first layout pass, and reserve the sidebar
column with a spinner while async loads are in flight (gated by a
persisted flag so users without enough channels never see a phantom
column).
When "Play in a separate window" is disabled, the expanded player was a
native sheet attached to the main window. AppKit refuses fullscreen with
an attached sheet, so pressing F swapped the sheet for an in-window
overlay before fullscreening — a visible two-step transition.
Present the inline player as the full-window overlay from the start, so
fullscreen is just the window's native toggle. This removes the whole
sheet-swap machinery (toggleSheetFullScreen with its detach timing,
SheetWindowResizer, sheet sizing helpers).
- beginInlineOverlay/endInlineOverlay hide/restore the main window
toolbar and mirror the window's fullscreen state into the renamed
isMacInlinePlayerFullScreen flag via NSWindow notifications, so exits
through Esc/green button/Mission Control stay in sync
- Collapsing exits fullscreen only if it was entered for the video
- Esc in the windowed overlay collapses to the mini bar via a new
onCollapse callback (replacing the sheet's default Esc-dismiss)
- ContentView registers its hosting window (MainContentWindowReader) so
the overlay targets the right window even when the setting is toggled
while the Settings window has focus
The Filter/Export/Clear buttons appeared twice on macOS: inline next
to the search bar and again inside the ellipsis toolbar menu. Show
them once as plain toolbar buttons on macOS; keep the menu on iOS/tvOS.
On macOS 15 Sequoia the error overlay buttons (Retry / Play Next /
error details) and the ended-state replay buttons did nothing when
clicked, while working fine on macOS 26. Layered event logging showed
the click reached SwiftUI but always landed on MacOSPlayerControlsView,
which covers the whole video surface above those overlays: on Sequoia a
view with .onContinuousHover participates in click hit-testing across
its full frame, so even with its inner layers hit-disabled the controls
view formed an invisible wall. Drop the whole controls view out of
hit-testing while the ended/failed overlays own the surface.
Also keep the traffic-light buttons visible on the ended/failed
overlays: they used to fade out with the controls and nothing could
bring them back, leaving the error screen with no way to close the
window.
The hide timer was gated on the pointer leaving the player view, which
never happens in fullscreen, so controls shown when pressing F stayed up
forever. Switch to idle-based hiding: mouse movement shows controls and
resets the 3s timer; controls fade after inactivity even while the
pointer rests over the video, unless it's on one of the control bars.
Entering fullscreen rebaselines hover tracking so the resize-driven
hover re-emission doesn't count as movement.
Also hide the mouse pointer (setHiddenUntilMouseMoves) when controls
hide in fullscreen, restoring it on exit or any mouse move.
macOS 27 beta's AUHAL rejects mpv's channel-layout property with paramErr
(-50), so the coreaudio AO fails to initialize and playback is silent.
Change the macOS ao option from "coreaudio" to "coreaudio,avfoundation"
so mpv self-heals: older macOS keeps using coreaudio unchanged, while the
beta drops to avfoundation (AVSampleBufferAudioRenderer) instead of playing
no sound.
AXe dlopens the private SimulatorKit.framework from
$DEVELOPER_DIR/Library/PrivateFrameworks. Xcode 27 relocated that
framework to Contents/SharedFrameworks, so under a selected Xcode 27
every axe command fails with "Failed to load essential private
frameworks" and all UI assertions fail uniformly.
Detect when the active Xcode lacks SimulatorKit at the path AXe expects
and inject DEVELOPER_DIR pointing at the first installed Xcode that has
it, for every axe subprocess. No global xcode-select switch required.
Claude-Session: https://claude.ai/code/session_01Aqq5bSGrYfjQNvkTEzWcva
On macOS the queue sheet's mode selector now shows both icon and text
and shares a single bottom toolbar bar with the Close button, pinned to
opposite edges.
The default SwiftUI Menu style on macOS 15 renders as a bordered
pull-down button that stretches to fill available width, while
macOS 26 hugs the label. Apply .menuStyle(.borderlessButton) and
.fixedSize() to the video context menu and playlist header menu.
In the separate player window the traffic-light buttons stayed visible
while the controls overlay was hidden. Animate the standard window
buttons' alpha in sync with controls visibility, QuickTime-style.
Applies only to the dedicated player window (inline sheet and overlay
fullscreen presentations keep their parent window's chrome). Buttons
are handed back at full alpha when entering native fullscreen, when the
controls unmount (PiP, debug overlay), and when the view re-parents to
another window.
When the pointer left the player window during playback, the auto-hide
timer set the manual showControls override to false. The override takes
precedence over hover state, so re-entering the window kept controls
hidden until a click toggled them back.
Clear a stale hide override on mouse activity so hovering reveals the
controls again, except while the details panel is open, which keeps
controls hidden on purpose.
The controls overlay was gated on the player backend existing, but the
backend is created only after the video details fetch completes, so the
loading phase showed just the thumbnail and spinner with no controls.
Drop the backend gate (MPV is the only backend type) and resolve the
PiP backend dynamically in the toggle callback. Also make ended/failed
states hide controls directly in shouldShowControls instead of relying
on catching the state transition, and reset the manual visibility
override when a new load starts so controls reappear for the next video.
Replace the log-content-with-copy sheet with an NSSavePanel that writes
the exported logs to a .txt file, matching the subscriptions and preset
export flows. ShareSheet presentation is now iOS-only; tvOS export is
unchanged.
The fullscreen button did nothing in sheet mode: AppKit ignores
toggleFullScreen on a window attached as a sheet, and the old fallback
targeted the key window — the sheet itself. Two other strategies were
ruled out first: fullscreening the sheet's parent is silently refused
while any sheet is attached, and resizing the sheet's backing window to
screen size NaN-asserts inside AppKit's sheet positioning even without
animation.
Working approach: swap the sheet for a plain overlay in the main window
and native-fullscreen the now sheet-free main window.
- isMacPlayerFullScreenOverlay on NavigationCoordinator dismisses the
sheet (presentation binding excludes it; the setter ignores the
dismissal write-back) and mounts ExpandedPlayerSheet as a black-backed
overlay in ContentView
- ExpandedPlayerWindowManager fullscreens the parent after the sheet
detaches, verifies AppKit accepted it (rolls back if refused), hides
the window toolbar (NSWindow keeps it as a strip above the overlay
otherwise) and restores it on exit
- a didExitFullScreen observer restores the sheet on any exit path
(button, Esc, green button, Window menu); closing the player while
fullscreen exits the main window's fullscreen too
- scheduleSharedViewAdoptionRetry on entry re-homes the shared render
view once the dismissing sheet's window loses visibility — its steal
is declined mid-dismissal and nothing else retried, leaving the
overlay black until the next video load
The separate macOS player window (and the inline sheet, which locks to the
same window via applyAspectRatioConstraint) could be dragged arbitrarily
small. Two AppKit behaviors combined to defeat the intended minimum:
- Setting contentAspectRatio makes AppKit's aspect-ratio resize handler take
over live drags and stop enforcing minSize/contentMinSize.
- NSHostingController resets minSize/contentMinSize to zero at runtime, even
with sizingOptions = [].
Enforce the floor in windowWillResize(_:to:) from constants instead, growing
any under-sized proposal back up to a 360pt minimum height (aspect-scaled
width, with a floor for narrow videos) while preserving the proposed ratio.
SPUUpdater is a main-thread-only API; calling resetUpdateCycle() from a
detached utility task violated Sparkle's threading contract and raised
a Swift 6 concurrency error (non-Sendable SPUUpdater captured in a
detached task). A plain main-actor Task keeps the deferral that avoids
running the feed-cache work synchronously inside the didSet.
- Appcast publish: fetch gh-pages into a local branch before creating
the worktree. actions/checkout's narrow refspec meant the branch was
checked out detached, so `git push origin gh-pages` failed with
"src refspec gh-pages does not match any" on a fresh runner.
- Gate the AltStore source update on an iOS build being part of the
release; a mac/tvOS-only release has no IPA and would publish a
broken entry.
- Pass the release tag to update-altstore explicitly (gh's "latest
release" excludes prereleases), parse the build number correctly for
beta tags (2.0.0-beta.263 -> 263, not beta.263), use the real
Yattee-<version>-iOS.ipa asset name, and fail instead of writing a
size-0 entry with a dead URL when the IPA asset is missing.
Previously CKSyncEngine was created with nil state serialization on
every launch, replaying the entire zone change history (hundreds of
merge/delete log lines and re-downloads each start). Now the saved
state is loaded so launches only fetch changes since the last session.
To make incremental sync safe:
- Clear persisted state once when the record schema version increases,
so records previously skipped as unsupported get re-delivered after
an app update.
- Handle remote zone deletion in fetchedDatabaseChanges by recreating
the zone and re-uploading local data, instead of leaving sync dead.
Account change and manual Refresh Sync still clear the state and force
a full fetch as before.
Sheet mode has no container lifecycle hook on either transition, so the
shared render view was stuck until the render watchdog (~10s of black):
- Presenting the sheet: the capsule parks the view while the sheet's
window exists but is not yet visible - viewDidMoveToWindow has already
fired and findTransferTarget rejects non-visible windows, so nothing
picked the parked view up.
- Dismissing the sheet: the dismissed sheet's hierarchy stays alive
holding the view inside a now-invisible window, with no unmount or
park at all.
Add scheduleSharedViewAdoptionRetry() to MPVContainerNSView: for ~1s it
re-checks every 50ms whether the shared view needs re-homing (parked,
or owned by a container whose window lost visibility) and runs the same
recovery the watchdog uses. Deduplicated so overlapping triggers don't
stack loops. Triggered after parking with no transfer target and from
hide()'s no-window branch, which every sheet-mode collapse funnels
through.
The retry ticks pass quiet: true to recoverSharedPlayerViewIfNeeded so
teardown after closing a video (when no container legitimately exists)
no longer spams warnings, and the loop stops once the shared view is
gone.
The capsule's claim on the shared render view is declined during the
player window's fade-out (the window is still visible, so the steal
guard refuses), and nothing retried after orderOut - the capsule stayed
black until the render watchdog recovered the view ~10 seconds later.
Call recoverSharedPlayerViewIfNeeded() in hide()'s cleanup right after
ordering the window out, so the view moves to the capsule as soon as
the collapse finishes. The PiP path is untouched - there the view must
stay in the hidden window.
Closing the player window via its close button left isPlayerCollapsing
stuck true (windowShouldClose has no animation completion to clear it),
so the mini capsule preview kept mounting its render container and
grabbed the shared render view on the next expand - the video rendered
into the 44x26 thumbnail while the player window stayed black.
- Clear isPlayerCollapsing on the next runloop in windowShouldClose
- Give containers in the tracked player window priority to claim the
shared view (even from a visible owner), and refuse steals from the
visible player window by outside containers
- Prefer the largest container in the player window during recovery so
thumbnail-sized surfaces are never picked
- Add a watchdog misplaced-view check in MPVBackend that re-attaches
the render view when it lives outside the visible player window
- Include container bounds and tracked-window flag in attach/decline
logs
In separate-window mode, hide() keeps the ordered-out window's SwiftUI
hierarchy alive (nilling contentViewController crashes AVKit), and its
updateNSView unconditionally re-attached the shared MPVOGLView -
stealing it into a window CoreAnimation never composites. The layer's
silent skip-render fallback then consumed every frame flag, so video
stayed black (climbing vo drops) until app restart.
- Decline stealing the shared view from a visible container into a
non-visible one; owner-initiated transfers bypass the guard
- Only transfer to containers in visible windows or the window still
tracked by ExpandedPlayerWindowManager; park the view otherwise and
reclaim it when a container gains a window
- Log skip-render frame consumption (rate-limited) and track render
health counters (draws/skips/dropped draws)
- Add a watchdog to the playback stats task that detects a stalled
video output and re-attaches the view to a visible container
- Don't mount player layouts during zero-size layout passes
The glass controls capsule can now be dragged anywhere over the video.
Position is stored as a fraction of the container size so it survives
window resizes and aspect-ratio driven sheet resizes, persists across
sessions via SettingsManager, and snaps back magnetically when dropped
near the default bottom-center spot. Travel is clamped so the capsule
stays inside the player and never overlaps the top button row. A
mouseDownCanMoveWindow=false backing view keeps the window's
movable-by-background behavior from swallowing the drag.
Add a queue-count badge to the macOS player controls queue button
(MacOSControlsSectionRenderer) and the shared mini player queue button
(MiniPlayerView), matching the iOS pill button behavior.
The storyboard seek preview was anchored to the top of the control-bar
pill and floated well above it. Anchor the preview and chapter capsule up
from the pill bottom (via geometry height) so they overlap the button row
and sit just above the progress bar, matching iOS proximity.
- Add macOS-specific entitlements via CODE_SIGN_ENTITLEMENTS[sdk=macosx*]:
the iOS aps-environment key is silently dropped from macOS exports,
leaving distribution builds without a push entitlement (no CloudKit
sync pushes). Release now uses Yattee-macOS.entitlements with
com.apple.developer.aps-environment = production.
- Release-DeveloperID uses Yattee-macOS-DeveloperID.entitlements, adding
the Sparkle mach-lookup temporary exceptions (-spks/-spki) required
for the updater to install in a sandboxed app.
- Default Sparkle updater to the beta channel while only beta releases
exist, so testers receive updates without toggling Advanced settings.
- Set NSHumanReadableCopyright for the app target.
Playback menu seek items now follow the durations from the active player
controls preset instead of hardcoded 10s/30s labels. AppEnvironment caches
the active preset's center settings (refreshed on preset change
notifications) so menu commands can read them synchronously.
Add secondary seek durations (default 30s) to CenterSectionSettings,
configurable on macOS in Seek Durations settings, used by Shift+arrows in
the player and Cmd+Shift+arrows in the Playback menu.
Also let Cmd-based key equivalents pass through the player keyboard
monitor so menu shortcuts (e.g. Cmd+Option+arrow previous/next video)
work while the player window has focus, and unify macOS built-in preset
primary seek at 10s to match iOS/tvOS (built-in presets version 8).
Queue items carry a pre-resolved stream, so play() takes the
provided-stream shortcut and skips the full API fetch that populates
storyboards. The follow-up loadOnlineStreams() call fetched the response
containing them but dropped storyboards on the floor.
Keep the storyboards in loadOnlineStreams() and apply them when none are
set (preserving local storyboards for downloaded playback), and bail out
if the current video changed during the fetch.
The PiP restore handler only expanded the player when mini player
video was disabled, which is the iOS behavior where playback can
continue in the mini player. On macOS the mini player is not a
restore target, so always reopen the player window; iOS keeps the
existing logic.
MPVBackend.stop() clears the PiP bridge callbacks on macOS to prevent
crashes during window close, but the backend is reused across videos
and setupPiPIfNeeded is guarded by isPiPSetUp, so the callbacks were
never wired again. The next PiP session then started without notifying
PlayerService (no onPiPStatusChanged), leaving the player window
visible alongside the PiP window, both playing.
Extract the callback wiring into wirePiPBridgeCallbacks() and re-run it
in startPiP() and in setupPiPIfNeeded when already set up.
Switching videos during active PiP left the window at the old video's
size with white space around the new content, for two reasons:
- MPV reports the new video's width and height as separate property
events while the old values are kept across switches, so a transient
mixed size (new width with old height) could reach the PiP capture
path and AVKit sized the window from that frame. Coalesce the PiP
capture-size/aspect update with a short debounce so only settled
dimensions are used.
- On macOS nothing told AVKit about the new aspect: unlike iOS, the
bridge never flushed the sample buffer renderer on an aspect change
during active PiP, and AVKit doesn't resize the macOS PiP window on
its own. Flush the renderer, reset the format description, and
resize the PiP window to the new aspect ratio explicitly.
The PiP-active guard that skips expanding the player when opening a new
video was compiled iOS-only, so on macOS a browser deep link always
re-showed the hidden player window instead of playing the new video in
the existing PiP window. Extend the guard in both openVideo overloads
and the Handoff path to macOS.
Since 1d9c5e3b performRender feeds swap timing to mpv via
mpv_render_context_report_swap, but only after presentRenderbuffer -
which is skipped while PiP is active. Once swap reports stop mid-stream,
display-vdrop's vsync estimation goes stale and mpv drops nearly every
frame (vo drop counter climbing ~19/s on a 24fps stream), turning PiP
into a slideshow.
The frame is genuinely displayed during PiP via the
AVSampleBufferDisplayLayer, so report the swap on that path too.
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.
Tapping a recent search (or suggestion, or opening a query deep link) mutates
the searchable text, which fires the onChange handler that clears results and
re-fetches suggestions. That raced against the search Task started by the same
tap: on Sequoia the onChange settled last, cancelling the in-flight search and
leaving suggestions visible instead of results.
Suppress the onChange-driven suggestion fetch for the single programmatic text
mutation via a one-shot flag, so the execute paths go straight to results
regardless of onChange-vs-Task ordering. Manual typing is unaffected.