Skip to content

514sid/gapless

Repository files navigation

Gapless

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.

Tested on

Device Result
Amazon Fire Stick 4K UHD video gapless playback

Features

  • 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

Installation

dependencies {
    implementation("io.github.514sid:gapless:0.0.13")
}

Also available via GitHub Packages and JitPack (see setup notes below).


Quick Start

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)
        }
    }
}

API

GaplessPlaylistManager

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.

GaplessPlayer

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

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

GaplessEvent

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.


Shuffle

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.


Sample App

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 to PlayerService in :player. When :player dies, onServiceDisconnected fires and the watchdog automatically restarts both the service and the activity.
  • PlayerActivity uses singleInstance launch mode, so only one player instance ever exists.
  • MainActivity immediately calls finish() 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 reboot

Web Page Support

Web 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.


How It Works

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.


Alternative Repositories

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")
}

License

MIT. See LICENSE.md.

About

No description or website provided.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages