mirror of
https://github.com/yattee/yattee.git
synced 2026-07-25 00:32:02 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user