Merge pull request #957 from rswilem/fix/apple-tv-hd-black-video-956

Fix black video, freezes, autoplay and clock on Apple TV HD (A8, 2016)
This commit is contained in:
Arkadiusz Fal
2026-07-23 22:57:05 +02:00
committed by GitHub
5 changed files with 106 additions and 28 deletions

View File

@@ -161,8 +161,9 @@ final class MPVClient: @unchecked Sendable {
weak var delegate: MPVClientDelegate?
/// Event loop task
private var eventLoopTask: Task<Void, Never>?
/// Whether the dedicated event-loop thread has been started (mpvQueue-guarded).
/// The loop itself exits via `isDestroyed` + `mpv_wakeup` + `eventLoopExitSemaphore`.
private var eventLoopRunning = false
/// Semaphore signaled when event loop exits
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
@@ -187,6 +188,22 @@ final class MPVClient: @unchecked Sendable {
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
/// Log to LoggingService from any thread (async dispatch to MainActor).
@@ -244,7 +261,14 @@ final class MPVClient: @unchecked Sendable {
case "warn":
Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) }
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...")
// Subscribe to mpv log messages so we can capture HTTP/demuxer/decoder errors
// and surface them when load fails. "warn" covers HTTP errors, demuxer/codec
// failures, and network issues without flooding on the happy path.
let logLevelResult = mpv_request_log_messages(mpv, "warn")
// Capture HTTP/demuxer/decoder errors to surface on load failure.
// Verbose logging (when enabled) also captures renderer init details.
let mpvLogLevel = MPVLogging.verboseEnabled ? "v" : "warn"
let logLevelResult = mpv_request_log_messages(mpv, mpvLogLevel)
if logLevelResult < 0 {
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)
}
let hasTask = eventLoopTask != nil
eventLoopTask?.cancel()
eventLoopTask = nil
let hasTask = eventLoopRunning
eventLoopRunning = false
return hasTask
}
@@ -449,6 +472,19 @@ final class MPVClient: @unchecked Sendable {
setOptionSync("target-prim", "bt.709")
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
// This is lighter weight than display-resample (no interpolation overhead)
// and handles both hardware and software decode gracefully.
@@ -1523,9 +1559,18 @@ final class MPVClient: @unchecked Sendable {
// MARK: - Event Loop
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()
}
thread.name = "stream.yattee.mpv.events"
thread.qualityOfService = .userInitiated
thread.start()
}
private func runEventLoop() {
@@ -1534,7 +1579,7 @@ final class MPVClient: @unchecked Sendable {
eventLoopExitSemaphore.signal()
}
while !Task.isCancelled && !isDestroyed {
while !isDestroyed {
guard let mpv else { break }
// Wait for events with a short timeout

View File

@@ -448,6 +448,14 @@ final class MPVRenderView: UIView {
// Skip if framebuffer already matches
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",
fromSize: CGSize(width: CGFloat(renderWidth), height: CGFloat(renderHeight)),
toSize: CGSize(width: CGFloat(expectedFBWidth), height: CGFloat(expectedFBHeight)))
@@ -487,19 +495,23 @@ final class MPVRenderView: UIView {
MPVLogging.log("setupAsync: creating EAGLContext on background thread")
let glStartTime = Date()
let context = try await Task.detached(priority: .userInitiated) {
// This runs on background thread - doesn't block main thread!
if let ctx = EAGLContext(api: .openGLES3) {
MPVLogging.log("setupAsync: created OpenGL ES 3.0 context")
return ctx
} else if let ctx = EAGLContext(api: .openGLES2) {
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
return ctx
} else {
MPVLogging.warn("setupAsync: failed to create EAGLContext")
throw MPVRenderError.openGLSetupFailed
// Bridge through GCD, not Task.detached: EAGLContext creation can block for
// seconds in the GL driver, and a detached task would tie up a Swift
// cooperative-pool thread the app needs elsewhere (issue #956).
let context: EAGLContext = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
if let ctx = EAGLContext(api: .openGLES3) {
MPVLogging.log("setupAsync: created OpenGL ES 3.0 context")
continuation.resume(returning: ctx)
} else if let ctx = EAGLContext(api: .openGLES2) {
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
continuation.resume(returning: ctx)
} else {
MPVLogging.warn("setupAsync: failed to create EAGLContext")
continuation.resume(throwing: MPVRenderError.openGLSetupFailed)
}
}
}.value
}
let glCreateTime = Date().timeIntervalSince(glStartTime)
MPVLogging.log("setupAsync: EAGLContext created",
@@ -558,7 +570,7 @@ final class MPVRenderView: UIView {
} else {
MPVLogging.log("setupAsync: deferring displayLink (framebuffer not ready)")
}
isSetup = true
}

View File

@@ -538,9 +538,9 @@ final class MPVBackend: PlayerBackend {
LoggingService.shared.logMPV("MPV stream loaded successfully")
} catch is CancellationError {
// Re-throw cancellation errors without retry
// Only reset isInitialLoading if we're still the active load operation
// A newer load may have already set isInitialLoading=true
// Re-throw cancellation errors without retry.
// Only reset isInitialLoading if we're still the active load operation.
LoggingService.shared.debug("MPV: loadWithRetry cancelled", category: .mpv)
if currentLoadingID == loadingID {
isInitialLoading = false
}

View File

@@ -250,6 +250,13 @@ final class PlayerService {
// Mark that we're loading this video - time updates will be ignored until loading completes
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.isFirstFrameReady = false // Reset until first frame of new video is rendered
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) {
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)
delegate?.playerService(self, didChangeState: playbackState)
}

View File

@@ -86,6 +86,12 @@ private class MPVContainerView: UIView {
}
}
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()
}
}