Disclaimer: This project is a Work in Progress (WIP).
This repository integrates Media3 ExoPlayer with Godot XR. It intentionally targets XR: Godot currently exposes the required Android Surface through an OpenXR composition layer, so non-XR Android view or texture output is outside this plugin's supported scope.
- Android Surface Retrieval: Utilizes the new capability in Godot 4.4 to obtain Android surfaces from plugins.
- ExoPlayer Integration: Embeds ExoPlayer for robust media playback with support for various formats and streaming protocols.
- Configurable Playback: Supports plain playback, optional Widevine DRM, cache-backed data sources, repeat mode, playback speed, volume, track selection, subtitles, and player state signals.
- Reusable Config Resources: Optional source, DRM, and audio Resources provide inspector-friendly configuration while the dictionary API remains available for advanced extensions.
- Godot Audio Routing: Optionally sends decoded PCM through Godot audio players and buses, with queue diagnostics and restoration of caller-owned audio nodes.
- Live Playback: Live/DVR window snapshots, wall-clock position, target latency, and a Go live action.
- Recovery and Diagnostics: Structured errors, opt-in bounded reconnect, credential refresh, and a reusable diagnostics panel.
- Media Queues: Per-item headers, DRM, external subtitles, next/previous, removal, shuffle, and transition events.
- Quality Preferences and Subtitles: Resolution/bitrate limits, language preferences, detailed text cues, and a Godot subtitle overlay.
- Cache Management: Size/resource statistics, cache/network byte counters, and asynchronous media removal or clearing.
- Godot Engine Compatibility: Designed specifically for Godot Engine 4.4.
- Godot Engine 4.4: Ensure you have the latest version installed.
- Android Development Environment: Set up Android Studio or an equivalent environment to build and deploy Android plugins.
- Clone this repository:
git clone https://github.com/bnjmntmm/godot-exoplayer.git
- Build the Addon yourself using Android Studio and Gradle or use the prebuilt inside from godot_exoplayer
- Test out the demo in main
-
Enable the Plugin: Activate the ExoPlayer plugin in your Godot project settings.
-
Surface Binding: Add an
ExoPlayerCompositionLayernode, or create an OpenXRCompositionLayer and selectuse_android_surface. -
Retrieve the Surface: Use the
get_android_surface()method to obtain the Android surface from the OpenXRCompositionLayer. -
Create a player through the
ExoPlayerautoload:var surface = $OpenXRCompositionLayer.get_android_surface() var player_id = ExoPlayer.create_url_player(surface, "https://example.com/video.m3u8") ExoPlayer.play(player_id)
Plain URL playback is the default workflow and does not require DRM configuration. Use
create_player(...)directly when composing custom options, or the dedicatedcreate_drm_player(...)helper for protected content.To route decoded audio through Godot's mixer and audio buses:
var player_id = ExoPlayer.create_url_player(surface, video_uri, { "routeAudioToGodot": true, "godotAudioPlayer": $AudioStreamPlayer3D })
The addon registers ExoPlayerCompositionLayer, a convenience node that extends OpenXRCompositionLayerQuad. Add it under your XR origin, set video_uri in the inspector, and enable create_on_ready to create the player automatically.
The node exposes the common player config in the inspector, including autoplay, volume, repeat mode, playback speed, cache, Widevine license URL, request headers, and Godot audio routing. It emits player-specific player_ready, player_error, video_end, and player_state_changed signals, and exposes helper methods such as play(), pause(), seek_to(), set_media(), and release_player().
For reusable inspector configuration, assign any of:
ExoPlayerSourceConfig: URL, media request headers, user agent, redirect policy, cache limit, and HLS date-time inspection.ExoPlayerDrmConfig: optional Widevine, ClearKey, PlayReady, or custom-UUID DRM plus license request headers. Leave its scheme atNONEfor clear content.ExoPlayerAudioConfig: Android or Godot output, spatial split mode, and generator buffer length.
The composition layer waits up to surface_wait_timeout for its OpenXR Android surface. The old creation_delay remains available for project-specific staging, but is no longer required as the primary surface-readiness mechanism.
For Godot-routed audio, enable route_audio_to_godot. If godot_audio_player_path points to an AudioStreamPlayer or AudioStreamPlayer3D, the wrapper assigns an AudioStreamGenerator to that player and pushes decoded ExoPlayer PCM into it. If the path is empty, the wrapper creates an internal AudioStreamPlayer. For AudioStreamPlayer3D, scene settings such as transform, bus, attenuation, and spatial behavior remain under your control; the wrapper only manages the stream and volume_linear.
create_player(surface, uri, options := {}) accepts an optional dictionary for features that are not needed by every project:
var player_id = ExoPlayer.create_player(surface, video_uri, {
"autoplay": true,
"volume": 0.8,
"repeatMode": 0,
"playbackSpeed": 1.0,
"useCache": false,
"parseProgramDateTime": false,
"debugLogging": false,
"routeAudioToGodot": true,
"godotAudioPlayer": $AudioStreamPlayer3D,
"godotAudioBufferLength": 0.5,
"drm": {
"scheme": "widevine",
"licenseUrl": license_url,
"requestHeaders": {
"Authorization": "Bearer <token>"
}
}
})For a clearer protected-content call site, the same DRM setup can be written as:
var player_id = ExoPlayer.create_drm_player(
surface,
video_uri,
license_url,
{"Authorization": "Bearer <token>"},
{"autoplay": true}
)Use set_url(id, uri) to switch a DRM player back to clear content, or
set_drm_media(id, uri, license_url, headers) to change it to protected content.
Supported options:
autoplay: Starts playback after the player is prepared. Defaults tofalse.volume: Initial player volume from0.0to1.0. Defaults to1.0.repeatMode: ExoPlayer repeat mode integer. Defaults to off.playbackSpeed: Initial playback speed. Defaults to1.0.useCache: Enables the plugin cache data source. Defaults tofalse.cacheMaxBytes: Shared LRU streaming-cache limit. Defaults to 256 MiB; the first active cache configuration owns the shared limit.requestHeaders: Headers applied to media and manifest requests.userAgent: Optional HTTP user agent.allowCrossProtocolRedirects: Allows HTTP-to-HTTPS or HTTPS-to-HTTP redirects. Defaults tofalse.pauseOnAppPause: Pauses active playback while the Android XR app is backgrounded and resumes only players that were previously playing. Defaults totrue.parseProgramDateTime: Enables the legacygetProgramDateTime()query for the current timeline window's UTC start time. Defaults tofalse. Uses Media3's timeline, including master-playlist resolution and live updates, instead of fetching/parsing the URL separately.debugLogging: Enables verbose Media3 logging and track debug output. Defaults tofalse.routeAudioToGodot: Routes decoded ExoPlayer PCM through Godot instead of Android's audible audio output. Defaults tofalse.godotAudioPlayer: OptionalAudioStreamPlayerorAudioStreamPlayer3Dused for routed audio. The wrapper creates and assigns anAudioStreamGenerator; you do not need to create the generator yourself.godotAudioBufferLength: AudioStreamGenerator buffer length in seconds for routed audio. Defaults to0.5.drm: Optional DRM config. Widevine is currently supported withscheme = "widevine"andlicenseUrl.
When routeAudioToGodot is enabled, ExoPlayer's Android audio output stays muted and setPlayerVolume controls the Godot audio player volume. The Kotlin plugin exposes pollAudioFrames(id, maxFrames), getAudioFormat(id), and clearAudioBuffer(id) for the wrapper's polling path.
get_audio_bridge_stats(id) returns the current format, queued frames, dropped frames, and underrun frames. Godot routing is intended for bus effects, visualization, or spatial placement. It uses a separate Godot audio clock, so applications should validate latency and A/V synchronization on their target XR hardware.
The old helper still works for simple Widevine usage:
var player_id = ExoPlayer.create_exoplayer_instance(surface, video_uri, license_url)The wrapper exposes playback controls such as play, pause, seekTo, seekBy, setMedia, setPlayerVolume, setRepeatMode, and setPlaybackSpeed.
It also exposes getVideoResolutions, setVideoResolution, getAvailableAudioTracks, setAudioTrack, getAvailableTextTracks, setTextTrack, getCurrentPlaybackPosition, getVideoDuration, and getProgramDateTime.
The preferred structured track API is get_video_tracks, get_audio_tracks, and get_subtitle_tracks, paired with select_video_track, select_audio_track, and select_subtitle_track. Track dictionaries contain their exact index, selected/supported state, format metadata, and type-specific properties. The older resolution and camelCase helpers remain for compatibility.
Signals:
player_created(id)— the native player was successfully constructed.player_ready(id, duration)player_error(id, error_message)video_end(id)player_state_changed(id, state)subtitle_cues(id, cues)— selected subtitle text for rendering in Godot UI.
- Currently experimental and may contain bugs or incomplete features.
- Only supports Android OpenXR composition-layer surfaces; this is intentionally an XR plugin.
- Godot-routed audio requires target-device validation for latency and A/V drift under XR load.
- May lack complete documentation and features
- Only supports Version 4.4 and onwards (hopefully)
Set liveTargetOffsetMs in player/item options (or ExoPlayerSourceConfig.live_target_offset_ms).
-1 keeps the stream defaults; 0 or greater requests a target offset in milliseconds.
This is a latency target, not a guarantee or an override of the source's capabilities.
var id = ExoPlayer.create_url_player(surface, stream_url, {
"autoplay": true,
"liveTargetOffsetMs": 4000
})
var live = ExoPlayer.get_live_state(id)
if live.get("is_live", false):
ExoPlayer.go_live(id)get_live_state() returns is_live, is_dynamic, is_seekable, live_offset_ms,
position_ms, window_duration_ms, default_position_ms, window_start_unix_ms,
playback_unix_ms, and target_offset_ms. Unknown times are -1. Positions are relative
to the current window; its start can move. go_live() seeks to the source's default
live position and preserves the playing/paused state. is_live() and is_seekable()
are convenience queries. live_state_changed(id, state) is emitted when the timeline changes;
poll a snapshot for a continuously changing position or offset.
The legacy getProgramDateTime() returns the current window's UTC start as an ISO string
when enabled, or an empty string if unknown. Use playback_unix_ms for the current media position.
get_playback_stats(id) collects one snapshot on the native player thread. It includes:
- Playback position/duration, buffered position/duration, state, playing state and play intent.
- Video width/height, bitrate, frame rate, decoder name, dropped frames and bandwidth estimate.
- Buffering count/time, cache/network bytes, retry attempts, current media ID and playlist index.
- Nested
liveandaudio_bridgedictionaries; the latter retains the existing PCM diagnostics.
Counters cover a player session, including its playlist; buffering_count includes initial preparation.
Network bytes count media/source transfers, not DRM license traffic. Unknown format values are -1.
PCM queue counters measure the bridge, not end-to-end A/V latency.
Add an ExoPlayerDiagnosticsPanel to a Control/SubViewport and assign its player_id.
It refreshes twice per second by default and offers previous/next, play/pause, Go live,
retry and quality controls. The existing demo video controls expose it via Diagnostics.
Avoid querying native snapshots every rendered frame: the queries synchronize with Android's UI thread.
The existing string player_error signal remains. playback_error(id, error) additionally
provides code, code_name, message, http_status (0 if absent), retryable and media_id.
The structured message uses the error name so it does not embed signed request URLs.
ExoPlayer.set_retry_policy(id, {
"maxAttempts": 3,
"baseDelayMs": 1000,
"maxDelayMs": 8000,
"liveAtDefaultPosition": true
})
# Explicit recovery starts a fresh retry budget and resumes playback.
ExoPlayer.retry(id) # Live: default live position; VOD: retained position.
ExoPlayer.retry(id, false) # Keep the current position, including for live streams.Automatic recovery is disabled by default. Configure it through set_retry_policy(),
the initial retryPolicy options dictionary, or an ExoPlayerRetryConfig on the layer.
Retries use exponential backoff, capped by maxDelayMs. The budget is per media item,
reset by changing items, setting a policy, credential refresh, or explicit retry().
It does not reset merely because a failing stream briefly becomes ready.
Transient connection/timeouts, behind-live-window errors, HTTP 408/429 and HTTP 5xx
are eligible; decoder/format failures and other HTTP errors do not loop automatically.
Signals are retry_scheduled(id, attempt, delay_ms) and retry_exhausted(id, error).
Pause, stop, release, source replacement and queue navigation cancel pending retries.
HTTP 401/403 emits authentication_required(id, error) and waits for the app:
func _on_authentication_required(id: int, _error: Dictionary) -> void:
var media_headers = await obtain_fresh_media_headers() # Your authentication service.
ExoPlayer.refresh_credentials(id, media_headers, {})refresh_credentials(id, media_headers, license_headers := {}) replaces the current
item's complete header sets, recreates its source, retains the queue and VOD position,
and resumes playback. For DRM, supply fresh license headers in the third argument too.
Connect this signal before playback starts. The plugin does not obtain tokens itself.
If your token is embedded in the URL, supply a new source with set_url()/set_drm_media();
those operations replace the queue.
play() also prepares a stopped player. Readiness follows the current native state;
background pausing covers buffering players with play intent as well as actively playing ones.
ExoPlayer.set_track_preferences(id, {
"maxWidth": 1920, "maxHeight": 1080, "maxBitrate": 6000000,
"audioLanguage": "de", "textLanguage": "en", "subtitlesEnabled": true
})Only supplied keys change. -1 removes a size/bitrate limit; an empty language clears
that language preference. Preferences apply across playlist items. They clear manual
track overrides by default; pass clearOverrides: false to keep them. Exact selection
with select_video_track/select_audio_track/select_subtitle_track remains available.
tracks_changed(id) announces track-list changes, and selected_tracks_changed(id)
also covers downstream format changes during adaptive playback. Fetch fresh track
lists after an item transition: indices are not stable across items.
Use the initial trackPreferences options dictionary or an ExoPlayerTrackPreferences
Resource on the composition layer to apply preferences before preparation.
var options = {"subtitles": [{
"uri": "https://example.com/captions.vtt", "mimeType": "text/vtt",
"language": "en", "label": "English", "default": true
}]}
var id = ExoPlayer.create_url_player(surface, video_url, options)
$SubtitleView.player_id = idExternal subtitles use the item's data source and request headers. An empty subtitles
array removes inherited external subtitles when changing media. Inspector configuration
uses ExoPlayerSourceConfig.subtitles, an array of ExoPlayerSubtitleConfig Resources.
subtitle_cues_detailed(id, cues) supplies text, alignment, fractional position/width,
position anchor, line value/type/anchor, line_set and a bitmap-presence flag.
The original plain-string subtitle_cues signal remains supported.
ExoPlayerSubtitleView renders plain text cues, common alignment/position information,
and bottom stacking. Set its player_id and size it over the video in a Control or XR
SubViewport. The demo does this automatically. It is not a full ASS/TTML styling or
bitmap subtitle renderer; font spans, vertical writing and bitmap pixels are not transferred.
ExoPlayer.enqueue(id, next_url, {"mediaId": "next-episode"})
ExoPlayer.enqueue(id, protected_url, {
"mediaId": "premium-episode",
"requestHeaders": media_headers,
"drm": {"scheme": "widevine", "licenseUrl": license_url, "requestHeaders": license_headers}
})
ExoPlayer.next(id)
ExoPlayer.previous(id)
ExoPlayer.seek_playlist_item(id, 0) # Default position of an item; does not force playback.
ExoPlayer.remove_playlist_item(id, 1)
ExoPlayer.set_shuffle(id, true)
var queue = ExoPlayer.get_playlist(id)Each item has its own headers, DRM, subtitles and live target. Omitted headers/DRM on
an enqueued item mean clear playback with no media headers; they are not copied from
the previous item. Audio routing and buffer durations belong to the player and cannot
change through enqueue. IDs must be unique within a queue; missing IDs are generated.
get_playlist() returns index, media_id, shuffle and an items array of index/ID
dictionaries, without exposing request URLs or credentials. Indices refer to the original
queue order even with shuffle enabled.
playlist_changed(id) announces timeline/queue updates. media_item_transition(id, media_id, index, reason) reports transitions with Media3's reason integer.
Existing repeat modes apply to the queue. set_media()/set_url()/set_drm_media()
replace it with one item; video_end denotes reaching the end of playback, not every transition.
var cache = ExoPlayer.get_cache_stats()
print(cache.get("size_bytes", 0))
# Release every plugin player before deleting cached data.
ExoPlayer.release_player(id)
ExoPlayer.remove_cached_media(video_url)
# Or explicitly remove all cached media:
# ExoPlayer.clear_cache()get_cache_stats() returns initialized, busy, size_bytes, max_bytes and
resource_count. It opens an existing disk cache when necessary. Inspecting the cache
before the first cached player establishes the default 256 MiB shared limit; create
your configured cached player first if you need a different limit.
Removal is asynchronous and reports cache_operation_completed(success, message).
It is rejected while any plugin player exists or another removal is running. New player
creation is rejected during maintenance. Removal by URI includes that source's cached
manifest/segments/subtitles; namespaces also distinguish request headers. Supply the
exact original URI, including its query. Passing an empty URI to remove_cached_media()
is rejected; use clear_cache() deliberately. Clearing operates on Media3 cache resources,
not arbitrary files. This remains an evictable streaming cache, not an offline downloader.
Entries created by older plugin versions use the old keys and require clear_cache()
for complete removal.
All extended player events are also available through player_event(id, event, data)
on the autoload and are filtered per player on ExoPlayerCompositionLayer. The layer
has convenience methods for snapshots, retry, credential refresh, preferences and queue
controls. Cache operations remain global on the autoload.
Contributions are welcome! If you encounter issues or have suggestions, feel free to open an issue or submit a pull request. As this is an experimental project, active collaboration will help shape its development.