diff --git a/Yattee/Services/Player/MPV/MPVOGLView.swift b/Yattee/Services/Player/MPV/MPVOGLView.swift index 0614bf4d..844ca97e 100644 --- a/Yattee/Services/Player/MPV/MPVOGLView.swift +++ b/Yattee/Services/Player/MPV/MPVOGLView.swift @@ -21,9 +21,9 @@ final class MPVOGLView: NSView { // MARK: - Properties /// The OpenGL layer that handles rendering. - private(set) lazy var videoLayer: MPVOpenGLLayer = { - MPVOpenGLLayer(videoView: self) - }() + /// `nil` when OpenGL is unavailable (e.g. a GPU-less virtual machine); the + /// view then renders nothing and `setup(with:)` throws instead of crashing. + private(set) var videoLayer: MPVOpenGLLayer? /// Reference to the MPV client. private weak var mpvClient: MPVClient? @@ -44,8 +44,8 @@ final class MPVOGLView: NSView { /// Callback when first frame is rendered. var onFirstFrameRendered: (() -> Void)? { - get { videoLayer.onFirstFrameRendered } - set { videoLayer.onFirstFrameRendered = newValue } + get { videoLayer?.onFirstFrameRendered } + set { videoLayer?.onFirstFrameRendered = newValue } } // MARK: - Video Info @@ -65,32 +65,32 @@ final class MPVOGLView: NSView { /// Whether to capture frames for PiP. var captureFramesForPiP: Bool { - get { videoLayer.captureFramesForPiP } - set { videoLayer.captureFramesForPiP = newValue } + get { videoLayer?.captureFramesForPiP ?? false } + set { videoLayer?.captureFramesForPiP = newValue } } /// Whether PiP is currently active. var isPiPActive: Bool { - get { videoLayer.isPiPActive } - set { videoLayer.isPiPActive = newValue } + get { videoLayer?.isPiPActive ?? false } + set { videoLayer?.isPiPActive = newValue } } /// Callback when a new frame is ready for PiP. var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)? { - get { videoLayer.onFrameReady } - set { videoLayer.onFrameReady = newValue } + get { videoLayer?.onFrameReady } + set { videoLayer?.onFrameReady = newValue } } /// Video content width (actual video dimensions for letterbox cropping). var videoContentWidth: Int { - get { videoLayer.videoContentWidth } - set { videoLayer.videoContentWidth = newValue } + get { videoLayer?.videoContentWidth ?? 0 } + set { videoLayer?.videoContentWidth = newValue } } /// Video content height (actual video dimensions for letterbox cropping). var videoContentHeight: Int { - get { videoLayer.videoContentHeight } - set { videoLayer.videoContentHeight = newValue } + get { videoLayer?.videoContentHeight ?? 0 } + set { videoLayer?.videoContentHeight = newValue } } // MARK: - Initialization @@ -113,10 +113,17 @@ final class MPVOGLView: NSView { private func commonInit() { // Set up layer-backed view wantsLayer = true - layer = videoLayer - // Configure layer properties - videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + // Create the OpenGL layer. This can fail on machines without a usable + // GPU (e.g. virtual machines), in which case the view renders nothing + // and setup(with:) will throw rather than crashing the app at launch. + if let videoLayer = MPVOpenGLLayer(videoView: self) { + self.videoLayer = videoLayer + layer = videoLayer + videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + } else { + LoggingService.shared.error("MPVOGLView: OpenGL unavailable - video rendering disabled", category: .mpv) + } // Configure view properties autoresizingMask = [.width, .height] @@ -132,6 +139,11 @@ final class MPVOGLView: NSView { func setup(with client: MPVClient) throws { self.mpvClient = client + // No layer means OpenGL was unavailable at init (e.g. GPU-less VM). + guard let videoLayer else { + throw MPVRenderError.openGLSetupFailed + } + // Set up the layer try videoLayer.setup(with: client) @@ -157,7 +169,7 @@ final class MPVOGLView: NSView { startDisplayLink() // Update contents scale for new window - videoLayer.contentsScale = window.backingScaleFactor + videoLayer?.contentsScale = window.backingScaleFactor } } @@ -166,7 +178,7 @@ final class MPVOGLView: NSView { // Update contents scale when backing properties change if let scale = window?.backingScaleFactor { - videoLayer.contentsScale = scale + videoLayer?.contentsScale = scale } // Update display refresh rate @@ -238,12 +250,12 @@ final class MPVOGLView: NSView { /// Reset first frame tracking (call when loading new content). func resetFirstFrameTracking() { mpvHasFrameReady = false - videoLayer.resetFirstFrameTracking() + videoLayer?.resetFirstFrameTracking() } /// Clear the view to black. func clearToBlack() { - videoLayer.clearToBlack() + videoLayer?.clearToBlack() } /// Pause rendering. @@ -254,12 +266,12 @@ final class MPVOGLView: NSView { /// Resume rendering. func resumeRendering() { - videoLayer.update(force: true) + videoLayer?.update(force: true) } /// Update cached time position for PiP timestamps. func updateTimePosition(_ time: Double) { - videoLayer.updateTimePosition(time) + videoLayer?.updateTimePosition(time) } /// Clear the main view for PiP transition (stub for now). @@ -269,7 +281,7 @@ final class MPVOGLView: NSView { /// Update PiP target render size - forces recreation of PiP capture resources. func updatePiPTargetSize(_ size: CMVideoDimensions) { - videoLayer.updatePiPTargetSize(size) + videoLayer?.updatePiPTargetSize(size) } // MARK: - Cleanup @@ -283,7 +295,7 @@ final class MPVOGLView: NSView { isUninited = true stopDisplayLink() - videoLayer.uninit() + videoLayer?.uninit() // Note: onFirstFrameRendered and onFrameReady are forwarded to videoLayer, // and videoLayer.uninit() clears them diff --git a/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift b/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift index 7f2b6233..b383c466 100644 --- a/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift +++ b/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift @@ -38,6 +38,17 @@ private let glFormat10Bit: [CGLPixelFormatAttribute] = [ CGLPixelFormatAttribute(0) ] +/// Last-resort pixel format without `kCGLPFAAccelerated`, allowing a software +/// renderer. This is what lets the app survive on GPU-less environments such as +/// virtual machines, where no hardware-accelerated renderer is available. +private let glFormatSoftware: [CGLPixelFormatAttribute] = [ + kCGLPFAOpenGLProfile, + CGLPixelFormatAttribute(kCGLOGLPVersion_3_2_Core.rawValue), + kCGLPFADoubleBuffer, + kCGLPFAAllowOfflineRenderers, + CGLPixelFormatAttribute(0) +] + // MARK: - MPVOpenGLLayer /// OpenGL layer for MPV rendering on macOS. @@ -141,16 +152,25 @@ final class MPVOpenGLLayer: CAOpenGLLayer { // MARK: - Initialization /// Creates an MPVOpenGLLayer for the given video view. - init(videoView: MPVOGLView) { + /// Returns `nil` when no OpenGL pixel format / context can be created + /// (e.g. on a GPU-less virtual machine), so callers can fail gracefully + /// instead of crashing the app at launch. + init?(videoView: MPVOGLView) { self.videoView = videoView - // Create pixel format (try 10-bit first, fall back to 8-bit) - let (pixelFormat, depth) = MPVOpenGLLayer.createPixelFormat() + // Create pixel format (try 10-bit, then 8-bit, then software renderer) + guard let (pixelFormat, depth) = MPVOpenGLLayer.createPixelFormat() else { + return nil + } self.cglPixelFormat = pixelFormat self.bufferDepth = depth // Create OpenGL context - self.cglContext = MPVOpenGLLayer.createContext(pixelFormat: pixelFormat) + guard let context = MPVOpenGLLayer.createContext(pixelFormat: pixelFormat) else { + CGLDestroyPixelFormat(pixelFormat) + return nil + } + self.cglContext = context super.init() @@ -560,8 +580,11 @@ final class MPVOpenGLLayer: CAOpenGLLayer { // MARK: - Pixel Format and Context Creation - /// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit. - private static func createPixelFormat() -> (CGLPixelFormatObj, GLint) { + /// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit, + /// then to a software renderer. Returns `nil` if no pixel format can be + /// created (e.g. a GPU-less virtual machine) so the caller can fail + /// gracefully instead of crashing. + private static func createPixelFormat() -> (CGLPixelFormatObj, GLint)? { var pixelFormat: CGLPixelFormatObj? var numPixelFormats: GLint = 0 @@ -583,17 +606,30 @@ final class MPVOpenGLLayer: CAOpenGLLayer { return (pf, 8) } - // This should not happen on any reasonable Mac - fatalError("MPVOpenGLLayer: failed to create any OpenGL pixel format") + // Last resort: software renderer (no hardware acceleration). This keeps + // the app alive on machines without a usable GPU, such as VMs. + result = CGLChoosePixelFormat(glFormatSoftware, &pixelFormat, &numPixelFormats) + if result == kCGLNoError, let pf = pixelFormat { + Task { @MainActor in + LoggingService.shared.debug("MPVOpenGLLayer: created software (non-accelerated) pixel format", category: .mpv) + } + return (pf, 8) + } + + // No pixel format available at all (e.g. GPU-less VM). Don't crash. + LoggingService.shared.error("MPVOpenGLLayer: failed to create any OpenGL pixel format (last error \(result.rawValue)) - video playback unavailable", category: .mpv) + return nil } - /// Create a CGL context with the given pixel format. - private static func createContext(pixelFormat: CGLPixelFormatObj) -> CGLContextObj { + /// Create a CGL context with the given pixel format. Returns `nil` on + /// failure so the caller can fail gracefully instead of crashing. + private static func createContext(pixelFormat: CGLPixelFormatObj) -> CGLContextObj? { var context: CGLContextObj? let result = CGLCreateContext(pixelFormat, nil, &context) guard result == kCGLNoError, let ctx = context else { - fatalError("MPVOpenGLLayer: failed to create OpenGL context: \(result)") + LoggingService.shared.error("MPVOpenGLLayer: failed to create OpenGL context: \(result.rawValue)", category: .mpv) + return nil } // Enable vsync