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.
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.
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 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
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.