A powerful and easy-to-use Android video compression library using MediaCodec. Generates compressed MP4 with configurable resolution, bitrate, and codec while maintaining good visual quality.
Add the JitPack repository to your settings.gradle:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}Add the dependency in your module-level build.gradle:
implementation 'com.github.davotoula:LightCompressor-enhanced:Tag'You also need Kotlin coroutines:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"import com.davotoula.lightcompressor.VideoCompressor
import com.davotoula.lightcompressor.VideoQuality
import com.davotoula.lightcompressor.config.Configuration
import com.davotoula.lightcompressor.config.SharedStorageConfiguration
import com.davotoula.lightcompressor.config.SaveLocation
import com.davotoula.lightcompressor.listener.CompressionListener
VideoCompressor.start(
context = applicationContext,
uris = listOf(videoUri),
isStreamable = true,
storageConfiguration = SharedStorageConfiguration(
saveAt = SaveLocation.movies,
subFolderName = "my-videos"
),
configureWith = Configuration(
videoNames = listOf("output.mp4"),
quality = VideoQuality.MEDIUM,
isMinBitrateCheckEnabled = true,
disableAudio = false,
),
listener = object : CompressionListener {
override fun onStart(index: Int) {
// Compression started
}
override fun onProgress(index: Int, percent: Float) {
// Update progress UI (worker thread — post to main thread if needed)
}
override fun onSuccess(index: Int, size: Long, path: String?) {
// Compression finished; path is the output file location
}
override fun onFailure(index: Int, failureMessage: String) {
// Handle error
}
override fun onCancelled(index: Int) {
// Compression was cancelled
}
}
)
// To cancel a running compression:
VideoCompressor.cancel()- H.264 (AVC) and H.265 (HEVC) codec support
- Flexible
VideoResizerAPI for resolution control:VideoResizer.auto— auto-resize based on original dimensionsVideoResizer.scale(0.5)— scale by percentageVideoResizer.limitSize(1920.0)— limit longest side (landscape-oriented bounding box)VideoResizer.limitSize(1920.0, 1080.0)— limit width and height independentlyVideoResizer.limitShortSide(1080.0)— constrain the shorter dimension, orientation-agnostic (preferred for portrait/landscape-neutral targeting)VideoResizer.limitShortSide(1920.0, 1080.0)— same as above, uses the smaller of the two valuesVideoResizer.matchSize(1920.0, 1080.0)— scale to match target dimensions exactly
- GIF to MP4 conversion
- Streamable output (moov atom moved for progressive download)
- Granular bitrate control: specify in Mbps (
videoBitrateInMbps) or bps (videoBitrateInBps) - Audio control (
disableAudio) - Native Android
MediaMuxer(no third-party MP4 muxer)
| Quality | Bitrate multiplier |
|---|---|
| VERY_HIGH | 0.6x original |
| HIGH | 0.4x original |
| MEDIUM | 0.3x original |
| LOW | 0.2x original |
| VERY_LOW | 0.1x original |
videoBitrateInMbps: Int?— custom bitrate in MbpsvideoBitrateInBps: Long?— custom bitrate in bps (takes precedence overvideoBitrateInMbps)isMinBitrateCheckEnabled: Boolean— whentrue, skips compression if source bitrate is below 2 Mbps
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.utils.CompressorUtils
val codec = if (CompressorUtils.isHevcEncodingSupported()) {
VideoCodec.H265 // Better compression, smaller files
} else {
VideoCodec.H264 // Maximum device compatibility (default)
}
val config = Configuration(
videoNames = listOf("output.mp4"),
quality = VideoQuality.MEDIUM,
videoCodec = codec,
)If VideoCodec.H265 is requested on a device that does not support HEVC encoding, onFailure is called with a descriptive error message.
Saves to shared storage (Movies, Pictures, or Downloads).
import com.davotoula.lightcompressor.config.SharedStorageConfiguration
import com.davotoula.lightcompressor.config.SaveLocation
SharedStorageConfiguration(
saveAt = SaveLocation.movies, // or .pictures / .downloads
subFolderName = "my-videos" // optional
)Saves to the app's private external storage directory.
import com.davotoula.lightcompressor.config.AppSpecificStorageConfiguration
AppSpecificStorageConfiguration(
subFolderName = "compressed" // optional subfolder
)Saves to the app's cache directory (may be cleared by the system).
import com.davotoula.lightcompressor.config.CacheStorageConfiguration
CacheStorageConfiguration()Implement StorageConfiguration for full control over where files are written.
import com.davotoula.lightcompressor.config.StorageConfiguration
class MyStorageConfiguration : StorageConfiguration {
override fun createFileToSave(
context: Context,
videoFile: File,
fileName: String,
shouldSave: Boolean
): File {
// Return the File where the output should be written
}
}API < 29
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />API 29 – 32
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>API >= 33 (Photo Picker recommended)
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>HlsPreparer transcodes a local video into multiple HLS VOD renditions (fMP4 segments + m3u8 playlists) for adaptive-bitrate playback.
HlsPreparer.start(context, uri, config, listener): Job— kicks off preparation and returns a coroutineJob.HlsPreparer.cancel()— cancels any running preparation.HlsConfig(ladder, codec, segmentDurationSeconds, disableAudio, singleFilePerRendition)— configuration. Defaults:HlsLadder.default(),VideoCodec.H264, 6 s segments, audio enabled, multi-file output. SetsingleFilePerRendition = trueto emit one combined fMP4 file per rendition (init + every media segment) referenced by#EXT-X-BYTERANGEin the playlist — useful when the consumer wants a single upload per rendition instead of dozens of segment files.HlsLadder— ordered list ofRenditions. UseHlsLadder.default()(360p / 540p / 720p / 1080p / 4K) and chain.drop("1080p", "4K")or.add(Rendition(Resolution.HD_720, 2500))to customise. Renditions whose short side exceeds the source are automatically dropped atstart.Rendition(resolution: Resolution, bitrateKbps: Int)— a single ladder entry.Resolutionis the same enum used byVideoCompressor(SD_360,SD_540,HD_720,FHD_1080,UHD_4K).HlsListener— 8 callbacks:onStart(renditionCount),onRenditionStart(rendition),onSegmentReady(rendition, segment),onRenditionComplete(rendition, summary),onComplete(masterPlaylist),onFailure(error),onProgress(rendition, percent),onCancelled().HlsRenditionSummarycarries the media playlist plus output dimensions, codec string, and the library's chosen filenames. SubclassSimpleHlsListenerif you only care about a subset of events.HlsSegment(file, index, durationSeconds, isInitSegment, isCombinedRendition)— one emitted segment.fileis a temp file that is valid only untilonSegmentReadyreturns — copy or upload it synchronously. In multi-file modeisInitSegment = truefor the per-renditioninit.mp4. In single-file mode the listener receives exactly one callback per rendition withisCombinedRendition = true; the file contains the init segment followed by every media fragment.HlsError(message, failedRenditions, completedRenditions)— delivered toonFailurewhen every rendition fails. Partial failures still triggeronComplete.
Threading: onSegmentReady and onProgress are invoked on a background dispatcher (Dispatchers.Default); all other callbacks are on the main thread.
Output layout (multi-file, default): segments are identified by the rendition's Resolution.label (e.g. 720p). Per rendition, HlsPreparer emits one init.mp4 followed by segment_000.m4s, segment_001.m4s, … The per-rendition media playlist (media.m3u8) is delivered as a String via onRenditionComplete, and the master playlist (master.m3u8) is delivered as a String via onComplete. Persisting playlists and segments to their final destination (disk, CDN, object storage) is the caller's responsibility — a typical layout is master.m3u8 at the root with one subdirectory per rendition label containing media.m3u8, init.mp4, and the segment_NNN.m4s files.
Output layout (single-file): with singleFilePerRendition = true, each rendition produces one <label>.mp4 file (e.g. 720p.mp4) containing the init segment immediately followed by every media fragment. The media playlist references the file via #EXT-X-MAP:URI="<label>.mp4",BYTERANGE="<initLen>@0" and uses #EXT-X-BYTERANGE for each #EXTINF entry, so a typical persisted layout is master.m3u8 at the root with one subdirectory per rendition label containing media.m3u8 and <label>.mp4.
import com.davotoula.lightcompressor.HlsPreparer
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsConfig
import com.davotoula.lightcompressor.hls.HlsError
import com.davotoula.lightcompressor.hls.HlsLadder
import com.davotoula.lightcompressor.hls.HlsListener
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.HlsSegment
import com.davotoula.lightcompressor.hls.Rendition
import com.davotoula.lightcompressor.hls.suggestedFilename
import java.io.File
val outputRoot = File(context.filesDir, "hls-out").apply { mkdirs() }
val config = HlsConfig(
ladder = HlsLadder.default().drop("4K"),
codec = VideoCodec.H264,
segmentDurationSeconds = 6,
disableAudio = false,
)
HlsPreparer.start(
context = applicationContext,
uri = videoUri,
config = config,
listener = object : HlsListener {
override fun onStart(renditionCount: Int) { /* prep UI */ }
override fun onRenditionStart(rendition: Rendition) { /* optional */ }
override fun onSegmentReady(rendition: Rendition, segment: HlsSegment) {
// Called on a background thread. Copy synchronously — the temp
// file is deleted as soon as this method returns.
val dest = File(outputRoot, rendition.suggestedFilename(segment))
dest.parentFile?.mkdirs()
segment.file.copyTo(dest, overwrite = true)
}
override fun onRenditionComplete(rendition: Rendition, summary: HlsRenditionSummary) {
val mediaPlaylistFile = File(outputRoot, summary.playlistFilename)
mediaPlaylistFile.parentFile?.mkdirs()
mediaPlaylistFile.writeText(summary.mediaPlaylist)
}
override fun onComplete(masterPlaylist: String) {
val master = File(outputRoot, "master.m3u8").apply { writeText(masterPlaylist) }
// Hand off to ExoPlayer (requires androidx.media3:media3-exoplayer-hls):
// val source = HlsMediaSource.Factory(DefaultDataSource.Factory(context))
// .createMediaSource(MediaItem.fromUri(Uri.fromFile(master)))
// exoPlayer.setMediaSource(source); exoPlayer.prepare()
}
override fun onFailure(error: HlsError) { /* handle error */ }
override fun onProgress(rendition: Rendition, percent: Float) { /* update UI */ }
override fun onCancelled() { /* cleanup */ }
},
)
// To cancel:
HlsPreparer.cancel()Most real integrations transcode → upload each segment → rewrite the playlist filenames to URLs → publish the rewritten master. The library ships the pieces you need.
The uploader lambda returns HlsUploaded<T>(url, metadata) where T is a caller-chosen type. Use it to thread per-upload data you need later — content hashes, sizes, server IDs, anything — without maintaining a side-channel map. Callers who only want URLs use T = Unit.
import com.davotoula.lightcompressor.hls.HlsConfig
import com.davotoula.lightcompressor.hls.HlsContentTypes
import com.davotoula.lightcompressor.hls.HlsUploaded
import com.davotoula.lightcompressor.hls.HlsUploadHelper
import com.davotoula.lightcompressor.hls.HlsUploadResult
data class UploadedBlob(val sha256: String, val sizeBytes: Long)
suspend fun uploadHls(context: Context, videoUri: Uri): String {
val result: HlsUploadResult<UploadedBlob> = HlsUploadHelper.run(
context = context,
uri = videoUri,
config = HlsConfig(),
) { file, suggestedFilename ->
// Invoked on Dispatchers.IO. Upload the file and return the URL + any metadata
// you need downstream (e.g. for NIP-71 `imeta` tags, signed manifests, analytics).
val contentType = if (suggestedFilename.endsWith(".m3u8")) {
HlsContentTypes.HLS_PLAYLIST
} else {
HlsContentTypes.FMP4_SEGMENT
}
val blob = myUploader.upload(file, filename = suggestedFilename, contentType = contentType)
HlsUploaded(url = blob.url, metadata = UploadedBlob(blob.sha256, blob.sizeBytes))
}
// Look up any upload's metadata by the same identifiers the library already gave you:
// result.uploads[summary.combinedFilename] // single-file rendition
// result.uploads[summary.playlistFilename] // media playlist
// result.uploads[rendition.suggestedFilename(segment)] // individual segment
result.renditions.forEach { summary ->
val renditionFile = summary.combinedFilename ?: return@forEach
val blob = result.uploads[renditionFile]?.metadata ?: return@forEach
println("${summary.rendition.resolution.label}: ${summary.width}x${summary.height}, sha256=${blob.sha256}, ${blob.sizeBytes} bytes")
}
// result.masterPlaylist is already rewritten to point at the uploaded URLs.
// Publish it however you want — write to disk, post to your CDN, insert into a DB row…
return myUploader.uploadText(
content = result.masterPlaylist,
filename = "master.m3u8",
contentType = HlsContentTypes.HLS_PLAYLIST,
).url
}If you only need URLs and don't want to define a metadata type, use Unit:
val result: HlsUploadResult<Unit> = HlsUploadHelper.run(ctx, videoUri) { file, name ->
HlsUploaded(url = myUploader.upload(file).url, metadata = Unit)
}HlsUploadResult.renditions carries per-rendition HlsRenditionSummary objects — use summary.width, summary.height, and summary.codecString to build downstream metadata (e.g. Nostr imeta tags) without re-parsing the master playlist. HlsUploadResult.uploads is a LinkedHashMap preserving the upload timeline: all segments first (across renditions, in emission order), then all media playlists (in rendition order). Map keys match exactly what rendition.suggestedFilename(segment), summary.playlistFilename, and summary.combinedFilename return — consumers can look up entries by the same identifiers they already hold.
If you need custom orchestration (retry, concurrency, progress reporting), wire the pieces yourself:
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.PlaylistRewriter
import com.davotoula.lightcompressor.hls.SimpleHlsListener
import com.davotoula.lightcompressor.hls.suggestedFilename
class UploadingListener(private val uploader: Uploader) : SimpleHlsListener() {
private val segmentUrls = mutableMapOf<Rendition, MutableMap<String, String>>()
private val renditionPlaylistUrls = mutableMapOf<String, String>()
override fun onSegmentReady(rendition: Rendition, segment: HlsSegment) {
val key = rendition.suggestedFilename(segment)
val url = uploader.upload(segment.file, filename = key)
segmentUrls.getOrPut(rendition) { mutableMapOf() }[key] = url
}
override fun onRenditionComplete(rendition: Rendition, summary: HlsRenditionSummary) {
// The media playlist references segments by bare filename (e.g. "init.mp4",
// "segment_000.m4s"), while the rewrite map is keyed by the library's full
// rendition.suggestedFilename(segment) output (e.g. "720p/init.mp4"). Strip the
// "<label>/" prefix before rewriting so the keys match what the playlist actually
// contains. Combined-rendition keys like "720p.mp4" have no prefix to strip.
val prefix = "${rendition.resolution.label}/"
val perRenditionMap =
(segmentUrls[rendition] ?: emptyMap()).entries.associate { (key, url) ->
(if (key.startsWith(prefix)) key.removePrefix(prefix) else key) to url
}
val rewritten = PlaylistRewriter.rewrite(
playlist = summary.mediaPlaylist,
urlMap = perRenditionMap,
)
val url = uploader.uploadText(rewritten, filename = summary.playlistFilename)
renditionPlaylistUrls[summary.playlistFilename] = url
}
override fun onComplete(masterPlaylist: String) {
val rewrittenMaster = PlaylistRewriter.rewrite(masterPlaylist, renditionPlaylistUrls)
uploader.uploadText(rewrittenMaster, filename = "master.m3u8")
}
}Rewrite-map keys are whatever Rendition.suggestedFilename(segment) / HlsRenditionSummary.playlistFilename return — the library guarantees the two are consistent, so your rewrite map just needs to match its own lookups.
.m3u8 playlists must be served as application/vnd.apple.mpegurl. Android's MimeTypeMap does not know this type, so if your upload pipeline derives content types from file extensions you must special-case it. Use HlsContentTypes.HLS_PLAYLIST / HlsContentTypes.FMP4_SEGMENT as the canonical source.
HlsRenditionSummary carries width, height, codecString, playlistFilename, and combinedFilename. Consumers building manifest metadata (e.g. Nostr imeta tags with variant, dim, codec) should read these from the summary rather than regex-parsing the master playlist.
HLS playback on the consumer side requires the Media3 HLS module:
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"The app/ module contains a Jetpack Compose sample app demonstrating the library. Install it via:
Minimum Android SDK: API level 21
Originally forked from AbedElazizShe/LightCompressor. Based on Telegram for Android.

