Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cpu-performance-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"dotlottie-android": minor
---

Add `performanceMode` and `cacheId` to `DotLottieGLSurfaceAnimation` and `DotLottieGLAnimation` to support keeping the player alive across surface recreation (CPU mode). Add `onSurfaceReady` callback to `DotLottieEventListener` and implement player caching and frame restoration. Fix safe GL context destruction and player lifecycle management in CPU mode.
2 changes: 1 addition & 1 deletion dotlottie/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dotlottieRust {
}

group = "com.github.LottieFiles"
version = "0.13.7"
version = "9.9.9-LOCAL"

android {
namespace = "com.lottiefiles.dotlottie.core"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ fun DotLottieGLAnimation(
eventListeners: List<DotLottieEventListener> = emptyList(),
threads: UInt? = null,
loopCount: UInt = 0u,
performanceMode: Int = 0,

cacheId: String = "",
) {
// TODO: Add HardwareBuffer support for API 31+
DotLottieGLSurfaceAnimation(
Expand All @@ -58,5 +61,7 @@ fun DotLottieGLAnimation(
eventListeners = eventListeners,
threads = threads,
loopCount = loopCount,
performanceMode = performanceMode,
cacheId = cacheId,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ internal fun DotLottieGLSurfaceAnimation(
eventListeners: List<DotLottieEventListener> = emptyList(),
@Suppress("UNUSED_PARAMETER") threads: UInt? = null,
loopCount: UInt = 0u,
performanceMode: Int = 0,

cacheId: String = "",
) {
val lifecycleOwner = LocalLifecycleOwner.current

Expand Down Expand Up @@ -101,7 +104,9 @@ internal fun DotLottieGLSurfaceAnimation(

onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
glWidgetRef[0]?.destroy()
val widget = glWidgetRef[0]
widget?.onPause() // Stop render loop immediately
widget?.destroy()
glWidgetRef[0] = null
}
}
Expand All @@ -112,6 +117,9 @@ internal fun DotLottieGLSurfaceAnimation(
GLWidget(ctx).also { widget ->
glWidgetRef[0] = widget

widget.setPerformanceMode(performanceMode)
widget.setCacheId(cacheId)

// Add event listeners
eventListeners.forEach { widget.addEventListener(it) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ interface DotLottieEventListener {
fun onUnFreeze() {}
fun onDestroy() {}
fun onError(error: Throwable) {}
fun onSurfaceReady() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlin.jvm.JvmStatic

@ExperimentalDotLottieGLApi
class DotLottieGLAnimation @JvmOverloads constructor(
Expand All @@ -55,6 +56,9 @@ class DotLottieGLAnimation @JvmOverloads constructor(

private val sharedGl = SharedGlThread.instance

private var performanceMode: Int = 0
private var cacheId: String = ""

private var dlPlayer: DotLottiePlayer? = null
private var dlConfig: Config? = null
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
Expand Down Expand Up @@ -236,35 +240,73 @@ class DotLottieGLAnimation @JvmOverloads constructor(
surfaceReady = true
glTargetDirty = true

// (Re)create player
if (initialized) {
destroyPlayerOnGlThread()
}
initPlayerOnGlThread()
// CPU mode: player kept alive across surface destroy/create
if (performanceMode == 1 && dlPlayer != null) {
val player = dlPlayer ?: return@post
sharedGl.makeCurrent(eglSurface)

val player = dlPlayer ?: return@post
sharedGl.makeCurrent(eglSurface)
destroyRenderFbo()
createRenderFbo()
if (renderFboId == 0) return@post

// Create intermediate render FBO
destroyRenderFbo()
createRenderFbo()
if (renderFboId == 0) return@post
// Rebind GL target to new surface on existing player
player.setGlTarget(
sharedGl.eglDisplay.nativeHandle,
eglSurface.nativeHandle,
sharedGl.eglContext.nativeHandle,
renderFboId,
width.toUInt(),
height.toUInt()
)
glTargetDirty = false
player.resize(width.toUInt(), height.toUInt())

// Restore saved frame
if (cacheId.isNotEmpty() && savedFrames.containsKey(cacheId)) {
val savedFrame = savedFrames[cacheId]
if (savedFrame != null) {
player.setFrame(savedFrame)
savedFrames.remove(cacheId)
}
}

player.setGlTarget(
sharedGl.eglDisplay.nativeHandle,
eglSurface.nativeHandle,
sharedGl.eglContext.nativeHandle,
renderFboId,
width.toUInt(),
height.toUInt()
)
glTargetDirty = false
// Render immediately
dirtyFrame = true
sharedGl.requestRender()

// Notify surface ready
post { eventListeners.forEach { it.onSurfaceReady() } }
} else {
// Default / RAM mode: destroy and recreate player
if (initialized) {
destroyPlayerOnGlThread()
}
initPlayerOnGlThread()

val player = dlPlayer ?: return@post
sharedGl.makeCurrent(eglSurface)

// Create intermediate render FBO
destroyRenderFbo()
createRenderFbo()
if (renderFboId == 0) return@post

player.setGlTarget(
sharedGl.eglDisplay.nativeHandle,
eglSurface.nativeHandle,
sharedGl.eglContext.nativeHandle,
renderFboId,
width.toUInt(),
height.toUInt()
)
glTargetDirty = false

// Load pending content or reload last content
val content = pendingContent ?: lastLoadedContent
if (content != null) {
pendingContent = null
loadContentOnGlThread(content)
// Load pending content or reload last content
val content = pendingContent ?: lastLoadedContent
if (content != null) {
pendingContent = null
loadContentOnGlThread(content)
}
}
}
sharedGl.register(this)
Expand Down Expand Up @@ -305,8 +347,19 @@ class DotLottieGLAnimation @JvmOverloads constructor(
surfaceReady = false
sharedGl.handler.post {
sharedGl.unregisterOnGlThread(this)
destroyPlayerOnGlThread()

// Save current frame for all modes if cacheId is set
if (cacheId.isNotEmpty()) {
dlPlayer?.let { player -> savedFrames[cacheId] = player.currentFrame() }
}

// CPU mode: keep player alive, only destroy GL resources
if (performanceMode != 1 || cacheId.isEmpty()) {
destroyPlayerOnGlThread()
}

destroyRenderFbo()

if (eglSurface != EGL14.EGL_NO_SURFACE) {
sharedGl.destroyWindowSurface(eglSurface)
eglSurface = EGL14.EGL_NO_SURFACE
Expand Down Expand Up @@ -397,11 +450,29 @@ class DotLottieGLAnimation @JvmOverloads constructor(
loopCount = attrLoopCount
)

dlPlayer = if (attrThreads != null) {
DotLottiePlayer.withThreads(config, attrThreads!!)
if (performanceMode == 1 && cacheId.isNotEmpty()) {

val cachedPlayer = playerCache[cacheId]
if (cachedPlayer != null) {
dlPlayer = cachedPlayer
} else {
dlPlayer =
if (attrThreads != null) {
DotLottiePlayer.withThreads(config, attrThreads!!)
} else {
DotLottiePlayer(config)
}
playerCache[cacheId] = dlPlayer!!
}
} else {
DotLottiePlayer(config)
dlPlayer =
if (attrThreads != null) {
DotLottiePlayer.withThreads(config, attrThreads!!)
} else {
DotLottiePlayer(config)
}
}

dlConfig = config
initialized = true

Expand Down Expand Up @@ -479,6 +550,8 @@ class DotLottieGLAnimation @JvmOverloads constructor(
)
glTargetDirty = false

if (!(performanceMode == 1 && player.isLoaded())) {

when (content) {
is DotLottieContent.Json -> {
player.loadAnimationData(
Expand All @@ -501,6 +574,15 @@ class DotLottieGLAnimation @JvmOverloads constructor(
if (!smId.isNullOrEmpty()) {
stateMachineLoad(smId)
stateMachineStart()
}
}

if (cacheId.isNotEmpty() && savedFrames.containsKey(cacheId)) {
val savedFrame = savedFrames[cacheId]
if (savedFrame != null) {
player.setFrame(savedFrame)
savedFrames.remove(cacheId)
}
}

// Always render at least one frame so events (e.g. Load) get polled and dispatched
Expand Down Expand Up @@ -567,6 +649,14 @@ class DotLottieGLAnimation @JvmOverloads constructor(

// ==================== Public API ====================

fun setPerformanceMode(mode: Int?) {
performanceMode = mode ?: 0
}

fun setCacheId(id: String?) {
cacheId = id ?: ""
}

fun load(config: ViewConfig) {
val newDlConfig = Config(
autoplay = config.autoplay,
Expand Down Expand Up @@ -769,6 +859,7 @@ class DotLottieGLAnimation @JvmOverloads constructor(
}

fun destroy() {
if (performanceMode == 1) return // Keep alive in CPU mode
sharedGl.handler.post { destroyPlayerOnGlThread() }
}

Expand Down Expand Up @@ -980,13 +1071,16 @@ class DotLottieGLAnimation @JvmOverloads constructor(
// ==================== View Lifecycle ====================

override fun onDetachedFromWindow() {
paused = true // STOP IMMEDIATELY
super.onDetachedFromWindow()
loadJob?.cancel()
coroutineScope.cancel()
surfaceReady = false
sharedGl.handler.post {
sharedGl.unregisterOnGlThread(this)
destroyPlayerOnGlThread()
if (performanceMode != 1) {
destroyPlayerOnGlThread()
}
destroyRenderFbo()
if (eglSurface != EGL14.EGL_NO_SURFACE) {
sharedGl.destroyWindowSurface(eglSurface)
Expand Down Expand Up @@ -1016,5 +1110,19 @@ class DotLottieGLAnimation @JvmOverloads constructor(

companion object {
private const val TAG = "DotLottieGLAnimation"
// Global cache of players keyed by cacheId
val playerCache = mutableMapOf<String, DotLottiePlayer>()
// Saved frames for RAM mode keyed by cacheId
val savedFrames = mutableMapOf<String, Float>()

/**
* Release a cached player instance to prevent memory leaks.
* Should be called when the animation is no longer needed across screens.
*/
@JvmStatic
fun releaseCache(cacheId: String) {
playerCache.remove(cacheId)?.destroy()
savedFrames.remove(cacheId)
}
}
}