Skip to content

Conversation

@aliIsazadeh
Copy link

@aliIsazadeh aliIsazadeh commented Sep 27, 2025

What’s Changed

  • Introduced a minimized video service, similar to YouTube’s PiP experience.
  • Allows users to keep watching videos while navigating the app.
  • Designed within Element X’s architecture constraints, since implementing it like Telegram/WhatsApp is not straightforward.
  • Ensures smooth transitions between fullscreen and minimized modes.

Why This Matters

Many modern messengers (e.g. Telegram, WhatsApp) already support minimized video.
Element X’s structure makes this challenging, but this approach provides a practical way to implement it while staying close to the app’s design principles.

Demo

video_2025-09-27_12-22-19.mp4

Closes #5421

Notes

This was really fun to implement, and I believe it can improve the user experience a lot. 🚀
Also — I’m open to remote Android opportunities. If anyone is interested in collaborating or has a project/position where I can contribute, feel free to reach out: [[email protected]]

- Adds a service to display a draggable video overlay
  outside the app, similar to YouTube or Telegram.
- Basic controls included: play/pause, close, maximize.
- Handles overlay permissions (SYSTEM_ALERT_WINDOW).
- This is a draft implementation for feedback; UX and integration
  with in-app Compose features may need adjustments.
@aliIsazadeh aliIsazadeh requested a review from a team as a code owner September 27, 2025 08:56
@aliIsazadeh aliIsazadeh requested review from jmartinesp and removed request for a team September 27, 2025 08:56
@CLAassistant
Copy link

CLAassistant commented Sep 27, 2025

CLA assistant check
All committers have signed the CLA.

@github-actions
Copy link
Contributor

Thank you for your contribution! Here are a few things to check in the PR to ensure it's reviewed as quickly as possible:

  • Your branch should be based on origin/develop, at least when it was created.
  • The title of the PR will be used for release notes, so it needs to describe the change visible to the user.
  • The test pass locally running ./gradlew test.
  • The code quality check suite pass locally running ./gradlew runQualityChecks.
  • If you modified anything related to the UI, including previews, you'll have to run the Record screenshots GH action in your forked repo: that will generate compatible new screenshots. However, given Github Actions limitations, it will prevent the CI from running temporarily, until you upload a new commit after that one. To do so, just pull the latest changes and push an empty commit.

@github-actions github-actions bot added the Z-Community-PR Issue is solved by a community member's PR label Sep 27, 2025
Copy link
Member

@bmarty bmarty left a comment

Choose a reason for hiding this comment

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

Awesome feature, thanks!
I have not tested the code yet, but here are a first set of comments.

@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
val (isMinimized, setMinimized) = remember { mutableStateOf(false) }
Copy link
Member

Choose a reason for hiding this comment

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

TIL we can assigned remembered mutable state like this. Handy!

) {
Icon(
imageVector = Icons.Default.FullscreenExit,
contentDescription = "Minimize"
Copy link
Member

Choose a reason for hiding this comment

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

a11y

Copy link
Author

Choose a reason for hiding this comment

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

sure

Copy link
Member

Choose a reason for hiding this comment

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

Oh sorry, my comment seems a bit rude, it was not supposed to be sent but was a note for me to not forget. But I forgot to edit it.
I have create a new String CommonStrings.action_minimize with this value. It will be available on develop next time we sync the string (probably in the coming days, but for sure on next Monday).

Copy link
Author

Choose a reason for hiding this comment

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

😂 Nah I undrstanded what you mean you were talking about accessibility and hard coded string.

Copy link
Member

Choose a reason for hiding this comment

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

oh yes, and the comment should have been i18n of course!

Copy link
Author

Choose a reason for hiding this comment

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

i changed string to unknown and also i add a comment about it needs to be replaced with CommonStrings.action_minimize

android:theme="@style/Theme.ElementX"
tools:targetApi="33">

<service
Copy link
Member

Choose a reason for hiding this comment

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

This declaration should be located in the AndroidManifest of the module where FloatingVideoService is defined.

Copy link
Author

Choose a reason for hiding this comment

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

oh of course

Copy link
Author

Choose a reason for hiding this comment

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

done

Copy link
Member

Choose a reason for hiding this comment

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

and remove the declaration from here?

Copy link
Author

Choose a reason for hiding this comment

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

oh shoot sorry i forgot about that
yeah now i removed it

<!-- To be able to install APK from the application -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
Copy link
Member

Choose a reason for hiding this comment

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

This declaration should be located in the AndroidManifest of the module where FloatingVideoService is defined.

Copy link
Author

Choose a reason for hiding this comment

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

done

<path
android:pathData="m256,760 l-56,-56 224,-224 -224,-224 56,-56 224,224 224,-224 56,56 -224,224 224,224 -56,56 -224,-224 -224,224Z"
android:fillColor="#e3e3e3"/>
</vector>
Copy link
Member

Choose a reason for hiding this comment

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

You should not add new icons but use icons provided by Compound. For this one, it is io.element.android.compound.R.drawable.ic_compound_close.

Copy link
Author

Choose a reason for hiding this comment

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

cool
what about minimize and maximize icons it seems Compound dose not have them
i will use something else but i will comment in the code that it need to change

Copy link
Member

Choose a reason for hiding this comment

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

The available compound icons are visible here: https://github.com/element-hq/compound-android/blob/main/compound/screenshots/Compound%20Icons%20-%20Light.png. You may want to use expand and collapse for your use case?

Copy link
Author

Choose a reason for hiding this comment

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

done

-->

<resources>
<string name="video_playback_error">Video playback error</string>
Copy link
Member

Choose a reason for hiding this comment

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

We do not add string manually in the project. For now you can just remove it and use CommonStrings.error_unknown.

Copy link
Author

Choose a reason for hiding this comment

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

done

Copy link
Member

Choose a reason for hiding this comment

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

not removed?

Copy link
Author

Choose a reason for hiding this comment

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

now it's removed.

})

container.addView(overlayContainer)
return container
Copy link
Member

Choose a reason for hiding this comment

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

It would be awesome to use Compose UI to design the View. It will simplify the code a lot (I think), and allow to have previews. Maybe https://gist.github.com/kishan-vadoliya/9fbd1e3c1590de1e4a1a830c5d4edb3f can inspire?

Copy link
Author

Choose a reason for hiding this comment

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

I will try that and then I will tell you what happend
thanks

Copy link
Author

@aliIsazadeh aliIsazadeh Oct 4, 2025

Choose a reason for hiding this comment

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

Quick update on the floating video overlay refactor. I migrated it from XML/ViewBinding to Compose, but ran into an issue: Compose recompositions don’t work reliably in a Service context, even with proper lifecycle setup. State updates happen, but the UI doesn’t recompose.

Because of that, I switched to a hybrid approach: Compose is used for layout, but window size/aspect ratio updates are applied directly via WindowManager and callbacks instead of relying on recomposition.

Main changes:

  • Dynamic aspect ratio for portrait/landscape

  • Removed controller bar (state-driven updates weren’t working) → replaced with tap-to-play/pause gesture

  • Fixed window resizing when toggling minimized/maximized

  • Improved drag/tap gesture handling

So the overlay is Compose-based now, but with direct view updates for stable behavior in Service.
@bmarty

@bmarty bmarty changed the title Feature: Add minimized video service (YouTube-style PiP Feature: Add minimized video service (YouTube-style PiP) Sep 29, 2025
@aliIsazadeh
Copy link
Author

such a cool checks

-changed minimize buttons text to error_unknown but it need new CommonString
-moved service permission and registration from app manifest to mediaViewer manifest
-handling TYPE_PHONE deprivation
-remove extra string of video_playback_error
-remove extra margin
…updates

Replace ViewBinding-based floating video UI with Jetpack Compose implementation.
Due to Compose recomposition limitations in Service context, implemented hybrid
approach using direct view manipulation instead of state-driven UI updates.

Key changes:
- Migrated floating video overlay from XML layouts to Compose
- Implemented dynamic aspect ratio handling for portrait/landscape videos
- Added direct WindowManager updates for window resizing instead of relying on recomposition
- Removed video controller bar (play/pause, seek bar) as Compose state changes don't
  trigger recomposition in Service lifecycle
- Added tap-to-play/pause gesture as alternative to controller bar
- Fixed window sizing issues when toggling between maximized and minimized states
- Improved drag gesture handling to prevent conflicts with tap gestures

Technical notes:
- Compose recomposition does not work reliably in Service context even with proper
  lifecycle owners set up
- State changes (mutableStateOf, mutableFloatStateOf) update values but don't trigger
  UI recomposition
- LaunchedEffect blocks can observe state changes but composable body doesn't recompose
- Solution: Use state to track values, but update UI elements (WindowManager, Android Views)
  directly via callbacks and references instead of relying on Compose recomposition
- This hybrid approach allows using Compose for layout while maintaining reliable
  UI updates in Service environment
@aliIsazadeh aliIsazadeh requested a review from bmarty October 6, 2025 08:18
Copy link
Member

@bmarty bmarty left a comment

Choose a reason for hiding this comment

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

Thanks for the update and the migration to Compose!
I have added more remarks.

I have also tested the feature and it seems to work pretty well. I would maybe add some padding, here the minimized video was displayed at the bottom start of the screen. Also maybe having a way to Play/Pause the video on the minimize video would be awesome. Last point, the View should maybe automatically close at the end of the Playback?

<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
Copy link
Member

Choose a reason for hiding this comment

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

You must revert this change.
I have the same change locally, this is a pain, I have not find a way to get ride of it. The problem is that other developers do not have it so we cannot touch this file on each PR...

Copy link
Author

@aliIsazadeh aliIsazadeh Oct 11, 2025

Choose a reason for hiding this comment

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

oh sure i put this file in gitignore from now on.

org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
kotlin.compiler.execution.strategy=in-process
kotlin.daemon.jvm.options=-Xmx2g
Copy link
Member

Choose a reason for hiding this comment

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

Please revert all the changes in this file. You can place your local configuration in the file local.properties instead, which is ignore by VCS.

Copy link
Author

Choose a reason for hiding this comment

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

My apologies — I’m not sure when these changes were made and committed. It wasn’t intentional.

import java.io.File
import androidx.core.net.toUri

class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
Copy link
Member

Choose a reason for hiding this comment

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

We try to avoid long file. Please split the code into multiple file. The UI can be extracted, it will force the code to be a bit more decoupled.

Also it will allow to add preview of the UI, for instance:

@PreviewsDayNight
@Composable
internal fun FloatingVideoOverlayPreview() = ElementPreview {
    FloatingVideoOverlay(
        // params
    )
}

The method getVideoUriFromMediaSource could be in a separate file too.

}

@OptIn(UnstableApi::class)
fun getVideoUriFromMediaSource(mediaSource: MediaSource): Uri {
Copy link
Member

Choose a reason for hiding this comment

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

It would be nicer to use an extension, which also allow to simplify the name:
fun MediaSource.getVideoUri(): Uri {
Using a block like with(url) {} could also simplify the code, but I am not a big fan of having several this in a block.

Copy link
Author

Choose a reason for hiding this comment

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

Done.

@OptIn(UnstableApi::class)
fun getVideoUriFromMediaSource(mediaSource: MediaSource): Uri {
return try {
val url = mediaSource.url
Copy link
Member

Choose a reason for hiding this comment

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

then you can just remove this line.

fun getVideoUriFromMediaSource(mediaSource: MediaSource): Uri {
return try {
val url = mediaSource.url
Log.d("VideoPlayer", "MediaSource URL: $url")
Copy link
Member

Choose a reason for hiding this comment

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

We use Timber on this project, please do not use Log.

Timber.tag("VideoPlayer").d("MediaSource URL: $url")

Also logging the url should be avoided for privacy reason. The log can be sent by the user to our company, so we avoid logging private data (the url could contains sensitive information)

Copy link
Author

Choose a reason for hiding this comment

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

Done.
and you are right it was there for test.

.align(Alignment.TopCenter)
.fillMaxWidth()
.background(
brush = androidx.compose.ui.graphics.Brush.verticalGradient(
Copy link
Member

Choose a reason for hiding this comment

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

please use import (there are several place to fix in this file)

Copy link
Author

Choose a reason for hiding this comment

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

Done

onClose: () -> Unit,
onToggleFullScreen: () -> Unit
) {
var currentAspectRatio by remember { mutableStateOf(16f / 9f) }
Copy link
Member

Choose a reason for hiding this comment

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

Please use mutableFloatStateOf

Copy link
Author

Choose a reason for hiding this comment

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

done

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.IconButton
Copy link
Member

Choose a reason for hiding this comment

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

You must use io.element.android.libraries.designsystem.theme.components.IconButton instead.

Copy link
Author

Choose a reason for hiding this comment

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

done

import androidx.compose.material.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Fullscreen
Copy link
Member

Choose a reason for hiding this comment

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

You should use CompoundIcon instead of Icon from material.

Copy link
Author

Choose a reason for hiding this comment

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

done

INSTANCE ?: VideoDataRepository().also { INSTANCE = it }
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

We used DI on this project, so you should remove this companion and let the DI handle the singleton:

@Inject
@SingleIn(AppScope::class)
class VideoDataRepository {

Then startFloating could have a VideoDataRepository parameter and VideoDataRepository could be injected in FloatingVideoService. You can see how members are injected in the Activites for instance.

Copy link
Author

Choose a reason for hiding this comment

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

done

…or better readability and maintainability.

- Replaced custom UI elements with components from io.element.android.libraries.designsystem.theme.components.
- Disabled minimize action until video is ready to prevent crash
- Fixed minimized video size issue after refactoring FloatingVideoService
…ject.

-Created FloatingVideoServiceBindings (inject(service), videoDataRepository()).
-Injected VideoDataRepository in FloatingVideoService, used bindings<...>().inject(this) in onCreate, and replaced singleton calls with DI.
@aliIsazadeh
Copy link
Author

for play and pause just tap on the video

@aliIsazadeh aliIsazadeh requested a review from bmarty October 17, 2025 15:04
@bmarty bmarty changed the base branch from develop to feature/bma/minimizeVideoPlayer November 3, 2025 08:39
Copy link
Member

@bmarty bmarty left a comment

Choose a reason for hiding this comment

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

Thanks for the update. More comments!


fun MediaSource.getVideoUriFromMediaSource () : Uri{
return try {
val url = this.url
Copy link
Member

Choose a reason for hiding this comment

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

This line can just be removed.

import timber.log.Timber
import java.io.File

fun MediaSource.getVideoUriFromMediaSource () : Uri{
Copy link
Member

Choose a reason for hiding this comment

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

Rename to getUri? No need to repeat MediaSource as it's the receiver and also no need to precise Video, it can stay generic.

}
url.startsWith("/") -> {
// Local file path, convert to file URI
Uri.fromFile(File(url))
Copy link
Member

Choose a reason for hiding this comment

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

Maybe change to

Suggested change
Uri.fromFile(File(url))
File(url).toUri()

isMaximized : Boolean,
windowManager : WindowManager?,
windowLayoutParams : WindowManager.LayoutParams,
floatingView : View?,
Copy link
Member

Choose a reason for hiding this comment

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

Instead of providing windowManager and floatingView, isMaximized, I'll provide some lambda parameter and handle all the computation in FloatingVideoService.

At the end, the signature should be something like:

fun FloatingVideoOverlay(
    uri: Uri?,
    onClose: () -> Unit,
    onToggleFullScreen: () -> Unit,
    updateAspectRatio: (Float) -> Unit,
    movePosition: (Int, Int) -> Unit,
    modifier: Modifier = Modifier,
)




var resolvedUri: Uri = Uri.EMPTY
Copy link
Member

Choose a reason for hiding this comment

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

This should be remembered

audioFocus = audioFocus,
onBackClick = callback::onDone,
)
val data = state.listData
Copy link
Member

Choose a reason for hiding this comment

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

This need to be moved below

)
val data = state.listData
.getOrNull(state.currentIndex) as? MediaViewerPageData.MediaViewerData
Box(modifier = modifier.fillMaxSize()) {
Copy link
Member

Choose a reason for hiding this comment

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

No need to add a Box here I think.

@ContributesTo(AppScope::class)
interface FloatingVideoServiceBindings {
fun inject(service: FloatingVideoService)
fun videoDataRepository(): VideoDataRepository
Copy link
Member

Choose a reason for hiding this comment

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

This fun can be removed if you handle my other remarks.

}

private var currentVideoId: String? = null
private var eventId: String? = null
Copy link
Member

Choose a reason for hiding this comment

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

Not used, remove?

windowLayoutParams.height = height
windowLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
windowManager?.updateViewLayout(floatingView, windowLayoutParams)
Copy link
Member

Choose a reason for hiding this comment

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

This seems to be done twice (here and below), can you check?

@aliIsazadeh
Copy link
Author

Thanks for the detailed review. I’ll apply these changes and I really appreciate the explanations.

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

Labels

Z-Community-PR Issue is solved by a community member's PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: System-wide floating video player (overlay service)

3 participants