Skip to content

Use Choreographer-based timing in MotionVideoPlayerCompose#63

Closed
tejpratap46 wants to merge 1 commit into
mainfrom
codex/integrate-android.view.choreographer-in-motionvideoplayercom
Closed

Use Choreographer-based timing in MotionVideoPlayerCompose#63
tejpratap46 wants to merge 1 commit into
mainfrom
codex/integrate-android.view.choreographer-in-motionvideoplayercom

Conversation

@tejpratap46

@tejpratap46 tejpratap46 commented May 21, 2026

Copy link
Copy Markdown
Owner

Motivation

  • Drive playback from display vsync so Compose updates are synchronized with the UI pipeline while keeping video timing accurate.
  • Support videos whose FPS differs from the device refresh rate by advancing frames according to the video time budget rather than assuming one-frame-per-vsync.

Description

  • Replaced the coroutine delay-based playback loop with a Choreographer.FrameCallback loop in modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt.
  • Imported android.view.Choreographer and removed the delay usage, adding nanosecond-based timing and accumulation to compute framesToAdvance from motionConfig.fps (video FPS).
  • Implemented DisposableEffect to register the frame callback and to removeFrameCallback on dispose to avoid leaks and duplicate callbacks.
  • Preserved existing looping and audio reset behavior when playback reaches the end.

Testing

  • Ran ./gradlew :modules:motion-video-player:compileDebugKotlin which failed due to missing Android SDK configuration (ANDROID_HOME or local.properties sdk.dir), so no successful module compile or CI run could be performed in this environment.

Codex Task

Summary by CodeRabbit

  • Refactor
    • Improved video frame playback timing mechanism for more consistent and accurate frame rendering.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

MotionVideoPlayerCompose's frame-advancement mechanism is refactored from a coroutine-based LaunchedEffect loop with time-based delay calls to an Android Choreographer vsync callback approach. The new mechanism computes elapsed time between refresh callbacks, advances currentFrame according to configured fps, and handles frame looping and resource cleanup when advancing past totalFrames.

Changes

Frame Playback Timing

Layer / File(s) Summary
Choreographer-driven frame playback loop
modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt
Imports Choreographer, removes delay import, and replaces LaunchedEffect timing loop with DisposableEffect that registers a FrameCallback. On each vsync, elapsed time deltas are accumulated and used to advance currentFrame based on fps. When advancing past totalFrames, the player loops back to frame 0, stops and releases all activePlayers, and clears the player list. Callback is properly removed in onDispose.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Frame by frame, synced with the screen's heartbeat,
Choreographer whispers when vsync times meet.
No more delays sleeping, just Android's own dance—
Pixels and refresh, in perfect advance. 📹✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: replacing time-based delay with Choreographer-based timing for video playback.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/integrate-android.view.choreographer-in-motionvideoplayercom

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@amazon-q-developer amazon-q-developer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

The migration from coroutine-based delay to Choreographer-based vsync timing is architecturally sound, but there are critical defects that must be fixed before merge:

Critical Issues Found (5):

  1. Off-by-one error: Frame boundary check allows accessing invalid frame index (frame N when valid range is 0 to N-1)
  2. Division by zero: No validation for fps = 0 causing potential ArithmeticException
  3. Race conditions: Unsynchronized access to isPlaying and currentFrame from callback thread
  4. Resource leak: Callback continues posting itself even after disposal, causing unnecessary work and potential memory leaks

Required Actions:

  • Fix the frame boundary condition from <= to <
  • Add fps validation to prevent division by zero
  • Address thread synchronization for state access in the callback
  • Conditionally post the next frame callback only when isPlaying is true

These issues block merge as they can cause crashes, incorrect behavior, and resource leaks in production.


You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.

accumulatedNanos %= frameDurationNanos

val nextFrame = currentFrame + framesToAdvance
if (nextFrame <= totalFrames) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Logic Error: The condition nextFrame <= totalFrames allows playback to advance beyond the last valid frame index. For a video with N frames (0 to N-1), this condition permits currentFrame to be set to N, which is out of bounds. Change the condition to nextFrame < totalFrames to prevent accessing an invalid frame.

Suggested change
if (nextFrame <= totalFrames) {
if (nextFrame < totalFrames) {


val callback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!isPlaying) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Crash Risk: Reading isPlaying inside doFrame without synchronization creates a race condition. The Choreographer callback runs on the main thread, but isPlaying can be modified by UI events simultaneously. Capture isPlaying state when registering the callback instead of reading the mutable property, or ensure thread-safe access.


val nextFrame = currentFrame + framesToAdvance
if (nextFrame <= totalFrames) {
currentFrame = nextFrame

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Crash Risk: Writing to currentFrame inside the Choreographer callback without synchronization creates a race condition. The callback modifies currentFrame on the main thread while other Compose code may be reading it, potentially causing inconsistent state. Use a thread-safe mechanism or ensure all state updates occur on the same thread with proper synchronization.

onDispose { }
} else {
val choreographer = Choreographer.getInstance()
val frameDurationNanos = (1_000_000_000L / motionConfig.fps).coerceAtLeast(1L)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Crash Risk: Integer division by motionConfig.fps can throw ArithmeticException if fps is zero. Add validation to prevent division by zero, or ensure fps is always positive before this calculation.

Suggested change
val frameDurationNanos = (1_000_000_000L / motionConfig.fps).coerceAtLeast(1L)
val frameDurationNanos = if (motionConfig.fps > 0) {
(1_000_000_000L / motionConfig.fps).coerceAtLeast(1L)
} else {
1_000_000_000L / 30 // Default to 30 fps if invalid
}

Comment on lines +86 to +100
if (accumulatedNanos >= frameDurationNanos) {
val framesToAdvance = (accumulatedNanos / frameDurationNanos).toInt().coerceAtLeast(1)
accumulatedNanos %= frameDurationNanos

val nextFrame = currentFrame + framesToAdvance
if (nextFrame <= totalFrames) {
currentFrame = nextFrame
} else {
currentFrame = 0
activePlayers.values.forEach { it.stop(); it.release() }
activePlayers.clear()
}
}
lastFrameTime = currentTime

choreographer.postFrameCallback(this)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Logic Error: The callback continues to post itself via choreographer.postFrameCallback(this) even after cleanup should occur. When isPlaying becomes false or the DisposableEffect is disposed, the callback remains scheduled. Move the check for !isPlaying before posting the next frame callback to prevent unnecessary callback executions after disposal.

Suggested change
if (accumulatedNanos >= frameDurationNanos) {
val framesToAdvance = (accumulatedNanos / frameDurationNanos).toInt().coerceAtLeast(1)
accumulatedNanos %= frameDurationNanos
val nextFrame = currentFrame + framesToAdvance
if (nextFrame <= totalFrames) {
currentFrame = nextFrame
} else {
currentFrame = 0
activePlayers.values.forEach { it.stop(); it.release() }
activePlayers.clear()
}
}
lastFrameTime = currentTime
choreographer.postFrameCallback(this)
if (accumulatedNanos >= frameDurationNanos) {
val framesToAdvance = (accumulatedNanos / frameDurationNanos).toInt().coerceAtLeast(1)
accumulatedNanos %= frameDurationNanos
val nextFrame = currentFrame + framesToAdvance
if (nextFrame <= totalFrames) {
currentFrame = nextFrame
} else {
currentFrame = 0
activePlayers.values.forEach { it.stop(); it.release() }
activePlayers.clear()
}
}
if (isPlaying) {
choreographer.postFrameCallback(this)
}

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
motion-lib 83551a7 Commit Preview URL

Branch Preview URL
May 21 2026, 11:58 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
sdui 83551a7 Commit Preview URL

Branch Preview URL
May 21 2026, 11:58 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
lyrics 83551a7 Commit Preview URL

Branch Preview URL
May 21 2026, 11:59 AM

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the video playback loop in MotionVideoPlayerCompose.kt to use Choreographer for vsync-aligned frame updates, replacing the previous delay-based coroutine loop. Feedback suggests adopting the more idiomatic withFrameNanos API within a LaunchedEffect to simplify lifecycle handling and improve code readability. Additionally, a safety check was recommended to prevent division by zero when calculating frame duration from the FPS configuration.


import android.graphics.Bitmap
import android.media.MediaPlayer
import android.view.Choreographer

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Choreographer import is no longer necessary if you use the more idiomatic withFrameNanos approach for Compose timing.

Comment on lines +68 to 110
DisposableEffect(isPlaying, motionConfig.fps, totalFrames) {
if (!isPlaying) {
onDispose { }
} else {
val choreographer = Choreographer.getInstance()
val frameDurationNanos = (1_000_000_000L / motionConfig.fps).coerceAtLeast(1L)
var lastFrameNanos = 0L
var accumulatedNanos = 0L

val callback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!isPlaying) return

if (lastFrameNanos != 0L) {
accumulatedNanos += (frameTimeNanos - lastFrameNanos).coerceAtLeast(0L)
}
lastFrameNanos = frameTimeNanos

if (accumulatedNanos >= frameDurationNanos) {
val framesToAdvance = (accumulatedNanos / frameDurationNanos).toInt().coerceAtLeast(1)
accumulatedNanos %= frameDurationNanos

val nextFrame = currentFrame + framesToAdvance
if (nextFrame <= totalFrames) {
currentFrame = nextFrame
} else {
currentFrame = 0
activePlayers.values.forEach { it.stop(); it.release() }
activePlayers.clear()
}
}
lastFrameTime = currentTime

choreographer.postFrameCallback(this)
}
delay(10) // Smooth check
}

choreographer.postFrameCallback(callback)

onDispose {
choreographer.removeFrameCallback(callback)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using DisposableEffect with a manual Choreographer.FrameCallback is less idiomatic in Jetpack Compose. A more robust and cleaner approach is to use LaunchedEffect combined with withFrameNanos. This automatically handles the lifecycle (cancellation when isPlaying changes or the composable leaves the tree) and aligns perfectly with the Compose rendering pipeline.

Additionally, I've added a safety check for motionConfig.fps to prevent a potential division by zero.

    LaunchedEffect(isPlaying, motionConfig.fps, totalFrames) {
        if (!isPlaying) return@LaunchedEffect

        val frameDurationNanos = (1_000_000_000L / motionConfig.fps.coerceAtLeast(1)).toLong()
        var lastFrameNanos = 0L
        var accumulatedNanos = 0L

        while (true) {
            withFrameNanos { frameTimeNanos ->
                if (lastFrameNanos != 0L) {
                    accumulatedNanos += (frameTimeNanos - lastFrameNanos).coerceAtLeast(0L)
                }
                lastFrameNanos = frameTimeNanos

                if (accumulatedNanos >= frameDurationNanos) {
                    val framesToAdvance = (accumulatedNanos / frameDurationNanos).toInt()
                    accumulatedNanos %= frameDurationNanos

                    val nextFrame = currentFrame + framesToAdvance
                    if (nextFrame <= totalFrames) {
                        currentFrame = nextFrame
                    } else {
                        currentFrame = 0
                        activePlayers.values.forEach { it.stop(); it.release() }
                        activePlayers.clear()
                    }
                }
            }
        }
    }

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt`:
- Line 73: The calculation of frameDurationNanos uses (1_000_000_000L /
motionConfig.fps) which throws if motionConfig.fps is 0 and misbehaves for
negatives; fix by coercing motionConfig.fps before the division (e.g., val
safeFps = motionConfig.fps.coerceAtLeast(1)) and then compute frameDurationNanos
= (1_000_000_000L / safeFps).coerceAtLeast(1L) to ensure a safe, non-zero
divisor and still guard the result; update the code around the
frameDurationNanos calculation in MotionVideoPlayerCompose (reference
motionConfig.fps and frameDurationNanos).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 673d17d0-841c-4ef0-88f4-2274219353e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6709191 and 83551a7.

📒 Files selected for processing (1)
  • modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt

onDispose { }
} else {
val choreographer = Choreographer.getInstance()
val frameDurationNanos = (1_000_000_000L / motionConfig.fps).coerceAtLeast(1L)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against zero/negative motionConfig.fps.

coerceAtLeast(1L) runs after the division, so if motionConfig.fps is 0 you get ArithmeticException, and a negative fps would yield a negative duration that silently coerces to 1L (effectively advancing one frame per vsync). Coerce the divisor instead.

🛡️ Proposed fix
-            val frameDurationNanos = (1_000_000_000L / motionConfig.fps).coerceAtLeast(1L)
+            val safeFps = motionConfig.fps.coerceAtLeast(1)
+            val frameDurationNanos = (1_000_000_000L / safeFps).coerceAtLeast(1L)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt`
at line 73, The calculation of frameDurationNanos uses (1_000_000_000L /
motionConfig.fps) which throws if motionConfig.fps is 0 and misbehaves for
negatives; fix by coercing motionConfig.fps before the division (e.g., val
safeFps = motionConfig.fps.coerceAtLeast(1)) and then compute frameDurationNanos
= (1_000_000_000L / safeFps).coerceAtLeast(1L) to ensure a safe, non-zero
divisor and still guard the result; update the code around the
frameDurationNanos calculation in MotionVideoPlayerCompose (reference
motionConfig.fps and frameDurationNanos).

@tejpratap46 tejpratap46 closed this Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant