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 expanded player window hidden for PiP is reused on restore, so its
MPVContainerNSView kept a stale weak reference to the shared render view
that the mini player preview had taken during PiP. On restore the mini
preview released the view as an orphan and the reused container skipped
re-attaching it because its guard only compared view identity.
- Skip re-attach in setPlayerView only when the view is still actually
our subview, matching the iOS container's stolen-view handling
- Transfer the shared render view to another living container on unmount
instead of orphaning it, since SwiftUI is not guaranteed to re-run
updateNSView on a hidden window's content after restore
- Force a repaint when restoring the window hidden for PiP; forced draws
requested while ordered out are dropped and the layer is pull-based
With Atmos "Continuous Audio Output" enabled, some HDMI routes report
32 output channels, which mpv's audiounit AO cannot open - playback
stays silent while other (AVPlayer-based) apps work fine.
Update MPVKit to 0.41.0-n8.1.2, which builds mpv's avfoundation AO
(AVSampleBufferAudioRenderer, previously macOS-only) for iOS/tvOS
(mpvkit/MPVKit#73), and prefer it on tvOS via ao=avfoundation,audiounit
so mpv still falls back to audiounit if it fails to initialize. iOS and
macOS audio outputs are unchanged.
The MPVKit pin must be exactVersion: SwiftPM sorts the pre-release
identifier below 0.41.0, so a range starting at 0.41.0 never resolves
to it. Note this tag also bumps FFmpeg from 8.0 to n8.1.1.
1d9c5e3b removed the main-thread EAGLContext bind from setupAsync so
the context is never left current on two threads. But libmpv's
mpv_render_context_create requires a current GL context on the calling
thread (it probes glGetString(GL_VERSION)), so render context creation
failed with MPV_ERROR_UNSUPPORTED (-18) and every video played with no
--vo ("No render context set") on real iOS/tvOS devices.
Bind the context around the createRenderContext call and unbind right
after. The bind is scoped to setup, before the display link starts and
before any frame is rendered, so the never-left-current invariant that
protects the A10X fence fix (#947/#949) still holds.
Verified on Apple TV 4K (3rd gen): createRenderContext succeeds and
video frames render.
Claude-Session: https://claude.ai/code/session_01UEXP5f6F4S1zLdY8fdVuLJ
The macOS CAOpenGLLayer is pull-based: after the shared render view is
re-parented into a reopened player window, a single forced repaint is
issued. draw() consumed the forceDraw flag before validating the
viewport, so when that repaint landed while the layer was still 0x0
(the expanded window builds its content a runloop late), the request
was swallowed. With mpv paused during load, nothing repainted again,
leaving the video permanently black with working audio.
- Validate viewport/framebuffer in draw() before consuming flags;
on failure keep the forced draw armed and retry (bounded, 5x50ms)
- Repaint from setFrameSize on the first non-zero layout while a
forced draw is still pending
- Drop the stale fbo=1 fallback; render only into the validated
live framebuffer binding
- clearToBlack: take the CGL lock and skip glClear when no
framebuffer is bound - clearing FBO 0 on this core-profile context
latched GL_INVALID_FRAMEBUFFER_OPERATION, which libmpv reported on
every load
- Add diagnostics for dropped draws and the first-frame render
Apple TV 4K 1st gen on tvOS 26 crashes to the home screen with a
"blocked by IOFence" gpuEvent at playback start (#949) and stutters
with a starved demuxer cache (#947). Defensive fixes in the shared
iOS/tvOS OpenGL path:
- Drain in-flight GPU work (glFinish) before destroying the
renderbuffer, so a pending presentRenderbuffer fence can't hold the
CAEAGLLayer IOSurface being torn down or reallocated
- Never leave the EAGLContext current on the main thread: framebuffer
create/destroy and the background glFinish now unbind on exit, and
setup no longer binds the context on main at all
- Disable retained backing on tvOS; it exists only for iOS PiP frame
capture and adds per-frame IOSurface fence pressure
- Report swaps to mpv after each present so display-vdrop (the tvOS
default video-sync) gets swap-timing feedback, matching macOS
Add verbose-gated diagnostics for remote triage: playback stats every
10s (cache fill/rate, dropped frames, hwdec, avsync) and a rate-limited
slow-frame warning in performRender.
Addresses #949 and #947
The player video surface is a single shared MPVOGLView that is re-parented
between the mini-bar container (main window) and the expanded-player sheet
(a separate sheet window). When presenting the sheet, the new container
attaches the shared view before the old mini-bar container tears down, so
the old container's viewWillMove(toSuperview: nil) was ripping the shared
view out of its new home via removeFromSuperview() — leaving it with no
window and a black surface. Symmetric, so it also blanked the mini-bar
preview after collapsing.
Only detach the shared view if it still belongs to the container
(superview === self). Also force a repaint on window reattach so a paused
frame redraws, and extend the onAppear resumeRendering to macOS.
Drop the dashEnabled toggle and its plumbing. Stream auto-selection now
keeps DASH as a candidate but ranks it strictly below progressive and
HLS formats, so DASH is only chosen when it is the only format available
(including live-stream fallback after HLS). The manual quality selector
hides DASH entirely on iOS/tvOS/macOS. Reconciles BackendSwitcher, which
previously treated HLS/DASH equally, to use the same DASH-last ranking.
The Test Connection buttons only probed API/server reachability, which
was misleading: a green result said nothing about whether videos would
actually play. Remove both the remote-server and WebDAV bandwidth test
buttons and all code exclusive to them (testBandwidth, BandwidthTestResult,
and orphaned localization keys). Add-time connectivity validation for
SMB/WebDAV sources is retained.
Legacy v1 instances without an account had no import path after the
switch to explicit account review. Surface them in a separate "Sources"
section of the legacy import view where they can be added with a single
tap (no sign-in) or removed, mirroring the accounts flow. Instances tied
to an account stay in the Accounts section and are not duplicated.
Detection now gates the entry points and first-launch prompt on any
legacy data (accounts or sources), not accounts alone.
The manual account-import flow made the old silent instance-import
pipeline unreachable. Drop the orphaned parsing, reachability checks,
batch import, result/error types, and the old import row view, plus the
httpClient dependency they were the only consumer of. Also remove the
LegacyAccount.password field, which was parsed but never read.
MPVOpenGLLayer called fatalError() when CGLChoosePixelFormat failed for
both the 10-bit and 8-bit (kCGLPFAAccelerated) formats, killing the app at
launch on machines without a usable GPU such as virtual machines.
Add a non-accelerated software pixel format as a last resort and make the
whole OpenGL init path failable: createPixelFormat/createContext return nil
(with an error log) instead of aborting, MPVOpenGLLayer.init becomes init?,
and MPVOGLView holds an optional layer, throwing openGLSetupFailed from
setup(with:) when OpenGL is unavailable. That error is already handled by
MPVBackend.setupMPVAsync, so the app now launches cleanly and only logs that
video playback is unavailable.
Surface audio-delay (±10/±100 ms) and video-sync-mode controls behind an
"A/V Sync" navigation row in Advanced settings rather than inline, keeping the
Advanced page uncluttered. Audio delay applies live to the running MPV
instance; sync mode takes effect on next playback.
CloudKitRecordMapper was a standalone actor, so 'await recordMapper.toCKRecord'
from the @MainActor sync engine hopped off the main actor and read live
SwiftData @Model properties (e.g. LocalPlaylistItem.authorName) from the wrong
executor. SwiftData models are bound to the main ModelContext and are not
thread-safe, so this raced the backing store and crashed with EXC_BAD_ACCESS.
Make the mapper @MainActor (it must touch main-isolated models regardless) and
drop the now-redundant awaits. Fixes the crash for all synced model types.
Fixes an EXC_BAD_ACCESS seen in TestFlight build 261.
The 'resumed' guard flag was set inside a dispatched MainActor Task rather than
synchronously in the connection state handler. Two states arriving in quick
succession (e.g. .ready then .failed) could both pass the guard before either
Task ran, resuming the continuation twice and trapping. Claim the continuation
synchronously on the serial queue before dispatching the side effects.
Fixes a SIGTRAP seen in TestFlight build 261.
completeMultiFileDownload captured an index via firstIndex, then awaited a
detached file-size calculation. Another concurrently-completing download could
mutate activeDownloads during that suspension, leaving the index stale and
crashing in Array.remove(at:) with an out-of-bounds index. Remove the download
by identity instead.
Fixes a SIGTRAP seen in TestFlight build 261 (5 reports).
SwiftUI evaluates a @State default-value autoclosure more than once,
keeping only the first instance but still running the side effects of
the discarded ones. Each AppEnvironment() built its own DownloadManager,
and each DownloadManager registered a background URLSession under the
same identifier. The download task started on the surviving instance's
session, but iOS delivered the completion delegate callback to the other
(leaked) instance, whose activeDownloads was empty - so the finished
file was dropped and the progress spinner spun forever at 0 KB.
- Make AppEnvironment a process-wide singleton (static let shared) and
reference it from the App's @State, guaranteeing exactly one instance
(and one background session, one DataManager/CloudKit stack).
- Make DownloadManager.setDownloadSettings idempotent: only create the
background session when none exists, so a re-entrant call never
invalidateAndCancels the live session and kills in-flight downloads.
Cellular changes already route through refreshCellularAccessSetting().
Player settings on tvOS showed the previous video's tracks after queue
advance because availableStreams was never cleared on a video change and
playQueuedVideo only seeded a single pre-resolved stream. Reset the list
on a new video, then fetch the full streams in the background for global
videos, and repoint state.currentStream/currentAudioStream to the
refreshed entries so the picker checkmarks land on the playing tracks.
Lets the Apple TV switch its HDMI output to match the playing video's
frame rate and dynamic range via AVDisplayManager.preferredDisplayCriteria,
driven from MPV's container-fps and video-params/gamma. Two opt-in toggles
(default off) live under Playback → Display on tvOS; both are no-ops on
other platforms. Anchor an AVKit class symbol so the linker keeps AVKit
linked — Swift only autolinks AVFoundation here, and without AVKit the
UIWindow.avDisplayManager category isn't loaded at runtime.
If the MPV backend was retrying a failed load and the user switched to
another video before retries exhausted, the eventual error was published
to the player UI even though that video was no longer active. Guard the
catch block with a loadingVideoID check so stale errors are dropped.
Render subscriber count, Subscribe button, and (for subscribed
channels) description in the tvOS loading state instead of just
avatar + name + spinner. Seed the in-memory author cache when
navigating to a channel from a video so the first-time channel
view has a name and avatar to display immediately.
When the same video was already loaded (typically paused), opening it
again via the URL scheme, a deep link, or a remote-control loadVideo
command did nothing — the player just stayed paused. Now the same-video
early-return path resumes playback if paused and seeks to the supplied
startTime, so timestamps from URLs and remotes are honoured even when
the video is already loaded.
URLRouter gains a parseTimestamp helper that reads t/time/start query
params in plain-seconds and YouTube-style (1h2m3s) forms, and the deep
link handler now forwards that timestamp through to openVideo.
Surfaces the existing iOS/macOS Background Playback setting in the tvOS
Playback settings, defaulting to off so audio stops when the user leaves
the app via the TV button. Pauses playback on .background/.inactive when
the toggle is off, regardless of audio route — the user's setting wins
over AirPlay/HomePod handoff. Also auto-shows the tvOS player controls
when returning to foreground so the paused state is immediately visible
and actionable.
On tvOS, registering MPRemoteCommandCenter handlers makes the system
classify the app as a long-form video media app. When audio is routed
to AirPlay 2 endpoints (HomePods), the system then enforces a ~2s
look-ahead buffer in the AVAudioSession → AirPlay 2 pipe for
multi-speaker sync. The result is a 2-3 second audio drain on pause
and refill on resume.
The buffer lives downstream of mpv, so no mpv command (ao-reload,
seek-flush, audio-add) can flush it; AVAudioSession setCategory
overrides (mode/policy) and setActive(false)/setActive(true) cycles
are also ignored once the app is media-classified.
Workaround: detect the active audio route via routeChangeNotification
on tvOS. While AirPlay/HomePod is the output, suppress
MPNowPlayingInfoCenter publication and disable every MPRemoteCommand
so tvOS un-classifies us. When the route returns to local outputs,
republish the cached Now Playing info and reconfigure remote commands.
A latch defers all media integration until the audio session has been
activated at least once, so no commands are registered before the
route can be evaluated.
Trade-off: while playing to HomePods, the Control Center widget and
external Siri Remote play/pause are not available — but pause/resume
is responsive.
Subscribe to mpv log messages and capture END_FILE error code/string so
load failures bubble up specific causes (HTTP 404/403, DNS failure,
demuxer errors) instead of a generic 10s timeout.
After iOS reinstall/restore the app container UUID rotates, which left both
the persisted source.url and the security-scoped bookmark pointing at a
no-longer-current path. Files derived a stale absolute path that got appended
onto the resolved bookmark, producing doubled URLs that MPV could not load.
- Resolve the base URL by picking whichever of the bookmark or source.url
actually exists on disk.
- Compute MediaFile relative paths against the resolved root so they survive
later container changes.
- Hold the security-scoped resource access for the source's lifetime via a
shared resolver, so MPV can open files long after the directory enumeration
that resolved the bookmark has returned.
- Normalize legacy absolute paths embedded in old recents/history video IDs
so they re-resolve under the current container.
Toast cards now follow the finger upward and dismiss on either a
sufficient drag or a fast flick (via predicted-end translation). The
auto-dismiss timer pauses while the user is dragging and re-arms if
they release without dismissing.
Drop comments restating what the code shows; hoist allowSoftwareDecodedFormats
out of the recommendedVideoStreams filter closure so the bridge property is
read once per render instead of once per stream.
Lets the auto stream selector pick formats whose codec isn't hardware
decoded on the current device. Defaults off; when on, 4K VP9/AV1 can be
auto-selected on Apple TV models without those decoders. Software-decoded
streams also move into the Recommended section so the selection stays
visible without enabling advanced stream details.
Pointing AddRemoteServer at a Piped Vue SPA (e.g. the frontend host
rather than the API host) used to fail with a generic
"could not detect instance type" — every JSON probe got the same
index.html back. On the failure path, fetch `/` once more and look
for `<title>Piped</title>`; if matched, return a new
`pipedFrontendDetected` error that tells the user to enter the
Piped API URL instead.
Commit aed78c13f moved the session token from the Authorization header to a
?authToken= query parameter on /subscriptions, /subscribe, /unsubscribe,
/user/playlists, and /playlists/{id}. Piped's backend only accepts the
?authToken= form on /feed; every other authenticated route reads it from
Authorization (the Java handler names the variable "session", which is why
the rejected requests returned "session is a required parameter"). Restore
the header form for those five routes and leave feed alone.
The proxy auto-detect path (when proxiesVideos is off) HEADed a
googlevideo URL with a 5 s timeout on every video. The verdict is a
property of the network, not the video, so the cost was paid for no
reason on videos 2..N. On a network where the CDN is blocked the full
5 s timeout was added to playback startup every single time.
Two changes:
1) ProxyDetectionCache (actor, per-instance, 10 min TTL). First miss
pays the HEAD once and caches the verdict; subsequent videos hit
the cache synchronously. Concurrent callers share one in-flight
probe. The last-seen sample CDN URL is retained so future probes
don't need a fresh URL from the current video.
2) PlayerService kicks off InvidiousAPI.prewarmProxyDetection() in
parallel with the videoWith... API call. By the time streams come
back, the verdict is usually already cached and proxyStreamsIfNeeded
is a sync lookup. Cheap when there's nothing to prewarm.
Cache invalidation:
- on InstancesManager.update (URL change, proxy toggle flip)
- on InstancesManager.remove
- TTL covers the network-change case for now (no NWPathMonitor yet)
The Sources -> Edit Source -> Proxy toggle now renders for Yattee
Server entries (supportsVideoProxying gains .yatteeServer). When the
toggle is on, playback fetches go through
videoWithProxyStreamsAndCaptionsAndStoryboards with mode .relay, so
the server returns signed /proxy/relay URLs (byte-relay, supports
HTTP Range, no on-disk caching). Downloads keep going through mode
.download so the server-side /proxy/fast/ flow continues to cache
files for repeat use.
InvidiousAPI.proxyStreamsIfNeeded early-returns for .yatteeServer
since proxying is now done at fetch time via ?proxy=true rather than
client-side host rewriting.
Tapping bit.ly/tinyurl/t.co/etc. in a description or comment previously
opened Safari even when the destination was a playable YouTube URL.
Added an opt-in "Resolve Short Links" toggle under YouTube Enhancements
(off by default) that follows the redirect on tap: if the target is a
YouTube/PeerTube/direct-media URL, open it in-app; otherwise prompt the
user before falling back to yt-dlp extraction or the browser.
Also added a confirmation dialog for non-shortener links that only
matched the loose .externalVideo yt-dlp fallback, so arbitrary web
pages in descriptions no longer silently kick off extraction.
Prompts live on NavigationCoordinator and are dual-hosted by YatteeApp
and ExpandedPlayerSheet so they remain visible whether or not the
expanded player is covering the main view.
New Release-DeveloperID configuration gates Sparkle behind a SPARKLE
compile flag so the App Store Release build stays Sparkle-free. Adds
SPUStandardUpdaterController wrapper, Check for Updates menu command,
Advanced Settings section with beta channel toggle, and a Ruby script
plus GitHub Actions job that signs each release and publishes the
appcast to gh-pages for consumption by Sparkle and Homebrew cask.
Description links to YouTube videos, channels, playlists, and external
video URLs now open in Yattee instead of Safari. When a video is
already playing, tapping a video link surfaces the existing
QueueActionSheet (Play Now / Play Next / Add to Queue) — the sheet is
hosted both at the app root and inside ExpandedPlayerSheet so it
appears above whichever layer is on screen.
- Move Close to a top-right circular icon; bottom row reorganizes into
left (Settings/Info/Comments), center transport (Previous/PlayPause/Next),
and right (Queue) clusters with equal side frames so transport stays
geometrically centered.
- Introduce a circular icon-only `TVTransportButtonStyle` (primary variant
for Play/Pause) mirroring the new Close button look.
- Always render Previous/Next so Play/Pause position is fixed; dim and
disable when unavailable.
- Share `isTransportDisabled` on `PlayerState` and reuse it on iOS and
tvOS; apply it (plus symbol replace transition) to the tvOS Play/Pause
button.
Delete the multi-page onboarding sheet. On first launch the app now
silently imports any v1 instances from UserDefaults (splitting embedded
basic-auth credentials out of the URL and into the Keychain) and then,
if the device is signed in to iCloud, shows a single alert offering to
enable sync. Accepting shows a blocking progress overlay until the
initial upload completes.
Surfaces Continue Watching, Recent Feed, and Recent Bookmarks in the
Apple TV Home top shelf when Yattee is focused. Tapping a tile opens
the video via the existing yattee://video/{id} deep link.
- New YatteeTopShelf app extension target (tvOS only). LD_ENTRY_POINT is
overridden to _NSExtensionMain; the tv-app-extension product type
defaults to _TVExtensionMain which is for the pre-tvOS-13 legacy API
and crashes modern TVTopShelfContentProvider subclasses at launch.
- Main app writes per-section JSON snapshots (capped at 10 items each)
to a shared App Group UserDefaults suite after bookmark, watch-history,
and feed-cache changes, plus an initial write on launch.
- Enabled-sections list is mirrored to the same App Group so the
extension can respect the user's selection without touching SwiftData.
- Settings → Top Shelf (tvOS only) lets the user toggle sections.
- Deep link playback shows a loading toast while video details are
fetched, and an error toast if no source is configured.
Proxied thumbnail URLs from Invidious/Piped/Yattee server expire over
time. Two paths were left holding stale URLs: the Video Info carousel
kept the original list copy even after fresh details arrived, and
downloaded videos rendered from the remote URL snapshot taken at
download time while the local thumbnail on disk was ignored.
Evict stale URLs from the Nuke cache when fresh video details load,
pass the fresh details through to the videoCard thumbnail, and resolve
downloads' thumbnails from the local file when localThumbnailPath is
set.
yattee-server returns direct YouTube CDN URLs in the storyboard `url`
and `templateUrl` fields instead of an Invidious-style VTT proxy path.
Two resulting issues:
- `Storyboard.directSheetURL` was replacing the whole `M$M` token with
just the index, producing `.../0.jpg` (404) instead of `.../M0.jpg`.
Replace `M$M` with `M\(index)` to preserve the literal `M` prefix;
matching the full token also avoids clobbering `$M` sequences that
may appear in `sigh=rs$...` query params.
- The download code fetched `proxyUrl` as if it were a WebVTT file;
with yattee-server that downloads a JPEG that fails UTF-8 parsing.
Skip the VTT round-trip when `proxyUrl` obviously points at an image.
Also align the on-disk filename with the local-playback template
(`sb_M$M.jpg` → `sb_M{N}.jpg`) so offline seek-bar previews resolve,
and add [Storyboard] debug logs at each decision point so future
failures can be diagnosed without guessing.
Left/right on the Siri Remote now seek instead of revealing controls,
reusing the iOS tap-seek accumulation handler and feedback overlay so
rapid presses compound into a single "+30s" / "-20s" jump. Up/down
still show controls.
parseAudioInfo() returned isOriginal=false for all streams when the
audioTrack object was present in the API response, preventing xtags
parsing from correctly identifying original tracks. This caused the
player to fall through to codec/bitrate sorting, often picking a
locale dub (e.g. Polish) instead of the original English audio.
Now determines isOriginal from both the audioTrack displayName
("original" keyword) and URL xtags (acont=original) for robustness.
Also adds isDefault to InvidiousAudioTrack for future use.
- InstanceDetector: a single 401 from one probe was over-eagerly concluded
as "credentials invalid" / "credentials required". On instances behind a
reverse proxy where one probe path (e.g. Yattee Server's /info) hits a
same-origin redirect, iOS URLSession strips the Authorization header on
the redirect and the request 401s even with valid credentials. Track 401s
across all probes and only conclude basicAuthRequired/basicAuthInvalid
when no probe matched and at least one returned 401.
- InstanceLoginView: the Invidious/Piped login flow constructed an API
client backed by the shared appEnvironment.httpClient, which has no
per-instance basic-auth headers. For instances behind a reverse proxy,
the login POST 401d before reaching the upstream login endpoint. Build a
per-instance HTTPClient with the basic-auth Authorization header baked in
via setDefaultHeaders, mirroring ContentService.httpClientWithBasicAuth.
- InvidiousAPI.login: the login function constructs its own URLSession (to
capture Set-Cookie via a redirect-blocking delegate), so it never
inherits headers from the injected httpClient. Add an optional
extraHeaders parameter and have InstanceLoginView pass the basic-auth
header through when present. PipedAPI.login uses httpClient.fetch and
inherits defaultHeaders correctly, so no change is needed there.
When an instance sits behind a reverse proxy that requires HTTP Basic Auth,
every detection probe (/info, /api/v1/config, /api/v1/stats, /healthcheck,
/config) returns 401 before reaching the real backend, so the type cannot be
identified. Re-throw APIError.unauthorized from each probe instead of
swallowing it, and have detectWithResult convert the first 401 it sees into
DetectionError.basicAuthRequired. Add a basicAuthHeader parameter so the
caller can retry detection after the user provides credentials; if a retry
also returns 401, surface basicAuthInvalid instead.