mirror of
https://github.com/yattee/yattee.git
synced 2026-07-24 16:22:04 +00:00
Fix black video, freezes, autoplay and clock on Apple TV HD (A8)
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.
This commit is contained in:
@@ -161,8 +161,9 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
|
|
||||||
weak var delegate: MPVClientDelegate?
|
weak var delegate: MPVClientDelegate?
|
||||||
|
|
||||||
/// Event loop task
|
/// Whether the dedicated event-loop thread has been started (mpvQueue-guarded).
|
||||||
private var eventLoopTask: Task<Void, Never>?
|
/// The loop itself exits via `isDestroyed` + `mpv_wakeup` + `eventLoopExitSemaphore`.
|
||||||
|
private var eventLoopRunning = false
|
||||||
|
|
||||||
/// Semaphore signaled when event loop exits
|
/// Semaphore signaled when event loop exits
|
||||||
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
|
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
|
||||||
@@ -187,6 +188,22 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
destroy()
|
destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device Detection
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
/// Apple TV HD (AppleTV5,3, A8): its GL driver deadlocks on the float
|
||||||
|
/// texture uploads mpv's renderer defaults to (issue #956).
|
||||||
|
static let hasFragileGLDriver: Bool = {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let machine = withUnsafeBytes(of: systemInfo.machine) { rawPtr -> String in
|
||||||
|
guard let base = rawPtr.baseAddress else { return "" }
|
||||||
|
return String(cString: base.assumingMemoryBound(to: CChar.self))
|
||||||
|
}
|
||||||
|
return machine == "AppleTV5,3"
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Logging Helpers
|
// MARK: - Logging Helpers
|
||||||
|
|
||||||
/// Log to LoggingService from any thread (async dispatch to MainActor).
|
/// Log to LoggingService from any thread (async dispatch to MainActor).
|
||||||
@@ -244,7 +261,14 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
case "warn":
|
case "warn":
|
||||||
Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) }
|
Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) }
|
||||||
default:
|
default:
|
||||||
Task { @MainActor in LoggingService.shared.logMPV(formatted) }
|
// Surface info always; verbose only for renderer/decoder subsystems
|
||||||
|
// so the persisted log stays readable when verbose capture is on.
|
||||||
|
let p = prefix.lowercased()
|
||||||
|
let rendererRelevant = p.hasPrefix("vo") || p.contains("gpu")
|
||||||
|
|| p.contains("placebo") || p.hasPrefix("vd") || p.hasPrefix("ffmpeg")
|
||||||
|
if level == "info" || rendererRelevant {
|
||||||
|
Task { @MainActor in LoggingService.shared.logMPV(formatted) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,10 +327,10 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
|
|
||||||
log("Initialized, setting up property observers...")
|
log("Initialized, setting up property observers...")
|
||||||
|
|
||||||
// Subscribe to mpv log messages so we can capture HTTP/demuxer/decoder errors
|
// Capture HTTP/demuxer/decoder errors to surface on load failure.
|
||||||
// and surface them when load fails. "warn" covers HTTP errors, demuxer/codec
|
// Verbose logging (when enabled) also captures renderer init details.
|
||||||
// failures, and network issues without flooding on the happy path.
|
let mpvLogLevel = MPVLogging.verboseEnabled ? "v" : "warn"
|
||||||
let logLevelResult = mpv_request_log_messages(mpv, "warn")
|
let logLevelResult = mpv_request_log_messages(mpv, mpvLogLevel)
|
||||||
if logLevelResult < 0 {
|
if logLevelResult < 0 {
|
||||||
logWarning("Failed to subscribe to mpv log messages: \(String(cString: mpv_error_string(logLevelResult)))")
|
logWarning("Failed to subscribe to mpv log messages: \(String(cString: mpv_error_string(logLevelResult)))")
|
||||||
}
|
}
|
||||||
@@ -394,9 +418,8 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
mpv_wakeup(mpv)
|
mpv_wakeup(mpv)
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasTask = eventLoopTask != nil
|
let hasTask = eventLoopRunning
|
||||||
eventLoopTask?.cancel()
|
eventLoopRunning = false
|
||||||
eventLoopTask = nil
|
|
||||||
return hasTask
|
return hasTask
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +472,19 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
setOptionSync("target-prim", "bt.709")
|
setOptionSync("target-prim", "bt.709")
|
||||||
setOptionSync("target-trc", "srgb")
|
setOptionSync("target-trc", "srgb")
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
// Avoid the float texture uploads that hang the A8 GL driver (issue #956):
|
||||||
|
// dithering and LUT scalers (mpv 0.37+ defaults) and CPU frame uploads all
|
||||||
|
// deadlock. Zero-copy VideoToolbox uploads via IOSurface instead.
|
||||||
|
if Self.hasFragileGLDriver {
|
||||||
|
setOptionSync("dither-depth", "no")
|
||||||
|
setOptionSync("hwdec", "videotoolbox")
|
||||||
|
setOptionSync("scale", "bilinear")
|
||||||
|
setOptionSync("dscale", "bilinear")
|
||||||
|
setOptionSync("cscale", "bilinear")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Use display-vdrop: drops/repeats frames to match display timing
|
// Use display-vdrop: drops/repeats frames to match display timing
|
||||||
// This is lighter weight than display-resample (no interpolation overhead)
|
// This is lighter weight than display-resample (no interpolation overhead)
|
||||||
// and handles both hardware and software decode gracefully.
|
// and handles both hardware and software decode gracefully.
|
||||||
@@ -1523,9 +1559,18 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
// MARK: - Event Loop
|
// MARK: - Event Loop
|
||||||
|
|
||||||
private func startEventLoop() {
|
private func startEventLoop() {
|
||||||
eventLoopTask = Task.detached(priority: .high) { [weak self] in
|
// Dedicated OS thread, not the Swift cooperative pool: runEventLoop blocks
|
||||||
|
// in mpv_wait_event for the client's whole lifetime, and as a Task it would
|
||||||
|
// permanently occupy a pool thread. On a 2-core device the two live clients
|
||||||
|
// (active + pre-warmed) would hold both pool threads and starve every await
|
||||||
|
// in the app (issue #956).
|
||||||
|
eventLoopRunning = true
|
||||||
|
let thread = Thread { [weak self] in
|
||||||
self?.runEventLoop()
|
self?.runEventLoop()
|
||||||
}
|
}
|
||||||
|
thread.name = "stream.yattee.mpv.events"
|
||||||
|
thread.qualityOfService = .userInitiated
|
||||||
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runEventLoop() {
|
private func runEventLoop() {
|
||||||
@@ -1534,7 +1579,7 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
eventLoopExitSemaphore.signal()
|
eventLoopExitSemaphore.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
while !Task.isCancelled && !isDestroyed {
|
while !isDestroyed {
|
||||||
guard let mpv else { break }
|
guard let mpv else { break }
|
||||||
|
|
||||||
// Wait for events with a short timeout
|
// Wait for events with a short timeout
|
||||||
|
|||||||
@@ -448,6 +448,14 @@ final class MPVRenderView: UIView {
|
|||||||
// Skip if framebuffer already matches
|
// Skip if framebuffer already matches
|
||||||
guard framebufferMismatch else { return }
|
guard framebufferMismatch else { return }
|
||||||
|
|
||||||
|
// Don't recreate the framebuffer for an orphaned (windowless) view: binding
|
||||||
|
// the CAEAGLLayer off-main would collide with the main-thread CoreAnimation
|
||||||
|
// commit and pin a CPU (issue #956).
|
||||||
|
guard window != nil else {
|
||||||
|
MPVLogging.warn("layoutSubviews: skipped framebuffer recreation (no window)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
MPVLogging.logTransition("layoutSubviews - size mismatch",
|
MPVLogging.logTransition("layoutSubviews - size mismatch",
|
||||||
fromSize: CGSize(width: CGFloat(renderWidth), height: CGFloat(renderHeight)),
|
fromSize: CGSize(width: CGFloat(renderWidth), height: CGFloat(renderHeight)),
|
||||||
toSize: CGSize(width: CGFloat(expectedFBWidth), height: CGFloat(expectedFBHeight)))
|
toSize: CGSize(width: CGFloat(expectedFBWidth), height: CGFloat(expectedFBHeight)))
|
||||||
@@ -487,19 +495,23 @@ final class MPVRenderView: UIView {
|
|||||||
MPVLogging.log("setupAsync: creating EAGLContext on background thread")
|
MPVLogging.log("setupAsync: creating EAGLContext on background thread")
|
||||||
let glStartTime = Date()
|
let glStartTime = Date()
|
||||||
|
|
||||||
let context = try await Task.detached(priority: .userInitiated) {
|
// Bridge through GCD, not Task.detached: EAGLContext creation can block for
|
||||||
// This runs on background thread - doesn't block main thread!
|
// seconds in the GL driver, and a detached task would tie up a Swift
|
||||||
if let ctx = EAGLContext(api: .openGLES3) {
|
// cooperative-pool thread the app needs elsewhere (issue #956).
|
||||||
MPVLogging.log("setupAsync: created OpenGL ES 3.0 context")
|
let context: EAGLContext = try await withCheckedThrowingContinuation { continuation in
|
||||||
return ctx
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
} else if let ctx = EAGLContext(api: .openGLES2) {
|
if let ctx = EAGLContext(api: .openGLES3) {
|
||||||
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
|
MPVLogging.log("setupAsync: created OpenGL ES 3.0 context")
|
||||||
return ctx
|
continuation.resume(returning: ctx)
|
||||||
} else {
|
} else if let ctx = EAGLContext(api: .openGLES2) {
|
||||||
MPVLogging.warn("setupAsync: failed to create EAGLContext")
|
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
|
||||||
throw MPVRenderError.openGLSetupFailed
|
continuation.resume(returning: ctx)
|
||||||
|
} else {
|
||||||
|
MPVLogging.warn("setupAsync: failed to create EAGLContext")
|
||||||
|
continuation.resume(throwing: MPVRenderError.openGLSetupFailed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.value
|
}
|
||||||
|
|
||||||
let glCreateTime = Date().timeIntervalSince(glStartTime)
|
let glCreateTime = Date().timeIntervalSince(glStartTime)
|
||||||
MPVLogging.log("setupAsync: EAGLContext created",
|
MPVLogging.log("setupAsync: EAGLContext created",
|
||||||
@@ -558,7 +570,7 @@ final class MPVRenderView: UIView {
|
|||||||
} else {
|
} else {
|
||||||
MPVLogging.log("setupAsync: deferring displayLink (framebuffer not ready)")
|
MPVLogging.log("setupAsync: deferring displayLink (framebuffer not ready)")
|
||||||
}
|
}
|
||||||
|
|
||||||
isSetup = true
|
isSetup = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -538,9 +538,9 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
LoggingService.shared.logMPV("MPV stream loaded successfully")
|
LoggingService.shared.logMPV("MPV stream loaded successfully")
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
// Re-throw cancellation errors without retry
|
// Re-throw cancellation errors without retry.
|
||||||
// Only reset isInitialLoading if we're still the active load operation
|
// Only reset isInitialLoading if we're still the active load operation.
|
||||||
// A newer load may have already set isInitialLoading=true
|
LoggingService.shared.debug("MPV: loadWithRetry cancelled", category: .mpv)
|
||||||
if currentLoadingID == loadingID {
|
if currentLoadingID == loadingID {
|
||||||
isInitialLoading = false
|
isInitialLoading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,13 @@ final class PlayerService {
|
|||||||
// Mark that we're loading this video - time updates will be ignored until loading completes
|
// Mark that we're loading this video - time updates will be ignored until loading completes
|
||||||
loadingVideoID = video.id
|
loadingVideoID = video.id
|
||||||
|
|
||||||
|
// Release the gate on any exit path (a newer play() claims it with its own
|
||||||
|
// id, so only clear our own). Prevents the time-update gate from leaking if
|
||||||
|
// the load flow exits early (issue #956).
|
||||||
|
defer {
|
||||||
|
if loadingVideoID == video.id { loadingVideoID = nil }
|
||||||
|
}
|
||||||
|
|
||||||
state.setPlaybackState(.loading)
|
state.setPlaybackState(.loading)
|
||||||
state.isFirstFrameReady = false // Reset until first frame of new video is rendered
|
state.isFirstFrameReady = false // Reset until first frame of new video is rendered
|
||||||
state.isBufferReady = false // Reset until buffer is ready for smooth playback
|
state.isBufferReady = false // Reset until buffer is ready for smooth playback
|
||||||
@@ -2844,6 +2851,14 @@ extension PlayerService: PlayerBackendDelegate {
|
|||||||
|
|
||||||
func backend(_ backend: any PlayerBackend, didChangeState playbackState: PlaybackState) {
|
func backend(_ backend: any PlayerBackend, didChangeState playbackState: PlaybackState) {
|
||||||
LoggingService.shared.debug("Backend state changed to: \(playbackState)", category: .player)
|
LoggingService.shared.debug("Backend state changed to: \(playbackState)", category: .player)
|
||||||
|
|
||||||
|
// Clear the time-update gate once playback is ready/playing, in case the
|
||||||
|
// load flow exited before doing so (issue #956). Safe: stale updates from a
|
||||||
|
// previous video can only arrive before the new video's ready transition.
|
||||||
|
if playbackState == .ready || playbackState == .playing, loadingVideoID != nil {
|
||||||
|
loadingVideoID = nil
|
||||||
|
}
|
||||||
|
|
||||||
state.setPlaybackState(playbackState)
|
state.setPlaybackState(playbackState)
|
||||||
delegate?.playerService(self, didChangeState: playbackState)
|
delegate?.playerService(self, didChangeState: playbackState)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ private class MPVContainerView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MPVLogging.warn("MPVContainerView deinit: no surviving container to transfer player view to!")
|
MPVLogging.warn("MPVContainerView deinit: no surviving container to transfer player view to!")
|
||||||
|
|
||||||
|
// Detach the shared view now instead of leaving it bound to this
|
||||||
|
// deallocating container: otherwise SwiftUI keeps reconciling a
|
||||||
|
// half-alive view and spins the main-thread trait update loop (100%
|
||||||
|
// CPU hang when opening a settings detail during playback, issue #956).
|
||||||
|
playerView.removeFromSuperview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user