This is the user-facing Android example for manual final playback checks. It
is deliberately separate from examples/android/, which remains the
automated connected-device regression harness.
The UI contains:
- a
SurfaceViewvideo area; - current time, seek slider, and total duration;
- local-file, remote-URL, play/pause, stop, and full-screen controls;
- post-load audio-track and subtitle-track selectors, including an explicit subtitle-off choice;
- a presentation-timed plain-text subtitle overlay above the video controls;
- live Vulkan, HDR, ZeroCopy, hardware-decode, and Debug switches, with rendering controls on the first row and ZeroCopy/hardware decode on a separate second row so every switch remains touchable;
- a Debug-controlled top-left status window showing the active native path, current presentation FPS, or the exact failure.
On a compact phone, the full-screen button requests sensor-aware landscape and
enters immersive playback; leaving button-initiated full screen restores the
activity's previous orientation request. A physical rotation into landscape
also enters full screen, and rotating back to portrait exits it. On a tablet,
unfolded large-screen device, or desktop window (smallestScreenWidthDp >= 600), the button does not lock orientation: the layout remains adaptive and
only the window enters immersive mode. This follows Android's large-screen
orientation policy, where orientation requests can be ignored, while retaining
the familiar landscape behavior of phone video players. See Android's
orientation and resizability guidance
and immersive system-bar guidance.
On Android 11/API 30 and newer, full screen uses WindowInsetsController and
lets an edge swipe reveal transient system bars over the video. Android 9 and
10 use the compatible immersive-sticky system-UI flags. Display cutouts are
used only in immersive mode. The complete control panel is overlaid at the
bottom of the video, remains visible for five seconds after the last touch,
and then disappears without hiding the independent Debug window or subtitle.
Tap the video once to reveal the controls again; that reveal gesture does not
activate a newly exposed button. Back exits full screen before leaving the
activity; API 33 and newer register the predictive-back dispatcher while the
older targets retain the activity callback.
The selectors use TrackInfo::index and the asynchronous
Player::setActiveTrack() contract. Audio choices show title, language,
codec, and channel count when present. Subtitle switching and disabling clear
the old cue immediately, and decoded cues are shown only over their
presentation interval. This demo currently uses SubtitleFrame::text():
plain text and the normalized text of ASS/SSA cues are visible, but ASS styling,
positioning, animation, embedded fonts, and bitmap subtitle rectangles are not
rendered by this lightweight Android view overlay. Those remain part of the
production subtitle-composition work tracked in modern/PLAN.md.
The build is Gradle-free and reproducible from the recorded Darwin cross-compilation host. macOS is not a supported QtAVCore target; its former native backends are archived and unmaintained. By default this script uses the same NDK/API/tool versions as the Android regression harness and consumes the repository-local arm64/API 28 vcpkg package containing FFmpeg 8.1.2, OpenSSL, libplacebo 7.351.0, and their transitive dependencies. The FFmpeg configuration has networking and its OpenSSL TLS backend enabled, together with common MP4/MOV, Matroska, AVI, MPEG-TS, FLV, Ogg, MP3, AAC, AC-3, FLAC, and WAV demux/decode support.
Vulkan application-rendered playback delegates color conversion, tone
mapping, output encoding, and FFmpeg-parsed Dolby Vision RPU reshaping to
libplacebo. The optional libdovi parser is intentionally disabled; FFmpeg's
per-frame AVDOVIMetadata is used directly. MediaCodec/AImageReader keeps
decoded pixels off the CPU, with only an external-format-to-FP16 GPU
normalization pass before libplacebo. Dolby Vision frames use an identity
sampler in that pass to preserve raw Y/Cb/Cr for RPU reshaping.
modern/examples/android_player/build-android-player.shThe signed debug APK is written to:
build/android-player/qtav-core-player.apk
No device installation occurs during the build.
The APK assets include third-party notices plus the FFmpeg package's GPLv3, OpenSSL Apache-2.0, and libplacebo LGPL-2.1-or-later license texts. QtAVCore source files remain LGPL-2.1-or-later; the selected repository FFmpeg feature package is a GPLv3 build.
The usual Android tool overrides are supported:
ANDROID_SDK_ROOT
QTAV_ANDROID_NDK_VERSION
QTAV_ANDROID_API
QTAV_ANDROID_COMPILE_SDK
QTAV_ANDROID_BUILD_TOOLS
QTAV_ANDROID_CMAKE_VERSION
QTAV_ANDROID_PLAYER_VERSION_CODE
QTAV_ANDROID_PLAYER_VERSION_NAME
QTAV_BUILD_JOBS
QTAV_HOST_PKG_CONFIG
The default demo package version is 6 / 1.1-fullscreen-tracks. Override
both version variables together for a later reproducible device build; Android
rejects an ordinary replacement when its version code is below the installed
package.
Before running the script, build the local dependency package with
ffmpeg/scripts/build-android.sh on macOS or
ffmpeg/scripts/build-android.ps1 on Windows. The package must exist under
ffmpeg/build/arm64-android-28-static/vcpkg_installed/; workflow artifacts are
not a supported fallback. The player build does not download or independently
compile target FFmpeg, OpenSSL, or libplacebo.
Installation is intentionally gated because recent Android versions can require a physical confirmation before installing or replacing a debug app. First run:
modern/examples/android_player/run-connected-device.shIt stops before adb install. Unlock the one connected device and prepare to
approve its prompt, then explicitly continue:
QTAV_ANDROID_INSTALL_CONFIRMED=1 \
modern/examples/android_player/run-connected-device.shThe script makes one install attempt. On a device-side authorization failure it stops instead of retrying or bypassing the prompt.
| Hardware | ZeroCopy | Vulkan | Active video path |
|---|---|---|---|
| off | either | on | software decode → Vulkan upload/render |
| off | either | off | software decode → OpenGL ES upload/render |
| on | on | on | MediaCodec → private AImageReader/AHardwareBuffer → Vulkan |
| on | on | off | MediaCodec → AImageReader/AHardwareBuffer → EGLImage raw YCbCr → OpenGL ES |
| on | off | either | MediaCodec direct Surface presentation |
Hardware decode, ZeroCopy, Vulkan, and HDR are enabled by default, selecting
the MediaCodec/AImageReader/Vulkan/libplacebo path. This keeps Dolby Vision
Profile 5 base-layer Y/Cb/Cr on the GPU while ensuring that FFmpeg's parsed
RPU metadata reaches libplacebo; direct-Surface presentation would bypass
that application-side reshaping and tone mapping. Disable ZeroCopy explicitly
to test codec direct-Surface presentation. While direct-Surface presentation
is selected, changing the Vulkan switch only preserves the renderer preference
for the next application-rendered path: enabled selects Vulkan and disabled
selects OpenGL
ES. The Debug control remains available because it only shows or hides the
top-left diagnostics. HDR remains independent: MediaCodec/Android supplies
the decoded HDR surface and, on Android 15 or newer, the switch controls that
SurfaceView layer's desired HDR headroom directly. This headroom request
does not convert MediaCodec's direct-Surface PQ/HLG buffers to SDR.
Changing an option that affects the active path preserves the current position, rebuilds the affected decoder/renderer resources, and resumes only if playback was active. Renderer preferences changed while direct-Surface presentation is active are retained without interrupting playback.
All non-direct video rendering runs on one native render thread. Player
retains the exact backend-deferred frame and the application only schedules
renderVideoDetailed() from setRenderCallback(); the demo has no
application-owned MediaCodec retry-frame FIFO or correctness-critical interop
flush around seek, stop, or media replacement. Direct Surface output uses the
core's independent video-decode and presentation workers: encoded packets are
paced before decode and the application releases each small-window output when
its ordinary presentation callback arrives. Vulkan waits up to 100 ms for
image ownership. OpenGL releases non-blockingly and lets the AImageReader
callback wake the native render thread. Both interops retain bounded producer
epoch associations so late invalidated AImages are acquired and returned but
cannot enter the current presentation generation. Audio decode/output remains
on separate workers.
The Debug switch shows or hides the top-left status window without rebuilding
the playback pipeline. Its first lines report the actual Vulkan swapchain or
EGL surface color space; direct-Surface mode instead reports the color metadata
of the buffer being passed through MediaCodec because Android's public
ANativeWindow getter does not expose the producer's final per-buffer
dataspace. They also report the effective HDR-switch policy, Android's current
HDR/SDR headroom ratio when available, and the display's desired maximum HDR
content luminance. The remaining status reports application
callbacks/presented, the rolling rate of successful application presents
(present fps), render-queue drops,
core decoded/delivered, queue/late drops, maximum queue depth, presentation
starvations/maximum starvation time, render timing, AHardwareBuffer cache
imports/hits, and interop queued/acquired/imported/stale counts. During active
Vulkan playback up to three callbacks may be in the renderer's frames-in-flight
ring; this bounded trailing difference is not a dropped frame. A stable run has
core queue/late drops at 0/0, no recurring presentation starvation, render
queue drops at zero, and equal interop queued/acquired counts.
After enough valid video timestamps are observed, the demo requests the
inferred fixed source frame rate from its ANativeWindow and selects the best
matching advertised display mode: the lowest exact multiple when available,
otherwise the fastest mode. Android remains free to keep another physical
mode; the status line reports the source-rate hint and requested display
target. Playback has no per-frame application logcat output; normal logging is
limited to setup, the first Dolby Vision RPU frame, first presentation, and
errors. The diagnostics count Dolby Vision RPU frames and raw YCbCr imports so
the Profile 5 libplacebo path can be distinguished from codec passthrough.
For Vulkan interop they also report acquire/release/fallback fence counts, the
AHardwareBuffer/Vulkan/external formats, and the zero-CPU
map/transfer/staging/upload counters used by the connected-device gate.
HDR selects PreferHdr; disabling it selects deterministic SDR output on an
application renderer. The status line reports whether the actual Vulkan/EGL
surface is HDR or SDR. In direct-Surface mode Android/MediaCodec owns the
decoded surface, so the Vulkan/OpenGL selection does not affect current frames,
but it remains selectable for the next application-rendered path. HDR remains
available. On Android 15 or newer, enabling HDR lets Android choose suitable
headroom for the SurfaceView; disabling it requests no headroom above SDR
white. Neither request retags or tone maps direct MediaCodec output, so a PQ
source can remain a BT.2020/PQ HDR compositor layer with this switch disabled;
the Debug window labels this behavior as codec dataspace passthrough. Earlier
Android releases still pass through direct-surface HDR automatically but do
not expose this per-SurfaceView headroom control.
present fps is measured from successful direct-Surface releases or successful
application-renderer presents over a rolling wall-clock window. It is distinct
from the inferred source rate hint and Android's requested display target;
it falls to 0.0 when presentation stops.
The OpenGL ES MediaCodec ZeroCopy path creates a private GPU-sampled
AImageReader, imports each AHardwareBuffer as an EGLImage, and requires
GL_OES_EGL_image_external_essl3, GL_EXT_YUV_target, and native-fence EGL
sync. The external sampler exposes normalized Y/Cb/Cr in R/G/B. A crop-aware
RGBA16F normalization pass preserves those components without performing
color conversion; libplacebo then applies Dolby Vision reshaping, color
conversion, tone mapping, and output encoding. A device that cannot provide
this raw contract rejects the hardware frame rather than interpreting Profile
5 samples through an implicit RGB conversion.
The Android crop matrix also converts AImage's top-left origin to the
bottom-left external-texture sampling origin. The renderer's generic
FBO-to-libplacebo flipped flag remains unchanged, and Java/SurfaceView adds
no separate transform.
Android window submission occurs through the renderer's present callback
before the AImage is returned. The exported EGL native fence therefore covers
eglSwapBuffers() as well as sampling; presenting only after render()
returned could accumulate unsignalled release fences and stall both video and
the shared demux feed after the AImageReader acquisition quota was exhausted.
For SDR output, the renderer also detects the Android EGL window's sRGB
framebuffer encoding and leaves the final linear-to-sRGB conversion to GL.
This prevents the HDR-off OpenGL ES path from applying sRGB encoding twice and
lifting midtones compared with Vulkan.
Both Vulkan and OpenGL ES AImageReader paths accept and correlate a valid zero timestamp.
FFmpeg's planar 10-bit 4:2:0 software frames map to
PixelFormat::YUV420P10 and are supported by both Vulkan and OpenGL ES.
Turning hardware decode off therefore produces a picture for HEVC Main10
media, although real-time 4K software decode still depends on CPU throughput
and may not keep pace on a phone.
Open local uses Android's Storage Access Framework and retains the detached
read-only file descriptor for the complete QtAVCore playback session. It is
passed through FFmpeg's seekable fd: protocol; the app never attempts to
reopen the provider path or its /proc/self/fd symlink.
Open URL accepts HTTP or HTTPS and passes the URL directly to QtAVCore.
FFmpeg performs HTTP reads and uses the OpenSSL backend for HTTPS; the Java
shell neither downloads the file nor opens a network connection. Peer and
host verification are enabled explicitly. At startup the Java shell reads the
public Android system trust files, concatenates them into an app-private PEM
bundle, and passes that file to FFmpeg's OpenSSL backend; Java still performs
no network I/O. This remains a remote-file player rather than an
adaptive-streaming example. The demo manifest explicitly allows cleartext
HTTP for manual test servers; production applications should remove that
allowance unless their own network-security policy requires it.
Hardware ZeroCopy does not pre-probe the coded video dimensions. Each private
AImageReader starts with a minimal default buffer size; MediaCodec overrides
that default with its decoded output size, and the interop reads each produced
image's actual dimensions and crop. Opening a
remote URL therefore performs one FFmpeg open for playback instead of a
separate metadata request followed by playback.
The decoded image size is independent of the presentation Surface size.
Resizing the video area or changing orientation rebuilds or reconfigures the
Vulkan/EGL presentation target without using the window size as a decoder
buffer size. Republishing the same Android Surface after surfaceChanged() is
enough to refresh an in-place Vulkan swapchain or EGL-surface size change. In
the Java SurfaceView shell, the settled Android display rotation is also
forwarded to the Vulkan render configuration so View-system buffer transforms
do not change the picture aspect after an orientation change. In
direct-Surface mode the demo fits and centers the SurfaceView from the
already-open track or decoded-frame dimensions so MediaCodec does not stretch
the picture to the control area's aspect ratio.
A surfaceChanged()/fullscreen refresh can re-offer the retained latest frame
after renderer invalidation. FFmpeg now reports an already-consumed or
flush-retired MediaCodec output explicitly, and both AImageReader interops
cancel that provisional association as stale. They do not wait for a callback
that the suppressed native release cannot emit, and this lifecycle rule is
independent of coded or window dimensions.
The long-form 4K performance samples used for connected-device checks are
Download/legend.mkv and Download/suzume.mkv. The matching direct-streaming
URL for legend.mkv is
https://2dland.cn/test/legend_of_the_magnate.mkv.
That sample already has a subtitle layer burned into the video pixels. Its
visible text must not be used as evidence that a selectable subtitle track was
decoded, switched, disabled, or timed correctly.
The multi-track selection fixture is:
https://2dland.cn/test/multi_audio_and_subtitle_tracks.mp4
The current file contains one video stream, five selectable audio streams, and five selectable subtitle streams. The repository core track-switch regression uses the same downloaded bytes to exercise paused and playing audio/subtitle switches, subtitle disable/re-enable, and seek continuity.
Native diagnostics:
adb logcat -s QtAVCorePlayerUse one ordinary H.264/AAC SDR file, one HEVC Main10 HDR10 file, and one HTTP/HTTPS URL. For each supported device path, verify picture, audible AAudio output, pause/resume, one slider seek, stop/play, and a background/foreground Surface recreation:
- software decode + Vulkan + SDR;
- software decode + OpenGL ES + SDR;
- software decode + Vulkan + HDR;
- hardware decode + ZeroCopy + Vulkan;
- hardware decode + ZeroCopy + OpenGL ES;
- hardware decode + ZeroCopy off (direct Surface);
- Debug off hides the top-left status window without interrupting playback;
- Debug on restores the status window and reports a stable
present fps; - on a phone, the full-screen button rotates portrait playback into landscape, enters immersive mode, allows transient system bars by edge swipe, and restores the prior orientation request on exit;
- on a large-screen emulator/device or resizable window, full screen does not lock orientation and the controls remain reachable in either layout;
- landscape rotation overlays the controls, auto-hides them after five seconds, and leaves Debug and the active subtitle visible;
- open the multi-track fixture above, select all five audio tracks, select all five subtitle tracks, turn subtitles off and back on, seek during a cue, and repeat a subtitle switch in full screen. The old cue must clear immediately and A/V playback must continue without a media reopen.
Also open one file through the Storage Access Framework and one remote file
through both HTTP and HTTPS where test hosting permits. Treat unsupported
device HDR/codec capabilities as explicit unavailable results, not
as successful coverage. For long-form 4K Vulkan or OpenGL ZeroCopy playback, let the
sample run for at least one minute and verify core queue/late drops 0/0, zero
recurring render-queue drops, bounded interop queued/acquired/imported trailing
counts, and no unbounded callback/presented growth instead of merely checking
that a picture appeared. Repeat OpenGL after fully closing and reopening the
application because surface/buffer-pool reuse is part of this regression gate.