Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9f024c7
feat(floating-video): add system-wide mini-player draft
aliIsazadeh Sep 26, 2025
a111b5e
Merge branch 'element-hq:develop' into minimized-video
aliIsazadeh Sep 27, 2025
16db3ab
fixed the bug of video being full size at first
aliIsazadeh Sep 27, 2025
bbc5458
Merge remote-tracking branch 'origin/minimized-video' into minimized-…
aliIsazadeh Sep 27, 2025
0f3fec9
Merge branch 'develop' into minimized-video
aliIsazadeh Sep 30, 2025
fe18ef7
-remove unused setMinimize
aliIsazadeh Sep 30, 2025
49a765d
-removed added icons and string and using existing icons and strings
aliIsazadeh Sep 30, 2025
daf7461
-adjusting maximize button size
aliIsazadeh Sep 30, 2025
46d226b
Merge branch 'element-hq:develop' into minimized-video
aliIsazadeh Sep 30, 2025
d07dc1e
-remove extra service declaration of service in app manifest
aliIsazadeh Sep 30, 2025
b99e67f
Merge remote-tracking branch 'origin/minimized-video' into minimized-…
aliIsazadeh Sep 30, 2025
2f9f56d
-remove extra import
aliIsazadeh Sep 30, 2025
7c13c1f
-remove extra string file
aliIsazadeh Oct 2, 2025
b9f415f
refactor: Convert floating video overlay to Compose with direct view …
aliIsazadeh Oct 4, 2025
a90c19a
Merge branch 'develop' into minimized-video
aliIsazadeh Oct 6, 2025
6a3cb61
-revert changes in gradle.properties and project
aliIsazadeh Oct 11, 2025
f91d167
- Broke down large FloatingVideoService into multiple smaller files f…
aliIsazadeh Oct 12, 2025
1466e74
Merge remote-tracking branch 'origin/minimized-video' into minimized-…
aliIsazadeh Oct 12, 2025
b33720c
fix(video): stabilize floating video behavior
aliIsazadeh Oct 13, 2025
d4228a8
-Converted VideoDataRepository to @SingleIn(AppScope::class) with @In…
aliIsazadeh Oct 16, 2025
19defb1
-on video completes the minimized video gose away
aliIsazadeh Oct 17, 2025
2cf92d5
-Toast for display over apps persmission(string needs to be added to …
aliIsazadeh Oct 17, 2025
44105d2
Merge branch 'element-hq:develop' into minimized-video
aliIsazadeh Oct 17, 2025
1978751
Merge branch 'develop' into minimized-video
aliIsazadeh Oct 20, 2025
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
2 changes: 1 addition & 1 deletion .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

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

<application
android:name=".ElementXApplication"
android:allowBackup="false"
Expand Down
19 changes: 19 additions & 0 deletions libraries/mediaviewer/impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2025 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
~ Please see LICENSE files in the repository root for full details.
-->

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.element.android.libraries.mediaviewer.impl">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

<application>
<service
android:name=".floatingvideo.FloatingVideoService"
android:enabled="true"
android:exported="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.mediaviewer.impl.floatingvideo

import android.annotation.SuppressLint
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.PixelFormat
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.Toast
import android.widget.VideoView
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData
import timber.log.Timber
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import androidx.core.net.toUri
import io.element.android.libraries.mediaviewer.impl.floatingvideo.ui.FloatingVideoOverlay
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getScreenHeight
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getScreenWidth
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings

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.

Copy link
Member

Choose a reason for hiding this comment

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

Does this service need to implement ViewModelStoreOwner? Also viewModelStore is unused, just remove it?

private var windowManager: WindowManager? = null
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
private var windowManager: WindowManager? = null
private val windowManager = getSystemService<WindowManager>()!!

The code will be simplified by this change, and I believe that the system always provides the WindowManager service.

private var floatingView: View? = null
private var videoView: VideoView? = null
private var currentVideoData: MediaViewerPageData.MediaViewerData? = null
private var currentPosition: Long = 0L
private var isMaximized = true

private lateinit var windowLayoutParams: WindowManager.LayoutParams
Copy link
Member

Choose a reason for hiding this comment

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

I think it's possible to remove lateinit, change to val and initialize the value here.


companion object {
const val ACTION_START_FLOATING = "START_FLOATING"
const val ACTION_STOP_FLOATING = "STOP_FLOATING"
const val ACTION_UPDATE_POSITION = "UPDATE_POSITION"
Copy link
Member

Choose a reason for hiding this comment

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

It appears to be dead code, can you remove please?

const val EXTRA_VIDEO_ID = "video_id"
const val EXTRA_POSITION = "position"

@SuppressLint("ObsoleteSdkInt")
fun startFloating(
context: Context, videoData: MediaViewerPageData.MediaViewerData, position: Long = 0L
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {

//the message needs to be added into commonStrings as notice for permission needed
Toast.makeText(context, "To show the floating video, please allow 'Display over other apps' permission.", Toast.LENGTH_LONG).show()

// Request overlay permission
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}


context.startActivity(intent)
Copy link
Member

Choose a reason for hiding this comment

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

This code should be moved to SystemUtils.kt and the exception for ActivityNotFoundException should be caught.

return
}

// Generate unique ID for this video session
val videoId = "floating_video_${System.currentTimeMillis()}"
Copy link
Member

Choose a reason for hiding this comment

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

Better to use UUID.randomUUID().toString() to generate an ID. The id could also be created by the videoDataRepository and returned by the method storeVideoData.


// Store the video data in repository via DI
context.bindings<FloatingVideoServiceBindings>().videoDataRepository().storeVideoData(videoId, videoData)
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 doing this, MediaViewerNode could have the video data repository and store the data before invoking startFloating. The videoId would be a parameter of startFloating.


val intent = Intent(context, FloatingVideoService::class.java).apply {
action = ACTION_START_FLOATING
putExtra(EXTRA_VIDEO_ID, videoId) // Pass only the ID, not the whole object
putExtra(EXTRA_POSITION, position)
}
context.startService(intent)
}
}

@Inject lateinit var videoDataRepository: VideoDataRepository

override fun onBind(intent: Intent?): IBinder? = null

override val viewModelStore = ViewModelStore()
private val lifecycleRegistry = LifecycleRegistry(this)
private val savedStateRegistryController = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle
get() = lifecycleRegistry

override val savedStateRegistry: SavedStateRegistry
get() = savedStateRegistryController.savedStateRegistry

override fun onCreate() {
super.onCreate()
bindings<FloatingVideoServiceBindings>().inject(this)
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
// 1. Attach controller
savedStateRegistryController.performAttach()

// 2. Restore state (if any)
savedStateRegistryController.performRestore(null)

// 3. Now move lifecycle forward
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}

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?


override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_FLOATING -> {
val videoId = intent.getStringExtra(EXTRA_VIDEO_ID)
val position = intent.getLongExtra(EXTRA_POSITION, 0L)

if (videoId != null) {
// Get video data from repository using the ID
val videoData = videoDataRepository.getVideoData(videoId)
if (videoData != null) {
eventId = videoData.eventId?.value ?: ""
currentVideoData = videoData
currentVideoId = videoId
currentPosition = position
createFloatingView()
}
}
}

ACTION_STOP_FLOATING -> {
// Clean up stored data
currentVideoId?.let { videoId ->
videoDataRepository.removeVideoData(videoId)
}
removeFloatingView()
stopSelf()
}

ACTION_UPDATE_POSITION -> {
val position = intent.getLongExtra(EXTRA_POSITION, 0L)
currentPosition = position
videoView?.seekTo(position.toInt())
}
}
return START_STICKY
}

private fun createFloatingView() {
removeFloatingView()
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager

windowLayoutParams = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
PixelFormat.TRANSLUCENT
)
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
PixelFormat.TRANSLUCENT
)
}

windowLayoutParams.gravity = Gravity.TOP or Gravity.START
windowLayoutParams.x = 0
windowLayoutParams.y = windowManager.getScreenHeight() - dpToPx(300)

val composeView = ComposeView(this).apply {
setViewTreeLifecycleOwner(this@FloatingVideoService)
setViewTreeViewModelStoreOwner(this@FloatingVideoService)
setViewTreeSavedStateRegistryOwner(this@FloatingVideoService)
setContent {
FloatingVideoOverlay(
onClose = {
removeFloatingView()
stopSelf()
},
onToggleFullScreen = {
Timber.tag("onToggleFullScreen").d(isMaximized.toString())
[email protected](it)
},
onCompleted = {
removeFloatingView()
stopSelf()
},
floatingView = floatingView,
isMaximized = isMaximized,
currentVideoData = currentVideoData,
windowManager = windowManager,
windowLayoutParams = windowLayoutParams
)
}
}


floatingView = composeView


try {
windowManager?.addView(floatingView, windowLayoutParams)
} catch (e: Exception) {
Timber.tag("FloatingVideoService").e(e, "Error adding floating view")
}
}

private fun removeFloatingView() {
floatingView?.let { view ->
try {
windowManager?.removeView(view)
} catch (e: Exception) {
Timber.tag("FloatingVideoService").e(e, "Error removing floating view")
}
floatingView = null
videoView = null
}
}

override fun onDestroy() {
super.onDestroy()
onVideoComplete()
}

private fun dpToPx(dp: Int): Int {
return (dp * resources.displayMetrics.density).toInt()
}

private fun onVideoComplete() {
removeFloatingView()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
viewModelStore.clear()
}

private fun toggleFullScreen(aspectRatio: Float) {
val layoutParams = windowLayoutParams
val wm = windowManager ?: return
val view = floatingView ?: return

isMaximized = !isMaximized

if (view.parent == null) return

val widthFrac = if (aspectRatio > 1f) 0.6f else 0.3f
val width = if (isMaximized) {
(windowManager.getScreenWidth() * widthFrac).toInt()
} else {
(windowManager.getScreenWidth() * 0.9f).toInt()
}
val height = (width / aspectRatio).toInt()




if (isMaximized) {
// Go full screen
val margin = dpToPx(24)
val screenWidth = Resources.getSystem().displayMetrics.widthPixels
layoutParams.width = screenWidth - margin * 2
layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT
layoutParams.x = margin
layoutParams.y = margin
} else {
// Minimized
val scaledWidth = wm.getScreenWidth() * 0.3f
layoutParams.width = scaledWidth.toInt()
layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT
layoutParams.x = 0
layoutParams.y = 0
}

windowLayoutParams.width = width
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?


Handler(Looper.getMainLooper()).post {
wm.updateViewLayout(view, layoutParams)
}
Copy link
Member

Choose a reason for hiding this comment

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

Please replace by:

        lifecycleScope.launch(coroutineDispatchers.io) {
            wm.updateViewLayout(view, layoutParams)
        }

coroutineDispatchers can be injected.

Same thing for the Looper used in FloatingVideoOverlay. All the treatment done in FloatingVideoOverlay must be moved to the service.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.mediaviewer.impl.floatingvideo

import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo

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

}


Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.mediaviewer.impl.floatingvideo

import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData

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

private val videoDataMap = mutableMapOf<String, MediaViewerPageData.MediaViewerData>()

fun storeVideoData(videoId: String, data: MediaViewerPageData.MediaViewerData) {
videoDataMap[videoId] = data
}

fun getVideoData(videoId: String): MediaViewerPageData.MediaViewerData? {
return videoDataMap[videoId]
}

fun removeVideoData(videoId: String) {
videoDataMap.remove(videoId)
}

fun clear() {
videoDataMap.clear()
}
}

Loading
Loading