mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
502
Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift
Normal file
502
Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift
Normal file
@@ -0,0 +1,502 @@
|
||||
//
|
||||
// MPVSoftwareRenderView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Software (CPU-based) rendering view for MPV in iOS/tvOS Simulator.
|
||||
// Uses MPV_RENDER_API_TYPE_SW to render to memory buffer, then displays via CGImage.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Libmpv
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(tvOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import CoreMedia
|
||||
|
||||
#if targetEnvironment(simulator) && (os(iOS) || os(tvOS))
|
||||
|
||||
/// Software-based MPV render view for iOS/tvOS Simulator (where OpenGL ES is not available).
|
||||
/// Renders video frames to CPU memory buffer and displays via CALayer.
|
||||
final class MPVSoftwareRenderView: UIView {
|
||||
// MARK: - Properties
|
||||
|
||||
private weak var mpvClient: MPVClient?
|
||||
private var isSetup = false
|
||||
|
||||
/// Render buffer for MPV to write pixel data
|
||||
private var renderBuffer: UnsafeMutableRawPointer?
|
||||
private var renderWidth: Int = 0
|
||||
private var renderHeight: Int = 0
|
||||
private var renderStride: Int = 0
|
||||
|
||||
/// Display link for frame rendering
|
||||
private var displayLink: CADisplayLink?
|
||||
|
||||
/// Video frame rate from MPV
|
||||
var videoFPS: Double = 30.0 {
|
||||
didSet {
|
||||
updateDisplayLinkFrameRate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Current display link target frame rate
|
||||
var displayLinkTargetFPS: Double {
|
||||
videoFPS
|
||||
}
|
||||
|
||||
/// Lock for thread-safe rendering
|
||||
private let renderLock = NSLock()
|
||||
private var isRendering = false
|
||||
|
||||
/// Tracks whether first frame has been rendered
|
||||
private var hasRenderedFirstFrame = false
|
||||
|
||||
/// Tracks whether MPV has signaled it has a frame ready
|
||||
private var mpvHasFrameReady = false
|
||||
|
||||
/// Generation counter to invalidate stale frame callbacks
|
||||
private var frameGeneration: UInt = 0
|
||||
|
||||
/// Callback when first frame is rendered
|
||||
var onFirstFrameRendered: (() -> Void)?
|
||||
|
||||
/// Callback when view is added to window (for PiP setup)
|
||||
var onDidMoveToWindow: ((UIView) -> Void)?
|
||||
|
||||
/// Dedicated queue for rendering operations
|
||||
private let renderQueue = DispatchQueue(label: "stream.yattee.mpv.software-render", qos: .userInitiated)
|
||||
|
||||
/// Lock for buffer recreation
|
||||
private let bufferLock = NSLock()
|
||||
private var isRecreatingBuffer = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
backgroundColor = .black
|
||||
contentScaleFactor = UIScreen.main.scale
|
||||
|
||||
// Observe app lifecycle
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidEnterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func appDidEnterBackground() {
|
||||
displayLink?.isPaused = true
|
||||
MPVLogging.logDisplayLink("paused", isPaused: true, reason: "enterBackground")
|
||||
}
|
||||
|
||||
@objc private func appDidBecomeActive() {
|
||||
displayLink?.isPaused = false
|
||||
MPVLogging.logDisplayLink("resumed", isPaused: false, reason: "becomeActive")
|
||||
|
||||
if isSetup {
|
||||
renderQueue.async { [weak self] in
|
||||
self?.performRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
stopDisplayLink()
|
||||
|
||||
// Free render buffer on render queue
|
||||
renderQueue.sync {
|
||||
freeRenderBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
override func willMove(toSuperview newSuperview: UIView?) {
|
||||
super.willMove(toSuperview: newSuperview)
|
||||
|
||||
if newSuperview == nil {
|
||||
MPVLogging.logDisplayLink("stop", reason: "removedFromSuperview")
|
||||
stopDisplayLink()
|
||||
}
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
|
||||
if superview != nil && isSetup {
|
||||
if displayLink == nil {
|
||||
MPVLogging.logDisplayLink("start", reason: "addedToSuperview")
|
||||
startDisplayLink()
|
||||
}
|
||||
|
||||
// Trigger immediate render
|
||||
renderQueue.async { [weak self] in
|
||||
self?.performRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard isSetup else { return }
|
||||
|
||||
let currentSize = bounds.size
|
||||
let scale = contentScaleFactor
|
||||
let expectedWidth = Int(currentSize.width * scale)
|
||||
let expectedHeight = Int(currentSize.height * scale)
|
||||
|
||||
// Check if buffer size needs update
|
||||
let bufferMismatch = abs(renderWidth - expectedWidth) > 2 || abs(renderHeight - expectedHeight) > 2
|
||||
|
||||
guard bufferMismatch && expectedWidth > 0 && expectedHeight > 0 else { return }
|
||||
|
||||
MPVLogging.logTransition("layoutSubviews - size mismatch (async resize)",
|
||||
fromSize: CGSize(width: renderWidth, height: renderHeight),
|
||||
toSize: CGSize(width: expectedWidth, height: expectedHeight))
|
||||
|
||||
// Recreate buffer on background queue
|
||||
isRecreatingBuffer = true
|
||||
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.allocateRenderBuffer(width: expectedWidth, height: expectedHeight)
|
||||
self.isRecreatingBuffer = false
|
||||
MPVLogging.log("layoutSubviews: buffer recreation complete")
|
||||
}
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
|
||||
if window != nil {
|
||||
onDidMoveToWindow?(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
/// Set up with an MPV client (async version).
|
||||
func setupAsync(with client: MPVClient) async throws {
|
||||
self.mpvClient = client
|
||||
|
||||
// Create MPV software render context
|
||||
let success = client.createSoftwareRenderContext()
|
||||
if !success {
|
||||
MPVLogging.warn("setupAsync: failed to create MPV software render context")
|
||||
throw MPVRenderError.renderContextFailed(-1)
|
||||
}
|
||||
|
||||
// Set up render update callback
|
||||
client.onRenderUpdate = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
// Set up video frame callback
|
||||
client.onVideoFrameReady = { [weak self] in
|
||||
guard let self else { return }
|
||||
let capturedGeneration = self.frameGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.frameGeneration == capturedGeneration else { return }
|
||||
self.mpvHasFrameReady = true
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
// Allocate initial render buffer
|
||||
let scale = contentScaleFactor
|
||||
let width = Int(bounds.width * scale)
|
||||
let height = Int(bounds.height * scale)
|
||||
|
||||
if width > 0 && height > 0 {
|
||||
renderQueue.async { [weak self] in
|
||||
self?.allocateRenderBuffer(width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
startDisplayLink()
|
||||
isSetup = true
|
||||
}
|
||||
|
||||
MPVLogging.log("MPVSoftwareRenderView: setup complete")
|
||||
}
|
||||
|
||||
/// Update time position for frame timestamps.
|
||||
func updateTimePosition(_ time: Double) {
|
||||
// Not used in software rendering, but kept for API compatibility
|
||||
}
|
||||
|
||||
// MARK: - Buffer Management
|
||||
|
||||
/// Allocate aligned render buffer for MPV to write pixels.
|
||||
/// Must be called on renderQueue.
|
||||
private func allocateRenderBuffer(width: Int, height: Int) {
|
||||
guard width > 0 && height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
// Free existing buffer
|
||||
freeRenderBuffer()
|
||||
|
||||
// Calculate stride (4 bytes per pixel for RGBA, aligned to 64 bytes)
|
||||
let bytesPerPixel = 4
|
||||
let minStride = width * bytesPerPixel
|
||||
let stride = ((minStride + 63) / 64) * 64 // Round up to 64-byte alignment
|
||||
|
||||
// Allocate aligned buffer
|
||||
var buffer: UnsafeMutableRawPointer?
|
||||
let bufferSize = stride * height
|
||||
let alignResult = posix_memalign(&buffer, 64, bufferSize)
|
||||
|
||||
guard alignResult == 0, let buffer else {
|
||||
MPVLogging.warn("allocateRenderBuffer: posix_memalign failed (\(alignResult))")
|
||||
return
|
||||
}
|
||||
|
||||
// Zero out buffer
|
||||
memset(buffer, 0, bufferSize)
|
||||
|
||||
renderBuffer = buffer
|
||||
renderWidth = width
|
||||
renderHeight = height
|
||||
renderStride = stride
|
||||
|
||||
// Trigger an immediate render now that we have a buffer
|
||||
if isSetup {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, !self.isRendering else { return }
|
||||
|
||||
self.renderLock.lock()
|
||||
self.isRendering = true
|
||||
self.renderLock.unlock()
|
||||
|
||||
self.renderQueue.async { [weak self] in
|
||||
self?.performRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Free render buffer.
|
||||
/// Must be called on renderQueue.
|
||||
private func freeRenderBuffer() {
|
||||
if let buffer = renderBuffer {
|
||||
free(buffer)
|
||||
renderBuffer = nil
|
||||
}
|
||||
renderWidth = 0
|
||||
renderHeight = 0
|
||||
renderStride = 0
|
||||
}
|
||||
|
||||
// MARK: - Display Link
|
||||
|
||||
private func startDisplayLink() {
|
||||
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFired))
|
||||
updateDisplayLinkFrameRate()
|
||||
displayLink?.add(to: .main, forMode: .common)
|
||||
}
|
||||
|
||||
private func updateDisplayLinkFrameRate() {
|
||||
guard let displayLink else { return }
|
||||
|
||||
// Match video FPS
|
||||
let preferred = Float(min(max(videoFPS, 24.0), 60.0))
|
||||
displayLink.preferredFrameRateRange = CAFrameRateRange(
|
||||
minimum: 24,
|
||||
maximum: 60,
|
||||
preferred: preferred
|
||||
)
|
||||
}
|
||||
|
||||
private func stopDisplayLink() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
|
||||
/// Pause rendering.
|
||||
func pauseRendering() {
|
||||
displayLink?.isPaused = true
|
||||
}
|
||||
|
||||
/// Resume rendering.
|
||||
func resumeRendering() {
|
||||
guard let displayLink else { return }
|
||||
displayLink.isPaused = false
|
||||
|
||||
if isSetup {
|
||||
renderQueue.async { [weak self] in
|
||||
self?.performRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset first frame tracking.
|
||||
func resetFirstFrameTracking() {
|
||||
frameGeneration += 1
|
||||
hasRenderedFirstFrame = false
|
||||
mpvHasFrameReady = false
|
||||
}
|
||||
|
||||
/// Clear the render view to black.
|
||||
func clearToBlack() {
|
||||
guard renderBuffer != nil else { return }
|
||||
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self, let buffer = self.renderBuffer else { return }
|
||||
memset(buffer, 0, self.renderStride * self.renderHeight)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.layer.contents = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
@objc private func displayLinkFired() {
|
||||
guard isSetup, !isRendering else { return }
|
||||
|
||||
renderLock.lock()
|
||||
isRendering = true
|
||||
renderLock.unlock()
|
||||
|
||||
renderQueue.async { [weak self] in
|
||||
self?.performRender()
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame counter for periodic logging
|
||||
private var renderFrameLogCounter: UInt64 = 0
|
||||
|
||||
private func performRender() {
|
||||
defer {
|
||||
renderLock.lock()
|
||||
isRendering = false
|
||||
renderLock.unlock()
|
||||
}
|
||||
|
||||
guard let mpvClient else {
|
||||
renderFrameLogCounter += 1
|
||||
return
|
||||
}
|
||||
|
||||
guard let buffer = renderBuffer, renderWidth > 0, renderHeight > 0 else {
|
||||
renderFrameLogCounter += 1
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if buffer is being recreated
|
||||
if isRecreatingBuffer {
|
||||
return
|
||||
}
|
||||
|
||||
// Render frame to buffer - returns true if a frame was actually rendered
|
||||
let didRender = mpvClient.renderSoftware(
|
||||
buffer: buffer,
|
||||
width: Int32(renderWidth),
|
||||
height: Int32(renderHeight),
|
||||
stride: renderStride
|
||||
)
|
||||
|
||||
// Only update the layer if we actually rendered a frame
|
||||
guard didRender else {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert buffer to CGImage and update layer
|
||||
if let image = bufferToCGImage() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.layer.contents = image
|
||||
}
|
||||
|
||||
// Notify on first frame rendered
|
||||
if !hasRenderedFirstFrame {
|
||||
hasRenderedFirstFrame = true
|
||||
mpvHasFrameReady = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onFirstFrameRendered?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderFrameLogCounter += 1
|
||||
}
|
||||
|
||||
/// Convert render buffer to CGImage for display.
|
||||
/// Must be called on renderQueue.
|
||||
private func bufferToCGImage() -> CGImage? {
|
||||
guard let buffer = renderBuffer, renderWidth > 0, renderHeight > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy buffer data to avoid lifetime issues
|
||||
let bufferSize = renderStride * renderHeight
|
||||
let dataCopy = Data(bytes: buffer, count: bufferSize)
|
||||
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue)
|
||||
|
||||
guard let dataProvider = CGDataProvider(data: dataCopy as CFData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let image = CGImage(
|
||||
width: renderWidth,
|
||||
height: renderHeight,
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 32,
|
||||
bytesPerRow: renderStride,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo,
|
||||
provider: dataProvider,
|
||||
decode: nil,
|
||||
shouldInterpolate: false,
|
||||
intent: .defaultIntent
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - PiP Compatibility Stubs
|
||||
|
||||
/// These properties/methods exist for API compatibility with MPVRenderView.
|
||||
/// PiP is not supported in software rendering mode.
|
||||
|
||||
var captureFramesForPiP: Bool = false
|
||||
var isPiPActive: Bool = false
|
||||
var videoContentWidth: Int = 0
|
||||
var videoContentHeight: Int = 0
|
||||
var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)?
|
||||
|
||||
func clearMainViewForPiP() {
|
||||
clearToBlack()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user