diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index cdef735570f..c66af88c8d4 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -121,4 +121,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bcfcd71b941..6c510a5e098 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,7 +9,6 @@
-
+
+
+
+
+
+
+
+
+
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoService.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoService.kt
new file mode 100644
index 00000000000..879d47b266b
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoService.kt
@@ -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 {
+ private var windowManager: WindowManager? = null
+ 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
+
+ companion object {
+ const val ACTION_START_FLOATING = "START_FLOATING"
+ const val ACTION_STOP_FLOATING = "STOP_FLOATING"
+ const val ACTION_UPDATE_POSITION = "UPDATE_POSITION"
+ 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)
+ return
+ }
+
+ // Generate unique ID for this video session
+ val videoId = "floating_video_${System.currentTimeMillis()}"
+
+ // Store the video data in repository via DI
+ context.bindings().videoDataRepository().storeVideoData(videoId, videoData)
+
+ 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().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
+
+ 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())
+ this@FloatingVideoService.toggleFullScreen(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)
+
+ Handler(Looper.getMainLooper()).post {
+ wm.updateViewLayout(view, layoutParams)
+ }
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoServiceBindings.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoServiceBindings.kt
new file mode 100644
index 00000000000..ff85e07bf55
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoServiceBindings.kt
@@ -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
+}
+
+
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/VideoDataRepository.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/VideoDataRepository.kt
new file mode 100644
index 00000000000..5d9c208c3fb
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/VideoDataRepository.kt
@@ -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()
+
+ 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()
+ }
+}
+
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/ui/FloatingVideoOverlay.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/ui/FloatingVideoOverlay.kt
new file mode 100644
index 00000000000..1e92b1574bc
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/ui/FloatingVideoOverlay.kt
@@ -0,0 +1,214 @@
+/*
+ * 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.ui
+
+import android.net.Uri
+import android.view.View
+import android.view.WindowManager
+import android.widget.VideoView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getScreenWidth
+import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getVideoUriFromMediaSource
+import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun FloatingVideoOverlay(
+ onClose: () -> Unit,
+ onToggleFullScreen: (Float) -> Unit,
+ currentVideoData : MediaViewerPageData.MediaViewerData?,
+ isMaximized : Boolean,
+ windowManager : WindowManager?,
+ windowLayoutParams : WindowManager.LayoutParams,
+ floatingView : View?,
+ onCompleted : () -> Unit
+) {
+ var currentAspectRatio by remember { mutableFloatStateOf(16f / 9f) }
+ val videoViewRef = remember { mutableStateOf(null) }
+
+
+
+ var resolvedUri: Uri = Uri.EMPTY
+ currentVideoData?.let { data ->
+ resolvedUri = when (val downloadedState = data.downloadedMedia.value) {
+ is AsyncData.Success -> downloadedState.data.uri
+ else -> data.mediaSource.getVideoUriFromMediaSource()
+ }
+ }
+
+ // Function to update window size directly
+ fun updateWindowSize(aspectRatio: Float) {
+ 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()
+
+
+ windowLayoutParams.width = width
+ windowLayoutParams.height = height
+ windowLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
+ windowManager?.updateViewLayout(floatingView, windowLayoutParams)
+ }
+
+ // Initial window size (16:9)
+ LaunchedEffect(Unit) {
+ updateWindowSize(16f / 9f)
+ }
+
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = Color.Black).pointerInput(Unit) {
+ var dragStarted = false
+ detectTapGestures(
+ onPress = {
+ dragStarted = false
+ },
+ onTap = {
+ if (!dragStarted) {
+ videoViewRef.value?.let { video ->
+ if (video.isPlaying) {
+ video.pause()
+ } else {
+ video.start()
+ }
+ }
+ }
+ }
+ )
+ }
+ ) {
+ // Video layer
+ AndroidView(
+ factory = { context ->
+ VideoView(context).apply {
+ videoViewRef.value = this
+ setVideoURI(resolvedUri)
+ setOnPreparedListener { mp ->
+ val videoWidth = mp.videoWidth
+ val videoHeight = mp.videoHeight
+
+ if (videoWidth > 0 && videoHeight > 0) {
+ val newAspectRatio = videoWidth.toFloat() / videoHeight
+
+ // Store the aspect ratio and update window size
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
+ currentAspectRatio = newAspectRatio
+ updateWindowSize(newAspectRatio)
+ }
+ }
+ start()
+ }
+ setOnCompletionListener {
+ onCompleted()
+ }
+
+ }
+ },
+ update = { videoView ->
+ if (resolvedUri != Uri.EMPTY && videoView.currentPosition == 0) {
+ videoView.setVideoURI(resolvedUri)
+ }
+ },
+ modifier = Modifier
+ .fillMaxSize()
+
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(Unit) {
+ detectDragGestures { change, dragAmount ->
+ change.consume()
+ val newX = windowLayoutParams.x + dragAmount.x.toInt()
+ val newY = windowLayoutParams.y + dragAmount.y.toInt()
+ windowLayoutParams.x = newX
+ windowLayoutParams.y = newY
+ windowManager?.updateViewLayout(floatingView, windowLayoutParams)
+ }
+ }
+ )
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.6f),
+ Color.Transparent
+ )
+ )
+ )
+ .padding(4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ IconButton(
+ onClick = {
+ onToggleFullScreen ( currentAspectRatio )
+ },
+ //it seems the CompoundIcons.Expand() is bigger than the CompoundIcons.Close(),
+ modifier = Modifier.size(28.dp)
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Expand(),
+ //action full screen needs to be added to CommonsString
+ contentDescription = stringResource(CommonStrings.a11y_expand_message_text_field),
+ tint = Color.White,
+ modifier = Modifier.padding(4.dp)
+ )
+ }
+
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Close(),
+ contentDescription = stringResource(CommonStrings.action_close),
+ tint = Color.White
+ )
+ }
+ }
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/ScreenSizeHelpers.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/ScreenSizeHelpers.kt
new file mode 100644
index 00000000000..dd958905f11
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/ScreenSizeHelpers.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.util
+
+import android.os.Build
+import android.util.DisplayMetrics
+import android.view.WindowManager
+
+fun WindowManager?.getScreenWidth() : Int {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val windowMetrics = this?.currentWindowMetrics
+ windowMetrics?.bounds?.width() ?: 0
+ } else {
+ val displayMetrics = DisplayMetrics()
+ @Suppress("DEPRECATION") this?.defaultDisplay?.getMetrics(displayMetrics)
+ displayMetrics.widthPixels
+ }
+}
+fun WindowManager?.getScreenHeight() : Int {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val windowMetrics = this?.currentWindowMetrics
+ windowMetrics?.bounds?.height() ?: 0
+ } else {
+ val displayMetrics = DisplayMetrics()
+ @Suppress("DEPRECATION") this?.defaultDisplay?.getMetrics(displayMetrics)
+ displayMetrics.heightPixels
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/VideoUriFromMediaSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/VideoUriFromMediaSource.kt
new file mode 100644
index 00000000000..52fadd14cdc
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/VideoUriFromMediaSource.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.util
+
+import android.net.Uri
+import androidx.core.net.toUri
+import io.element.android.libraries.matrix.api.media.MediaSource
+import timber.log.Timber
+import java.io.File
+
+fun MediaSource.getVideoUriFromMediaSource () : Uri{
+ return try {
+ val url = this.url
+ when {
+ url.startsWith("http://") || url.startsWith("https://") -> {
+ // Remote URL
+ url.toUri()
+ }
+ url.startsWith("file://") -> {
+ // Already a file URI
+ url.toUri()
+ }
+ url.startsWith("/") -> {
+ // Local file path, convert to file URI
+ Uri.fromFile(File(url))
+ }
+ url.startsWith("content://") -> {
+ // Content URI (from MediaStore, etc.)
+ url.toUri()
+ }
+ else -> {
+ // Try parsing as-is, might work
+ url.toUri()
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("Uri Parsing").e(e)
+ Uri.EMPTY
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
index cb9743bf972..29d3250b120 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
@@ -7,8 +7,13 @@
package io.element.android.libraries.mediaviewer.impl.viewer
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -21,6 +26,7 @@ import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
@@ -29,6 +35,7 @@ import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
import io.element.android.libraries.mediaviewer.impl.datasource.TimelineMediaGalleryDataSource
+import io.element.android.libraries.mediaviewer.impl.floatingvideo.FloatingVideoService
import io.element.android.libraries.mediaviewer.impl.model.hasEvent
import io.element.android.services.toolbox.api.systemclock.SystemClock
@@ -127,15 +134,27 @@ class MediaViewerNode(
@Composable
override fun View(modifier: Modifier) {
+ val context = LocalContext.current
+ val (isMinimized, setMinimized) = remember { mutableStateOf(false) }
+
ForcedDarkElementTheme {
val state = presenter.present()
- MediaViewerView(
- state = state,
- textFileViewer = textFileViewer,
- modifier = modifier,
- audioFocus = audioFocus,
- onBackClick = ::onDone,
- )
+ val data = state.listData
+ .getOrNull(state.currentIndex) as? MediaViewerPageData.MediaViewerData
+ Box(modifier = modifier.fillMaxSize()) {
+ MediaViewerView(
+ state = state,
+ textFileViewer = textFileViewer,
+ modifier = modifier,
+ audioFocus = audioFocus,
+ onBackClick = ::onDone,
+ setMinimize = setMinimized
+ )
+ if (isMinimized && data?.mediaInfo?.mimeType.isMimeTypeVideo() && data != null) {
+
+ FloatingVideoService.startFloating(context, data, 0L)
+ }
+ }
}
}
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
index 110054eb20b..6953e53e527 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
@@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TopAppBarDefaults
@@ -97,6 +99,7 @@ fun MediaViewerView(
onBackClick: () -> Unit,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
+ setMinimize: (Boolean) -> Unit
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
@@ -206,7 +209,8 @@ fun MediaViewerView(
onInfoClick = {
state.eventSink(MediaViewerEvents.OpenInfo(currentData))
},
- eventSink = state.eventSink
+ eventSink = state.eventSink,
+ setMinimize = setMinimize
)
}
else -> {
@@ -445,6 +449,7 @@ private fun MediaViewerTopBar(
onBackClick: () -> Unit,
onInfoClick: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
+ setMinimize: (Boolean) -> Unit
) {
val downloadedMedia by data.downloadedMedia
val actionsEnabled = downloadedMedia.isSuccess()
@@ -483,6 +488,22 @@ private fun MediaViewerTopBar(
),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
+ if (mimeType.isMimeTypeVideo()) {
+ IconButton(
+ enabled = actionsEnabled,
+ onClick = {
+ setMinimize(true)
+ onBackClick()
+ },
+ modifier = Modifier
+ ) {
+ Icon(
+ imageVector = Icons.Default.FullscreenExit,
+ //Common string needed (CommonStrings.action_minimize)
+ contentDescription = stringResource(CommonStrings.error_unknown)
+ )
+ }
+ }
IconButton(
enabled = actionsEnabled,
onClick = {
@@ -598,5 +619,6 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::
audioFocus = null,
textFileViewer = { _, _ -> },
onBackClick = {},
+ setMinimize = {}
)
}