A Jetpack Compose library for seamless, gapless media playback on Android. Designed for digital signage, kiosks, and any display that must never show a black frame between assets.
Plays video, images, and web content in a continuous loop. Transitions are preloaded before the current asset ends, so the switch is instantaneous.
| Device | Result |
|---|---|
| Amazon Fire Stick 4K | UHD video gapless playback |
- Zero black frames — next asset is buffered before the current one finishes
- Mixed media — video (MP4, HLS, DASH, RTSP via ExoPlayer), images (Coil), and web pages (WebView) in the same playlist
- Rotation — built-in 0/90/180/270 degree content rotation without affecting the composable's layout bounds
dependencies {
implementation("io.github.514sid:gapless:0.0.13")
}Also available via GitHub Packages and JitPack (see setup notes below).
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
data class Asset(val spec: GaplessAsset, val durationMs: Long)
val assets = listOf(
Asset(GaplessAsset(id = "promo-video", uri = "https://example.com/promo.mp4", mimeType = "video/mp4"), 15_000L),
Asset(GaplessAsset(id = "poster", uri = "https://example.com/poster.jpg", mimeType = "image/jpeg"), 8_000L),
)
var index = 0
var timerJob: Job? = null
val manager = GaplessPlaylistManager(scope = lifecycleScope)
manager.start(assets[index++].spec)
lifecycleScope.launch {
manager.events.collect { event ->
if (event is GaplessEvent.Started) {
timerJob?.cancel()
val current = assets.first { it.spec.id == event.asset.id }
val next = assets[index++ % assets.size]
manager.prepareNext(next.spec)
timerJob = launch {
delay(current.durationMs)
manager.play(next.spec)
}
}
}
}
setContent {
GaplessPlayer(modifier = Modifier.fillMaxSize(), manager = manager)
}
}
}Owns the playback loop. Create it once, call start with a callback that returns the next asset to play.
val manager = GaplessPlaylistManager(
scope = lifecycleScope, // cancelled automatically with the scope
preloadMs = 3_000, // how early to buffer the next asset
)Call start with the first asset. On each Started event, call prepareNext to begin buffering the next asset and play when it is time to transition. The host controls all timing.
val durations = mapOf("promo-video" to 15_000L, "poster" to 8_000L)
manager.start(firstAsset)
lifecycleScope.launch {
manager.events.collect { event ->
if (event is GaplessEvent.Started) {
val next = nextAsset()
manager.prepareNext(next)
launch {
delay(durations[event.asset.id] ?: return@launch)
manager.play(next)
}
}
}
}| Method / Property | Description |
|---|---|
start(asset) |
Begin playback with the given asset. |
prepareNext(asset) |
Start buffering the next asset. Call this early — as soon as Started fires — so it is ready when play is called. |
play(asset) |
Transition to the asset. If already preloading, transitions immediately. If not, prepares it first and plays as soon as the renderer is ready. |
stop() |
Cancel all coroutines and halt playback. |
events: SharedFlow<GaplessEvent> |
Stream of playback events (collect in a coroutine). |
currentState: StateFlow<GaplessPlaybackState?> |
Currently-playing asset, playback ID, and start timestamp. |
The Compose entry point. Pair it with a GaplessPlaylistManager.
GaplessPlayer(
modifier = Modifier.fillMaxSize(),
manager = manager,
rotation = GaplessRotation.Deg90,
videoConfig = GaplessVideoConfig( // optional; shown with non-default values
enableDecoderFallback = true,
minBufferMs = 2_000,
maxBufferMs = 8_000,
bufferForPlaybackMs = 1_000,
bufferForPlaybackAfterRebufferMs = 2_000,
),
webConfig = GaplessWebConfig( // optional; shown with non-default values
enableChromeDebugging = true, // enable for development builds
allowThirdPartyCookies = true,
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW,
userAgent = null, // null uses the WebView system default
),
)When the asset list is empty, the player renders nothing (transparent/black).
GaplessAsset(
id = "unique-id", // stable across list updates
uri = "https://...", // local path, content://, or remote URL
mimeType = "video/mp4", // determines the renderer
width = 1920, // optional, used for aspect ratio before first frame
height = 1080,
volume = 0f, // video only: 0.0 (silent) to 1.0 (full)
// Video only
durationMs = 10_000, // optional; clip the video so the next one can preload early
// Web assets only
refreshIntervalMs = 60_000
)durationMs (video only). Without it, a clip plays its full natural length and ExoPlayer only begins
buffering the next clip in the final maxBufferMs window, so a long clip can transition with a rebuffer.
Setting durationMs clips the video to that length, which lets ExoPlayer start loading the next clip far
earlier. For the largest benefit, pair it with a GaplessVideoConfig.maxBufferMs at least as
large as the clip:
GaplessPlayer(
manager = manager,
videoConfig = GaplessVideoConfig(maxBufferMs = 12_000), // >= the clip length
)The host still drives the transition with play(). If a clip reaches its durationMs end before then, it holds
on the last frame and never auto-advances. Leave durationMs null for live streams (HLS/DASH/RTSP).
MIME type to renderer mapping:
| MIME type | Renderer |
|---|---|
video/*, HLS, DASH, RTSP |
ExoPlayer |
image/* |
Coil |
| anything else | WebView |
Collect from manager.events:
lifecycleScope.launch {
manager.events.collect { event ->
when (event) {
is GaplessEvent.Started -> log("Playing: ${event.asset.id} [${event.playbackId}]")
is GaplessEvent.Ended -> log("Finished: ${event.asset.id}")
is GaplessEvent.Preloading -> log("Buffering next: ${event.asset.id}")
is GaplessEvent.PlaybackError -> log("Error on ${event.asset.id}: ${event.message}")
is GaplessEvent.PreloadMissed -> log("Preload not ready for ${event.asset.id}: took ${event.elapsedMs}ms")
}
}
}PlaybackError stops the loop — the host decides whether to call start() again, retry, or stay blank.
Manage the shuffled order in the host and push assets via prepareNext.
Shuffle, reshuffle every cycle:
data class Asset(val spec: GaplessAsset, val durationMs: Long)
val all: List<Asset> = listOf(/* ... */)
val shuffled = all.shuffled().toMutableList()
var index = 0
val durations = all.associate { it.spec.id to it.durationMs }
fun nextShuffled(): Asset {
if (index >= shuffled.size) {
shuffled.shuffle()
index = 0
}
return shuffled[index++]
}
manager.start(nextShuffled().spec)
lifecycleScope.launch {
manager.events.collect { event ->
if (event is GaplessEvent.Started) {
val next = nextShuffled()
manager.prepareNext(next.spec)
launch {
delay(durations[event.asset.id] ?: return@launch)
manager.play(next.spec)
}
}
}
}Prevent the last-played asset from appearing first after a reshuffle:
fun nextShuffled(): Asset {
if (index >= shuffled.size) {
val last = shuffled.last()
shuffled.shuffle()
if (shuffled.size > 1 && shuffled.first().spec.id == last.spec.id) Collections.swap(shuffled, 0, 1)
index = 0
}
return shuffled[index++]
}Wire it up the same way as above — prepareNext + delayed play.
The included sample demonstrates a production-style digital signage setup where the player runs in a completely separate OS process from the launcher activity.
app process :player process
+-----------------------------------------+ +------------------------------+
| MainActivity (thin launcher) | | PlayerActivity |
| | | GaplessPlayer composable |
| WatchdogService (foreground) --- bind -+----+ GaplessPlaylistManager |
| detects crash, restarts player | | |
+-----------------------------------------+ | PlayerService |
| (keepalive stub) |
+------------------------------+
Why a separate process?
- A crash in the player (ExoPlayer, WebView, Coil) cannot take down the watchdog.
- The watchdog (
WatchdogService) is a foreground service in the main process. It holds a Binder toPlayerServicein:player. When:playerdies,onServiceDisconnectedfires and the watchdog automatically restarts both the service and the activity. PlayerActivityusessingleInstancelaunch mode, so only one player instance ever exists.MainActivityimmediately callsfinish()after starting the watchdog. It has no logic of its own.
Running the sample:
Assets are defined in PlayerActivity.onCreate(). Swap in your own URIs and MIME types to test different content:
val assets = listOf(
GaplessAsset(
id = "promo",
uri = "android.resource://$packageName/raw/promo",
mimeType = "video/mp4",
width = 3840,
height = 2160
) to 10_000L // (asset, durationMs)
)Place raw video files under app/src/main/res/raw/.
Not seeing the Started/Ended/Preloading logs? Some locked-down devices (e.g. Fire TV) ship with
persist.log.tag = I, which raises the global logcat level to INFO and drops every Log.d call. Playback
still runs; the debug logs are just filtered. Whitelist the tag to see them:
adb shell setprop log.tag.GaplessPlayer VERBOSE # app sample tag; lasts until rebootWeb assets use Android WebView and work out of the box for most sites. For PWAs and service-worker-dependent pages (common in digital signage platforms), you must configure ServiceWorkerController before any WebView is created in your process. Add this to your Activity.onCreate() or Application.onCreate():
import android.webkit.ServiceWorkerClient
import android.webkit.ServiceWorkerController
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
ServiceWorkerController.getInstance().apply {
serviceWorkerWebSettings.allowContentAccess = true
serviceWorkerWebSettings.allowFileAccess = true
setServiceWorkerClient(object : ServiceWorkerClient() {
override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? = null
})
}Without this, service workers will fail to make network requests and the page will appear to load but never display content.
Each media type uses a different strategy to eliminate the gap:
Video uses ExoPlayer's native 2-item media queue. When the next video is due, it is added as item[1] while item[0] is still playing. On transition, ExoPlayer seeks to item[1] and drops item[0]. The switch happens inside a single player instance with no surface swap. If the aspect ratio changes between clips, a bitmap snapshot of the last frame is captured and held on screen until the first frame of the next video renders, hiding any resize flicker.
Images maintain two Coil slots (A and B) in the Compose hierarchy at all times. While one slot is visible, the other enqueues the next image with Coil in the background. On transition, the active slot index flips. Both slots stay composed so Coil keeps the decoded bitmap warm.
Web pages keep two WebView instances (A always-on, B created lazily). While one is visible, the other loads the next URL. On transition, the active slot flips and the previous WebView is either blanked (about:blank) or destroyed, so only one view holds live content at a time.
GitHub Packages (requires a token even for public packages):
repositories {
maven {
url = uri("https://maven.pkg.github.com/514sid/gapless")
credentials {
username = System.getenv("GITHUB_ACTOR")
password = System.getenv("GITHUB_TOKEN")
}
}
}JitPack:
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation("com.github.514sid:gapless:v0.0.13")
}MIT. See LICENSE.md.