diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index 1b8a9179..3fa99acd 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -161,8 +161,9 @@ final class MPVClient: @unchecked Sendable { weak var delegate: MPVClientDelegate? - /// Event loop task - private var eventLoopTask: Task? + /// 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 diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index 512b489a..0b8cd21a 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -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 } diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 586b93e7..77a9341b 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -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 } diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 84b67ec3..bf541b47 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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) } diff --git a/Yattee/Views/Player/MPVRenderViewRepresentable.swift b/Yattee/Views/Player/MPVRenderViewRepresentable.swift index dbae611f..b576ce8c 100644 --- a/Yattee/Views/Player/MPVRenderViewRepresentable.swift +++ b/Yattee/Views/Player/MPVRenderViewRepresentable.swift @@ -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() } }