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?
|
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",
|
||||||
|
|||||||
@@ -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