AndroidSurfaceVideoRenderer

Draws frames into a Surface the caller owns.

The conversion from the decoder's pixel format to RGBA is supplied by the caller through convert, because it lives in whichever backend produced the frame. This module knows how to put pixels on a Surface and nothing about how they were decoded, which is why the converter arrives as a function rather than as a dependency.

The Surface belongs to the caller

The constructor stores the Surface and never calls release() on it, because it never owned it: it belongs to whichever view, window or codec handed it over, and that owner releases it on its own schedule. The one rule the caller has to keep is the other side of the same coin: close this renderer before releasing or replacing the Surface. close waits for a lock or a post already in flight, so once it returns nothing here will touch the Surface again. Releasing it first instead leaves this renderer drawing into a dead handle, which on Android is not an exception but a native abort.

Why it has its own worker

Converting a frame on the CPU and pushing it through a software canvas costs milliseconds, and at 1080p enough of them that doing it inside present starves everything: the presentation schedule slips, and with the process saturated the audio feeder cannot keep the device fed either. So present hands the frame to a worker and returns at once, which is what the renderer contract allows for exactly this reason.

Only the newest frame is kept. When the renderer cannot keep up, a new frame replaces the one still waiting rather than a queue building, which is the right trade for a slow renderer: the engine's schedule stays intact and the picture updates as often as this renderer manages, instead of the whole player being dragged down to its speed. The one slot is written out by hand rather than taken from a conflated channel, because a conflated channel discards the displaced element silently, and a silently discarded frame is a decoder buffer that is never closed and never counted. Owning the slot means the displaced frame is closed and counted, which is both correct and visible in supersededFrames.

One seam for Android graphics

Every Surface, Canvas and Bitmap call sits behind CanvasTarget, and everything above that seam is ordinary Kotlin: the ownership rules, the byte validation, the swizzle and the geometry. That is what lets the whole of this class be tested on a development machine, where the Android graphics classes exist as stubs that throw the moment they are called. The real Surface is proved separately, once, by a device test.

The rules this obeys

The frame belongs to this renderer from the moment present is called and is closed exactly once, including when it is superseded before being drawn, when conversion fails, and when the renderer is closed while it is still in hand. Losing the Surface is an event and not an exception: the renderer refuses frames while it is gone, counts them, says so once through events, and starts drawing again by itself when a lock next succeeds. It never calls back into the player and never stops playback, because a backgrounded window should not stop the sound.

Constructors

Link copied to clipboard
constructor(surface: Surface, convert: (VideoFrame) -> ByteArray)

The renderer as a caller builds it: pictures are drawn into surface from this renderer's own thread.

constructor(convert: (VideoFrame) -> ByteArray, onOverlay: (SubtitleOverlay?) -> Unit, onVideoGeometry: (VideoSize, Int, PictureCrop?) -> Unit = { _, _, _ -> }, toneMapped: (VideoFrame) -> Boolean = { false })

Builds a renderer before its display Surface exists.

Properties

Link copied to clipboard
open override val events: Flow<RendererEvent>
Link copied to clipboard

Frames that reached no Surface for a reason other than being superseded: a conversion that failed or returned the wrong number of bytes, a frame refused while the Surface was gone, a lock or a post the Surface would not give, a draw that threw, or a frame still in hand when the renderer closed. From Android 14 it also counts a hardware frame the display dropped after its release, because a newer frame was shown in its place.

Link copied to clipboard
open override val outputSize: VideoSize?

The size the engine lays subtitles out for, rule 2 of docs/subtitle-placement.md. When a separate layer draws them, it is the size the host gave through setViewport. Otherwise it is the canvas this renderer draws them into, known from the first frame, and the host's size before that.

Link copied to clipboard

Frames the display showed. From Android 14 a hardware frame counts when MediaCodec reports it rendered. Before that, and for every software frame, it counts when its picture was posted to the Surface, which the display can still drop.

Link copied to clipboard
open override val redrawsHeldPicture: Boolean

True while the picture on screen came through the software path, whose pixels this renderer keeps and draws again at a change of its look, playing or not (#541). False after a MediaCodec frame, which goes to the Surface and leaves no copy, so a paused picture takes a change of its look only from the engine decoding it again (#463).

Link copied to clipboard
open override val showsHdr: Boolean

True when HDR shows as HDR here (#447): under HdrPolicy.Auto, on the direct codec path, which hands the codec's HDR to the system, and on a display that says it supports PQ or HLG. The software path tone maps every frame, and a display the view has not described yet counts as standard range, so the variant choice keeps SDR until the view says otherwise.

Link copied to clipboard

Frames replaced in the waiting slot by a newer one before they could be drawn, or let go because the picture was taken off before they were drawn.

Functions

Link copied to clipboard
open fun accepts(shape: FrameShape): Boolean
Link copied to clipboard
open override fun clearPicture()

Takes the picture off (#530): the frame waiting for the worker goes, a frame the worker is converting is not drawn, and the worker blanks the Surface. Nothing here waits, because the engine calls this from its own loop.

Link copied to clipboard
open override fun close()

Stops drawing and gives everything back except the Surface, which was never this renderer's.

Link copied to clipboard
open suspend override fun present(frame: VideoFrame, targetNanos: Long): Boolean

Queues frame for drawing and returns immediately.

Link copied to clipboard
open override fun setAdjustments(adjustments: VideoAdjustments)
Link copied to clipboard
fun setDisplayHdr(types: IntArray, headroom: Float)

What the display this renderer's Surface is on can show of HDR: the Display.HdrCapabilities types it supports and how far beyond standard white it goes now, or 1 when unknown. Fed by the view, because a Surface does not know its display. Until it is fed, the renderer reports nothing about HDR on the direct path, where only the display decides.

Link copied to clipboard
Link copied to clipboard
open override fun setHdrPolicy(policy: HdrPolicy)

HDR from the decoder goes to the Surface as it is under HdrPolicy.Auto, and the system shows it as HDR on a display that can. Under HdrPolicy.ToneMap the next open asks MediaCodec for SDR, and a software frame is tone mapped by the converter either way.

Link copied to clipboard
open suspend override fun setOverlay(overlay: SubtitleOverlay?)

Stores the overlay for the worker to composite above every following picture. A software picture is drawn again with it at once, however long the picture is held (#541); a MediaCodec picture's cues are drawn by the layer above it.

Link copied to clipboard
open fun setRenderQuality(quality: RenderQuality)
Link copied to clipboard
open override fun setScaleMode(mode: VideoScale)
Link copied to clipboard
fun setSurface(surface: Surface?)

Replaces the display Surface without replacing this renderer or its paired decoder.

Link copied to clipboard
open override fun setTransform(transform: VideoTransform)
Link copied to clipboard
open override fun setViewport(width: Int, height: Int, scale: Float)

The size of the surface the overlay is drawn on, from its host. KitePlayerView gives the size of its subtitle layer here, which only the host knows while the codec owns the video Surface.

Link copied to clipboard

MediaCodec buffers go straight to the Surface; every other format uses the software fallback.

Link copied to clipboard
open override fun supports(format: PlayerPixelFormat): Boolean
Link copied to clipboard
Link copied to clipboard
open override fun vsyncIntervalNanos(): Long?