Skip to content

Fall back to a video frame when the server thumbnail is not ready#6524

Open
andremion wants to merge 6 commits into
v6from
andrerego/and-1267-video-preview-shows-blank-until-reload-when-server-thumbnail
Open

Fall back to a video frame when the server thumbnail is not ready#6524
andremion wants to merge 6 commits into
v6from
andrerego/and-1267-video-preview-shows-blank-until-reload-when-server-thumbnail

Conversation

@andremion

@andremion andremion commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Goal

A sent video shows a blank preview in the message list (and in the shared-media grids and quoted previews) until the chat is reopened. Photos work fine.

The cause is a race with server-side thumbnail generation. The upload-intent response returns a thumbUrl before the server has finished generating the thumbnail. The client requests that URL right away and gets a 404, so the preview is blank. A few seconds later the thumbnail is ready, and the next time the item is rendered (scroll away and back, or reopen) it loads. The existing ?: upload fallback does not help, because thumbUrl is present; only the load fails.

iOS already handles this: when the thumbnail load fails, it extracts a frame from the video itself. This change brings Android to the same behavior.

Resolves AND-1267

Implementation

Core, in stream-chat-android-ui-common:

  • VideoThumbnailImageData(thumbnailUrl, videoUrl) is a small request model used as the Coil data for a video preview.
  • VideoThumbnailFallbackInterceptor loads the server thumbnail first and, when it is missing or fails, falls back to a frame from the video. It rewrites the request to a plain URL before proceeding, so CDN signing, headers, and caching are reused. It is registered as the outermost interceptor, so the CDN interceptor still signs the fallback video URL.
  • VideoFrameFetcher extracts the fallback frame with MediaMetadataRetriever, which seeks with HTTP range requests and reads only the bytes needed for the frame. The full video is never written to the image disk cache. This matches the cost profile of iOS (AVAssetImageGenerator on a remote asset). Local files keep using Coil's VideoFrameDecoder.

Call sites now pass VideoThumbnailImageData(thumbUrl, assetUrl) for videos, on both kits and on the same surfaces iOS covers:

  • Message list: MediaAttachmentContent (Compose), MediaAttachmentView (XML).
  • Shared-media grid: ChannelMediaAttachmentsScreen (Compose), MediaAttachmentAdapter (XML).
  • Quoted and file previews: imagePreviewData (Compose, shared by quoted and file content), loadAttachmentThumb (XML).

The new types are @InternalStreamChatApi, so there is no public API change.

🎨 UI Changes

Message list with a video whose thumbnail is not yet available.

Before After
before-blank-video-preview after-video-frame-fallback

Testing

Manual steps (Compose sample):

  1. Open the Compose sample and open a channel that has a video message.
  2. With a normal thumbnail, the preview shows as usual.
  3. To exercise the fallback deterministically, apply the patch below to force the thumbnail load to fail, reinstall, and open the channel. The preview should still render, now from a frame of the video instead of a blank box.
Patch to force the thumbnail to fail
// AttachmentExtensions.kt, imagePreviewData, video branch
isVideo() && ChatTheme.videoThumbnailsEnabled -> {
    val thumbnailUrl = thumbUrl?.let { "https://invalid.invalid/nonexistent-thumbnail.jpg" }
    if (thumbnailUrl != null || assetUrl != null) {
        VideoThumbnailImageData(thumbnailUrl = thumbnailUrl, videoUrl = assetUrl)
    } else {
        upload
    }
}

Verified on the emulator: the frame renders through the fallback, and the full video is not written to the image disk cache (after clearing the cache and reloading, the largest cache entry stays in the hundreds of KB).

Summary by CodeRabbit

  • New Features

    • Improved video attachment previews across the app: load a server thumbnail first, and automatically fall back to a preview frame from the video when needed.
  • Bug Fixes

    • Fixed cases where video attachments could show blank previews or fail to load in gallery, message lists, and attachment views—especially when thumbnail/upload data is missing or incomplete.
  • Tests

    • Added coverage for video frame preview selection and thumbnail fallback behavior.

A sent video showed a blank preview in the message list and the shared-media
grids until the chat was reopened. The upload-intent response returns a
thumbnail URL before server-side thumbnail generation has finished, so the
client requests it and gets a 404, leaving the preview blank. The existing
`?: upload` fallback never helped because the thumbnail URL is present; only
the load fails.

Add a `VideoThumbnailFallbackInterceptor` in ui-common that loads the server
thumbnail first and, when it is missing or fails to load, extracts a frame
from the video asset itself via Coil's `VideoFrameDecoder`. Both UI kits now
pass `VideoThumbnailImageData(thumbUrl, assetUrl)` for videos. The interceptor
rewrites the request to a plain URL before proceeding, so CDN signing,
network fetch, disk cache, and decoding are reused for both the thumbnail and
the fallback frame. It is registered as the outermost interceptor so the CDN
interceptor still signs the fallback video URL.

This matches the iOS behaviour in StreamMediaLoader.
loadAttachmentThumb is used by the XML file rows (FileAttachmentsView) and
quoted previews (DefaultQuotedAttachmentView). It loaded only the server
thumbnail, so a video showed a blank thumbnail when the thumbnail was not
yet generated. The Compose equivalents already recover through
imagePreviewData. Pass VideoThumbnailImageData here too so both kits behave
the same.
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled (or ignored for dependabot PRs).

🎉 Great job! This PR is ready for review.

@andremion andremion added the pr:improvement Improvement label Jun 26, 2026
@andremion

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.31 MB 0.05 MB 🟢
stream-chat-android-offline 5.49 MB 5.53 MB 0.04 MB 🟢
stream-chat-android-ui-components 10.64 MB 10.75 MB 0.11 MB 🟢
stream-chat-android-compose 12.87 MB 12.95 MB 0.08 MB 🟢

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 19537259-bafa-4dc8-ba0c-19b3cc86cb4a

📥 Commits

Reviewing files that changed from the base of the PR and between 8de58e7 and bc99d1b.

📒 Files selected for processing (5)
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt
🚧 Files skipped from review as they are similar to previous changes (5)
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt

Walkthrough

This PR adds video thumbnail fallback loading for attachments. It introduces a thumbnail-first video preview payload, a Coil interceptor and frame fetcher, registers them in the image loader, updates attachment preview call sites to supply the new payload, and adds tests.

Changes

Video thumbnail fallback flow

Layer / File(s) Summary
Preview primitives
stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt, stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt, stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt
Adds the video thumbnail payload, thumbnail-first interceptor, video-frame fetcher, and Coil component registration for them.
Compose preview inputs
stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt, stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt
Updates compose attachment preview paths to build VideoThumbnailImageData from thumbnail and video URLs.
Attachment loaders
stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt, stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt, stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/AttachmentUtils.kt
Updates gallery, message-list, and image-view attachment loading to pass VideoThumbnailImageData when video previews are enabled.
Video preview tests
stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt, stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt
Adds tests for the video-frame fetcher factory and thumbnail-first interceptor.

Sequence Diagram(s)

sequenceDiagram
  participant StreamImageLoaderFactory
  participant VideoThumbnailFallbackInterceptor
  participant VideoFrameFetcher
  participant MediaMetadataRetriever
  StreamImageLoaderFactory->>VideoThumbnailFallbackInterceptor: register interceptor
  StreamImageLoaderFactory->>VideoFrameFetcher: register factory
  VideoThumbnailFallbackInterceptor->>VideoThumbnailFallbackInterceptor: load thumbnailUrl first
  VideoThumbnailFallbackInterceptor->>VideoFrameFetcher: retry with videoUrl and videoFramePreview()
  VideoFrameFetcher->>MediaMetadataRetriever: setDataSource(url, headers)
  MediaMetadataRetriever-->>VideoFrameFetcher: preview frame bitmap
  VideoFrameFetcher-->>VideoThumbnailFallbackInterceptor: ImageFetchResult
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

pr:bug

Suggested reviewers

  • gpunto
  • VelikovPetar

Poem

Hop, hop—new previews bloom tonight,
A thumbnail darts in, then a frame takes flight.
If the thumb is shy, the video lends a spark,
Tiny bunny pixels bounce through Coil in the dark.
🐰🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: falling back to a video frame when the server thumbnail is unavailable.
Description check ✅ Passed The description covers Goal, Implementation, UI Changes, and Testing, with only some non-critical template sections left incomplete.
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.
✨ 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 andrerego/and-1267-video-preview-shows-blank-until-reload-when-server-thumbnail

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt (1)

57-66: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Assert the fallback request is marked as a video-frame preview.

This test only proves the URL rewrite. If VideoThumbnailFallbackInterceptor stops calling videoFramePreview(), it would still pass while breaking the VideoFrameFetcher.Factory handoff and potentially sending the fallback through the default video fetch path. Capture the rewritten ImageRequest in FakeCoilChain and assert videoFramePreviewKey is set on the video fallback request.

Also applies to: 107-129

🤖 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
`@stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt`
around lines 57 - 66, The fallback test in VideoThumbnailFallbackInterceptorTest
only verifies the URL switch, so it can miss regressions where the video
fallback is no longer marked as a video-frame preview. Update the FakeCoilChain
used by VideoThumbnailFallbackInterceptorTest to capture the rewritten
ImageRequest for the VIDEO_URL request, then assert that the fallback request
has videoFramePreviewKey set, alongside the existing URL/proceed assertions.
stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt (1)

101-109: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Set isSampled = true when returning a scaled frame.

When getScaledFrameAtTime downsizes to the requested dimensions the returned bitmap is effectively sampled, but fetch() reports isSampled = false. This can mislead Coil's memory-cache/exact-size bookkeeping for the scaled branch.

🤖 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
`@stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt`
around lines 101 - 109, The scaled-frame branch in
MediaMetadataRetriever.extractFrame() currently returns a downsized bitmap but
fetch() still treats it as not sampled. Update the VideoFrameFetcher.fetch flow
so that when getScaledFrameAtTime is used for the requested size, the resulting
ImageSource/result is marked with isSampled = true, while keeping the unscaled
getFrameAtTime path unchanged.
🤖 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
`@stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt`:
- Around line 45-53: The video thumbnail branch in AttachmentExtensionsKt is
forcing failures by replacing the real thumbUrl with an invalid hardcoded URL,
so remove the TEMP TEST override and pass through the actual thumbnail value.
Update the isVideo() && ChatTheme.videoThumbnailsEnabled path to use the same
CDN-resizing transform as other call sites in this Compose scope (for example
via ChatTheme.streamCdnImageResizing or the equivalent accessor) before
constructing VideoThumbnailImageData, while still falling back to upload only
when both thumbnail and asset URLs are absent.

---

Nitpick comments:
In
`@stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt`:
- Around line 101-109: The scaled-frame branch in
MediaMetadataRetriever.extractFrame() currently returns a downsized bitmap but
fetch() still treats it as not sampled. Update the VideoFrameFetcher.fetch flow
so that when getScaledFrameAtTime is used for the requested size, the resulting
ImageSource/result is marked with isSampled = true, while keeping the unscaled
getFrameAtTime path unchanged.

In
`@stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt`:
- Around line 57-66: The fallback test in VideoThumbnailFallbackInterceptorTest
only verifies the URL switch, so it can miss regressions where the video
fallback is no longer marked as a video-frame preview. Update the FakeCoilChain
used by VideoThumbnailFallbackInterceptorTest to capture the rewritten
ImageRequest for the VIDEO_URL request, then assert that the fallback request
has videoFramePreviewKey set, alongside the existing URL/proceed assertions.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 44d6975d-31ca-4697-ab02-afea89397054

📥 Commits

Reviewing files that changed from the base of the PR and between 51f561b and 8de58e7.

📒 Files selected for processing (10)
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt
  • stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt
  • stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt
  • stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/AttachmentUtils.kt

The fallback previously routed the video URL through VideoFrameDecoder, which
needs the whole file downloaded first and writes it to the image disk cache.
Replace it with a VideoFrameFetcher backed by MediaMetadataRetriever, which
seeks with HTTP range requests and reads only the bytes needed for the frame,
and never stores the full video. This matches the cost profile of iOS, which
uses AVAssetImageGenerator on a remote asset.

The interceptor marks the fallback request and still runs before the CDN
interceptor, so the CDN-signed URL and headers reach the fetcher. Requests
that are not marked fall through to the default network fetcher.
@andremion andremion force-pushed the andrerego/and-1267-video-preview-shows-blank-until-reload-when-server-thumbnail branch from 8de58e7 to bc99d1b Compare June 26, 2026 10:30
@andremion

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…table

- VideoThumbnailFallbackInterceptor is only used inside ui-common, so it is
  internal rather than @InternalStreamChatApi public.
- The MediaAttachmentQuotedContent preview handler matched the raw "video"
  string, but a video preview's data is now VideoThumbnailImageData. Match that
  type so the video cells keep their preview color and the snapshot is unchanged.
Add VideoFrameFetcher.fetch() tests using Robolectric's MediaMetadataRetriever
shadow for the scaled, unscaled, and no-frame paths, and a
VideoThumbnailFallbackInterceptor test for the case with neither a thumbnail
nor a video URL.
@andremion andremion marked this pull request as ready for review June 26, 2026 11:18
@andremion andremion requested a review from a team as a code owner June 26, 2026 11:18
Add a Compose test that captures the value imagePreviewData returns for image,
video (with thumbnail, asset-only, and upload fallback), thumbnails-disabled,
and non-media attachments.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:improvement Improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant