From 9f024c709b56eb97f078c50f178fd80bd249bd0b Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 27 Sep 2025 00:38:52 +0330 Subject: [PATCH 01/15] feat(floating-video): add system-wide mini-player draft - 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. --- app/src/main/AndroidManifest.xml | 7 +- .../floatingvideo/FloatingVideoService.kt | 727 ++++++++++++++++++ .../impl/floatingvideo/VideoDataRepository.kt | 42 + .../impl/viewer/MediaViewerNode.kt | 33 +- .../impl/viewer/MediaViewerView.kt | 24 +- .../impl/src/main/res/drawable/ic_close.xml | 9 + .../src/main/res/drawable/ic_full_screen.xml | 9 + .../main/res/drawable/ic_full_screen_exit.xml | 9 + .../impl/src/main/res/values/strings.xml | 10 + 9 files changed, 860 insertions(+), 10 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoService.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/VideoDataRepository.kt create mode 100644 libraries/mediaviewer/impl/src/main/res/drawable/ic_close.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen_exit.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/values/strings.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 26e8c7dc23f..6f3911eb28e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ - + - + 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..745d4b9a0f4 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoService.kt @@ -0,0 +1,727 @@ +/* + * 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.Color +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.media.MediaPlayer +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Settings +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.TextView +import android.widget.VideoView +import androidx.annotation.OptIn +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData +import timber.log.Timber +import kotlin.math.abs +import androidx.media3.common.util.Log +import androidx.media3.common.util.UnstableApi +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.impl.R +import java.io.File + +class FloatingVideoService : Service() { + private var windowManager: WindowManager? = null + private var floatingView: View? = null + private var videoView: VideoView? = null + private var closeButton: ImageView? = null + private var maximizeButton: ImageView? = null + private var playPauseButton: ImageView? = null + private var overlayContainer: FrameLayout? = null + private var currentVideoData: MediaViewerPageData.MediaViewerData? = null + private var currentPosition: Long = 0L + private var isMaximized = false + private var seekBar: SeekBar? = null + private var progressHandler = Handler(Looper.getMainLooper()) + + private var overlayHandler = Handler(Looper.getMainLooper()) + private var controlsVisibility: Boolean = false + + private lateinit var layoutParams: 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" // Changed from EXTRA_VIDEO_DATA + 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)) { + // Request overlay permission + val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply { + data = Uri.parse("package:${context.packageName}") + 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 + VideoDataRepository.getInstance().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) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + } + + 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.getInstance().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.getInstance().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() + floatingView = createFloatingVideoLayout() + windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + + val fixedHeight = dpToPx(200) + layoutParams = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowManager.LayoutParams( + dpToPx(150), + fixedHeight, + 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 { + WindowManager.LayoutParams( + dpToPx(150), + fixedHeight, + WindowManager.LayoutParams.TYPE_PHONE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + PixelFormat.TRANSLUCENT + ) + } + + layoutParams.gravity = Gravity.TOP or Gravity.START + layoutParams.x = getScreenWidth() - dpToPx(150) - dpToPx(16) + layoutParams.y = getScreenHeight() - fixedHeight - dpToPx(100) + + setupTouchListener(layoutParams) + + try { + windowManager?.addView(floatingView, layoutParams) + + setupVideo(layoutParams, fixedHeight) + } catch (e: Exception) { + Timber.tag("FloatingVideoService").e(e, "Error adding floating view") + } + } + + + @SuppressLint("ClickableViewAccessibility") + private fun createFloatingVideoLayout(): ViewGroup { + var cornerRadius = dpToPx(0).toFloat() + + val container = FrameLayout(this).apply { + layoutDirection = View.LAYOUT_DIRECTION_LTR + clipToOutline = true // This enables corner clipping + + background = GradientDrawable().apply { + setColor(Color.BLACK) // Optional: background behind video + this.cornerRadius = cornerRadius + } + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + + ) + } + videoView = VideoView(this).apply { + id = View.generateViewId() + layoutDirection = View.LAYOUT_DIRECTION_LTR + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT + ).apply { + setMargins(2, 2, 2, 2) + } + + } + + container.addView(videoView) + overlayContainer = FrameLayout(this).apply { + id = View.generateViewId() + layoutDirection = View.LAYOUT_DIRECTION_LTR + + // Semi-transparent black background + background = GradientDrawable().apply { + setColor(Color.parseColor("#80000000")) // 50% transparent black + } + + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, dpToPx(32) + ) + + isFocusable = false + isFocusableInTouchMode = false + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + clearFocus() + // Initially visible, you can hide/show this container for auto-hide functionality + visibility = View.VISIBLE + } + closeButton = ImageView(this).apply { + setImageResource(R.drawable.ic_close) + setColorFilter(Color.WHITE) + + + + layoutParams = FrameLayout.LayoutParams(dpToPx(24), dpToPx(24)).apply { + gravity = Gravity.TOP or Gravity.END + setMargins(dpToPx(4), dpToPx(4), dpToPx(4), dpToPx(4)) + } + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + view.alpha = 0.7f + true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + view.alpha = 1f + if (event.action == MotionEvent.ACTION_UP) { + removeFloatingView() + stopSelf() + } + true + } + + else -> false + } + } + } + overlayContainer?.addView(closeButton) + + maximizeButton = ImageView(this).apply { + setImageResource(R.drawable.ic_full_screen) + setColorFilter(Color.WHITE) + + background = GradientDrawable().apply { + cornerRadius = dpToPx(16).toFloat() + } + + layoutParams = FrameLayout.LayoutParams(dpToPx(24), dpToPx(24)).apply { + gravity = Gravity.TOP or Gravity.START + setMargins(dpToPx(4), dpToPx(4), dpToPx(4), dpToPx(4)) + } + + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + view.alpha = 0.7f + true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + view.alpha = 1f + if (event.action == MotionEvent.ACTION_UP) { + toggleFullScreen() + } + true + } + + else -> false + } + } + } + overlayContainer?.addView(maximizeButton) + + val controlBar = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + dpToPx(48) // height of bar + ).apply { + gravity = Gravity.BOTTOM + } + setPadding(dpToPx(8), dpToPx(4), dpToPx(8), dpToPx(4)) + setBackgroundColor(Color.parseColor("#80000000")) // optional semi-transparent bg + } + playPauseButton = ImageView(this).apply { + setImageResource(android.R.drawable.ic_media_pause) + setColorFilter(Color.WHITE) + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(Color.parseColor("#40FFFFFF")) // semi-transparent background + } + layoutParams = LinearLayout.LayoutParams(dpToPx(20), dpToPx(20)).apply { + + } + + setOnClickListener { + videoView?.let { vv -> + if (vv.isPlaying) { + vv.pause() + setImageResource(android.R.drawable.ic_media_play) + } else { + setImageResource(android.R.drawable.ic_media_pause) + vv.start() + + } + } + } + } + + seekBar = SeekBar(this).apply { + visibility = View.VISIBLE + layoutParams = LinearLayout.LayoutParams( + 0, dpToPx(32), 1f // take remaining width + ).apply { + setMargins(8,0,8,0) + } + setPadding(dpToPx(16), dpToPx(0), dpToPx(16), dpToPx(0)) + + } + + controlBar.addView(playPauseButton) + controlBar.addView(seekBar) + +// Add control bar to container + container.addView(controlBar) + seekBar?.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + videoView?.seekTo(progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + // pause updates while user is dragging + progressHandler.removeCallbacksAndMessages(null) + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + startProgressUpdater() + showControls() + } + }) + + container.addView(overlayContainer) + return container + } + + + private fun setupVideo(layoutParams: WindowManager.LayoutParams, fixedHeight: Int) { + currentVideoData?.let { data -> + val resolvedUri = when (val downloadedState = data.downloadedMedia.value) { + is AsyncData.Success -> downloadedState.data.uri + else -> getVideoUriFromMediaSource(data.mediaSource) + } + videoView?.apply { + stopPlayback() + setVideoURI(resolvedUri) + setMediaController(null) + + setOnPreparedListener { mediaPlayer -> + seekBar?.max = mediaPlayer.duration + startProgressUpdater() + mediaPlayer.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING) + + // Calculate scaled width for fixed height + val videoWidth = mediaPlayer.videoWidth + val videoHeight = mediaPlayer.videoHeight + if (videoWidth > 0 && videoHeight > 0) { + val scaledWidth = fixedHeight * videoWidth / videoHeight + layoutParams.width = scaledWidth + layoutParams.height = fixedHeight + windowManager?.updateViewLayout(floatingView, layoutParams) + + val videoLayoutParams = videoView?.layoutParams + videoLayoutParams?.width = scaledWidth + videoLayoutParams?.height = fixedHeight + videoView?.layoutParams = videoLayoutParams + + Timber.tag("FloatingVideoService") + .d("Updated floating view size: ${scaledWidth}x$fixedHeight") + } + + if (currentPosition > 0) { + seekTo(currentPosition.toInt()) + + } + start() + } + setOnErrorListener { _, what, extra -> + showErrorInFloatingView(context.getString(R.string.video_playback_error)) + true + } + setOnInfoListener { _, what, _ -> + false + } + } + } + } + + + private fun showErrorInFloatingView(message: String) { + floatingView?.let { view -> + // Find or create a TextView to show error + var errorText = view.findViewWithTag("error_text") + if (errorText == null) { + errorText = TextView(this).apply { + tag = "error_text" + text = message + setTextColor(Color.WHITE) + textSize = 12f + gravity = Gravity.CENTER + setBackgroundColor(Color.TRANSPARENT) + + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT + ).apply { + gravity = Gravity.CENTER + } + } + (view as ViewGroup).addView(errorText) + } else { + errorText.text = message + errorText.visibility = View.VISIBLE + } + } + } + + private fun setupTouchListener(layoutParams: WindowManager.LayoutParams) { + floatingView?.setOnTouchListener(object : View.OnTouchListener { + private var initialX = 0 + private var initialY = 0 + private var initialTouchX = 0f + private var initialTouchY = 0f + private var isDragging = false + + override fun onTouch(v: View?, event: MotionEvent?): Boolean { + when (event?.action) { + MotionEvent.ACTION_DOWN -> { + initialX = layoutParams.x + initialY = layoutParams.y + initialTouchX = event.rawX + initialTouchY = event.rawY + isDragging = false + return true + } + + MotionEvent.ACTION_MOVE -> { + val deltaX = event.rawX - initialTouchX + val deltaY = event.rawY - initialTouchY + + if (abs(deltaX) > 10 || abs(deltaY) > 10) { + isDragging = true + layoutParams.x = initialX + deltaX.toInt() + layoutParams.y = initialY + deltaY.toInt() + + layoutParams.x = + layoutParams.x.coerceIn(0, getScreenWidth() - dpToPx(150)) + layoutParams.y = + layoutParams.y.coerceIn(0, getScreenHeight() - dpToPx(100)) + + windowManager?.updateViewLayout(floatingView, layoutParams) + } + return true + } + + MotionEvent.ACTION_UP -> { + if (!isDragging) { + // Single tap - toggle play/pause + videoView?.let { vv -> + if (vv.isPlaying) { + vv.pause() + playPauseButton?.setImageResource( + android.R.drawable.ic_media_play + ) + } else { + vv.start() + playPauseButton?.setImageResource( + android.R.drawable.ic_media_pause + ) + } + } + } + return true + } + } + return false + } + }) + } + + + 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 + } + } + + private fun updateButtonSizes(isMaximized: Boolean) { + val size = if (isMaximized) dpToPx(28) else dpToPx(24) + val margin = if (isMaximized) dpToPx(12) else dpToPx(4) + + closeButton?.layoutParams = + (closeButton?.layoutParams as? FrameLayout.LayoutParams)?.apply { + width = size + height = size + setMargins(margin, margin, margin, margin) + } + + maximizeButton?.layoutParams = + (maximizeButton?.layoutParams as? FrameLayout.LayoutParams)?.apply { + width = size + height = size + setMargins(margin, margin, margin, margin) + } + + + // Overlay container does not need weird expressions + overlayContainer?.layoutParams = + (overlayContainer?.layoutParams as? FrameLayout.LayoutParams)?.apply { + width = FrameLayout.LayoutParams.MATCH_PARENT + height = FrameLayout.LayoutParams.WRAP_CONTENT + } + + // Make sure to request layout after changes + closeButton?.requestLayout() + maximizeButton?.requestLayout() + overlayContainer?.requestLayout() + } + + override fun onDestroy() { + super.onDestroy() + removeFloatingView() + } + + private fun dpToPx(dp: Int): Int { + return (dp * resources.displayMetrics.density).toInt() + } + + private fun toggleFullScreen() { + if (!isMaximized) { + isMaximized = true + maximizeButton?.apply { + setImageResource(R.drawable.ic_full_screen) + } + updateButtonSizes(false) + hideControls() + + } else { + isMaximized = false + maximizeButton?.apply { + setImageResource(R.drawable.ic_full_screen_exit) + } + updateButtonSizes(true) + showControls() + } + + if (floatingView?.parent == null) return + + if (!isMaximized) { + // Full screen with margin + val margin = dpToPx(24) + + // shrink the container size by margins + layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT + layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT + layoutParams.x = 0 + layoutParams.y = 0 + + // video fills the container, container itself is inset by margins + val screenWidth = Resources.getSystem().displayMetrics.widthPixels + val screenHeight = Resources.getSystem().displayMetrics.heightPixels + + layoutParams.width = screenWidth - margin * 2 + layoutParams.height = screenHeight - margin * 2 + layoutParams.x = margin + layoutParams.y = margin + + videoView?.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT + ) + + } else { + // Back to floating size + val fixedHeight = dpToPx(200) + val scaledWidth = fixedHeight * (videoView?.width ?: 16) / (videoView?.height ?: 9) + + layoutParams.width = scaledWidth + layoutParams.height = fixedHeight + layoutParams.x = 0 + layoutParams.y = 0 + + videoView?.layoutParams = FrameLayout.LayoutParams(scaledWidth, fixedHeight) + } + + windowManager?.updateViewLayout(floatingView, layoutParams) + } + + private fun getScreenWidth(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager?.currentWindowMetrics + windowMetrics?.bounds?.width() ?: 0 + } else { + val displayMetrics = DisplayMetrics() + @Suppress("DEPRECATION") windowManager?.defaultDisplay?.getMetrics(displayMetrics) + displayMetrics.widthPixels + } + } + + private fun getScreenHeight(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager?.currentWindowMetrics + windowMetrics?.bounds?.height() ?: 0 + } else { + val displayMetrics = DisplayMetrics() + @Suppress("DEPRECATION") windowManager?.defaultDisplay?.getMetrics(displayMetrics) + displayMetrics.heightPixels + } + } + + + private fun showControls() { + seekBar?.visibility = View.VISIBLE + + overlayHandler.removeCallbacksAndMessages(null) + overlayHandler.postDelayed({ hideControls() }, 3000) + controlsVisibility = true + } + + private fun hideControls() { + + controlsVisibility = false + + } + + private fun startProgressUpdater() { + progressHandler.post(object : Runnable { + override fun run() { + if (videoView != null && videoView!!.isPlaying) { + seekBar?.progress = videoView!!.currentPosition + if (!videoView!!.isPlaying) { + // show controls automatically when paused + showControls() + } + } + progressHandler.postDelayed(this, 500) // update every 0.5s + } + }) + } + +} + +@OptIn(UnstableApi::class) +fun getVideoUriFromMediaSource(mediaSource: MediaSource): Uri { + return try { + val url = mediaSource.url + Log.d("VideoPlayer", "MediaSource URL: $url") + + when { + url.startsWith("http://") || url.startsWith("https://") -> { + // Remote URL + Uri.parse(url) + } + url.startsWith("file://") -> { + // Already a file URI + Uri.parse(url) + } + url.startsWith("/") -> { + // Local file path, convert to file URI + Uri.fromFile(File(url)) + } + url.startsWith("content://") -> { + // Content URI (from MediaStore, etc.) + Uri.parse(url) + } + else -> { + Log.w("VideoPlayer", "Unknown URL format: $url") + // Try parsing as-is, might work + Uri.parse(url) + } + } + } catch (e: Exception) { + Uri.EMPTY + } +} 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..65b61d1f6dc --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/VideoDataRepository.kt @@ -0,0 +1,42 @@ +/* + * 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 io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData + +class VideoDataRepository { + companion object { + @Volatile + private var INSTANCE: VideoDataRepository? = null + + fun getInstance(): VideoDataRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: VideoDataRepository().also { INSTANCE = it } + } + } + } + + 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/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 7165ac7c8e8..a61ad163abc 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..50d2db0e4ef 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) } @@ -170,6 +173,7 @@ fun MediaViewerView( }, audioFocus = audioFocus, isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId, + setMinimize = setMinimize ) // Bottom bar AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { @@ -206,7 +210,8 @@ fun MediaViewerView( onInfoClick = { state.eventSink(MediaViewerEvents.OpenInfo(currentData)) }, - eventSink = state.eventSink + eventSink = state.eventSink, + setMinimize = setMinimize ) } else -> { @@ -295,6 +300,7 @@ private fun MediaViewerPage( onShowOverlayChange: (Boolean) -> Unit, audioFocus: AudioFocus?, modifier: Modifier = Modifier, + setMinimize: (Boolean) -> Unit ) { val currentShowOverlay by rememberUpdatedState(showOverlay) val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) @@ -445,6 +451,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 +490,20 @@ private fun MediaViewerTopBar( ), navigationIcon = { BackButton(onClick = onBackClick) }, actions = { + if (mimeType.isMimeTypeVideo()) { + IconButton( + onClick = { + setMinimize(true) + onBackClick() + }, + modifier = Modifier + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = "Minimize" + ) + } + } IconButton( enabled = actionsEnabled, onClick = { @@ -598,5 +619,6 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider:: audioFocus = null, textFileViewer = { _, _ -> }, onBackClick = {}, + setMinimize = {} ) } diff --git a/libraries/mediaviewer/impl/src/main/res/drawable/ic_close.xml b/libraries/mediaviewer/impl/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000000..9b5fcccf837 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen.xml b/libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen.xml new file mode 100644 index 00000000000..f75decb02dd --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen_exit.xml b/libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen_exit.xml new file mode 100644 index 00000000000..1f2e4d457b6 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/drawable/ic_full_screen_exit.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/mediaviewer/impl/src/main/res/values/strings.xml b/libraries/mediaviewer/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000000..17a002abdb8 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + Video playback error + From 16db3ab386388acf49c92966cf00d4c6225badc6 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 27 Sep 2025 12:12:23 +0330 Subject: [PATCH 02/15] fixed the bug of video being full size at first --- .../mediaviewer/impl/floatingvideo/FloatingVideoService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 745d4b9a0f4..9d02cbcd0cc 100644 --- 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 @@ -55,7 +55,7 @@ class FloatingVideoService : Service() { private var overlayContainer: FrameLayout? = null private var currentVideoData: MediaViewerPageData.MediaViewerData? = null private var currentPosition: Long = 0L - private var isMaximized = false + private var isMaximized = true private var seekBar: SeekBar? = null private var progressHandler = Handler(Looper.getMainLooper()) From fe18ef7fc50c76f62f01d1bd601a25f7dddd8540 Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 30 Sep 2025 13:16:29 +0330 Subject: [PATCH 03/15] -remove unused setMinimize -changed minimize buttons text to error_unknown but it need new CommonString -moved service permission and registration from app manifest to mediaViewer manifest --- .idea/codeStyles/Project.xml | 35 +++++++++++++++++++ app/src/main/AndroidManifest.xml | 1 - gradle.properties | 6 +++- .../impl/src/main/AndroidManifest.xml | 19 ++++++++++ .../impl/viewer/MediaViewerView.kt | 5 ++- 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/AndroidManifest.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index cdef735570f..012427d5622 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,40 @@ + + + diff --git a/gradle.properties b/gradle.properties index 2914c7065ae..18399ccf06c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -#org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseG1GC +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseG1GC # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK @@ -54,7 +54,3 @@ ksp.allow.all.target.configuration=false # Used to prevent detekt from reusing invalid cached rules detekt.use.worker.api=true -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 From f91d1679c9c2e5a92cfe9437b33ce527b95c35f1 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 13 Oct 2025 00:44:48 +0330 Subject: [PATCH 11/15] - Broke down large FloatingVideoService into multiple smaller files for better readability and maintainability. - Replaced custom UI elements with components from io.element.android.libraries.designsystem.theme.components. --- .../floatingvideo/FloatingVideoService.kt | 300 ++---------------- .../floatingvideo/ui/FloatingVideoOverlay.kt | 213 +++++++++++++ .../floatingvideo/util/ScreenSizeHelpers.kt | 33 ++ .../util/VideoUriFromMediaSource.kt | 45 +++ 4 files changed, 318 insertions(+), 273 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/ui/FloatingVideoOverlay.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/ScreenSizeHelpers.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/util/VideoUriFromMediaSource.kt 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 index 456fe073153..d9c31abb406 100644 --- 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 @@ -13,43 +13,14 @@ import android.content.Context import android.content.Intent import android.content.res.Resources import android.graphics.PixelFormat -import android.net.Uri import android.os.Build import android.os.IBinder import android.provider.Settings -import android.util.DisplayMetrics import android.view.Gravity import android.view.View import android.view.WindowManager import android.widget.VideoView -import androidx.annotation.OptIn -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.material.IconButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -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.platform.ComposeView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry @@ -57,20 +28,17 @@ import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData import timber.log.Timber -import androidx.media3.common.util.Log -import androidx.media3.common.util.UnstableApi import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.ui.strings.CommonStrings -import java.io.File import androidx.core.net.toUri +import dev.zacsweers.metro.Inject +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 class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner { private var windowManager: WindowManager? = null @@ -209,7 +177,7 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav windowLayoutParams.gravity = Gravity.TOP or Gravity.START windowLayoutParams.x = 0 - windowLayoutParams.y = getScreenHeight() - dpToPx(300) + windowLayoutParams.y = windowManager.getScreenHeight() - dpToPx(300) val composeView = ComposeView(this).apply { setViewTreeLifecycleOwner(this@FloatingVideoService) @@ -223,8 +191,13 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav }, onToggleFullScreen = { Timber.tag("onToggleFullScreen").d(isMaximized.toString()) - toggleFullScreen() - } + toggleFullScreen(windowLayoutParams , windowManager) + }, + floatingView = floatingView, + isMaximized = isMaximized , + currentVideoData = currentVideoData, + windowManager = windowManager, + windowLayoutParams = windowLayoutParams ) } } @@ -252,170 +225,6 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav } } - @Composable - fun FloatingVideoOverlay( - onClose: () -> Unit, - onToggleFullScreen: () -> Unit - ) { - var currentAspectRatio by remember { mutableStateOf(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 -> getVideoUriFromMediaSource(data.mediaSource) - } - } - - // Function to update window size directly - fun updateWindowSize(aspectRatio: Float) { - val widthFrac = if (aspectRatio > 1f) 0.6f else 0.3f - val width = if (isMaximized) { - (getScreenWidth() * widthFrac).toInt() - } else { - (getScreenWidth() * 0.9f).toInt() - } - val height = (width / aspectRatio).toInt() - - Timber.tag("WindowUpdate").d("Updating window - width: $width, height: $height, aspectRatio: $aspectRatio") - - 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) - } - - // Update window size when isMaximized changes - LaunchedEffect(isMaximized) { - Timber.tag("MaximizeToggle").d("isMaximized changed to: $isMaximized, updating with aspectRatio: $currentAspectRatio") - updateWindowSize(currentAspectRatio) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(color = androidx.compose.ui.graphics.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() - } - - } - }, - 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 = androidx.compose.ui.graphics.Brush.verticalGradient( - colors = listOf( - androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f), - androidx.compose.ui.graphics.Color.Transparent - ) - ) - ) - .padding(4.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - IconButton( - onClick = { - onToggleFullScreen() - updateWindowSize(currentAspectRatio) - }, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.Fullscreen, - //action full screen needs to be added to CommonsString - contentDescription = stringResource(CommonStrings.action_view), - tint = androidx.compose.ui.graphics.Color.White - ) - } - - IconButton( - onClick = onClose, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(CommonStrings.action_close), - tint = androidx.compose.ui.graphics.Color.White - ) - } - } - } - } - override fun onDestroy() { super.onDestroy() removeFloatingView() @@ -427,7 +236,7 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav return (dp * resources.displayMetrics.density).toInt() } - private fun toggleFullScreen() { + private fun toggleFullScreen(layoutParams : WindowManager.LayoutParams, windowManager: WindowManager?) { isMaximized = !isMaximized if (floatingView?.parent == null) return @@ -437,84 +246,29 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav val margin = dpToPx(24) // shrink the container size by margins - windowLayoutParams.width = WindowManager.LayoutParams.MATCH_PARENT - windowLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT - windowLayoutParams.x = 0 - windowLayoutParams.y = 0 + layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT + layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT + layoutParams.x = 0 + layoutParams.y = 0 // video fills the container, container itself is inset by margins val screenWidth = Resources.getSystem().displayMetrics.widthPixels - windowLayoutParams.width = screenWidth - margin * 2 - windowLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT - windowLayoutParams.x = margin - windowLayoutParams.y = margin + layoutParams.width = screenWidth - margin * 2 + layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT + layoutParams.x = margin + layoutParams.y = margin } else { - val scaledWidth = getScreenWidth() * 0.3f - - windowLayoutParams.width = scaledWidth.toInt() - windowLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT - windowLayoutParams.x = 0 - windowLayoutParams.y = 0 - } - - windowManager?.updateViewLayout(floatingView, windowLayoutParams) - } + val scaledWidth = windowManager.getScreenWidth() * 0.3f - private fun getScreenWidth(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val windowMetrics = windowManager?.currentWindowMetrics - windowMetrics?.bounds?.width() ?: 0 - } else { - val displayMetrics = DisplayMetrics() - @Suppress("DEPRECATION") windowManager?.defaultDisplay?.getMetrics(displayMetrics) - displayMetrics.widthPixels + layoutParams.width = scaledWidth.toInt() + layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT + layoutParams.x = 0 + layoutParams.y = 0 } - } - private fun getScreenHeight(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val windowMetrics = windowManager?.currentWindowMetrics - windowMetrics?.bounds?.height() ?: 0 - } else { - val displayMetrics = DisplayMetrics() - @Suppress("DEPRECATION") windowManager?.defaultDisplay?.getMetrics(displayMetrics) - displayMetrics.heightPixels - } + windowManager?.updateViewLayout(floatingView, layoutParams) } -} -@OptIn(UnstableApi::class) -fun getVideoUriFromMediaSource(mediaSource: MediaSource): Uri { - return try { - val url = mediaSource.url - Log.d("VideoPlayer", "MediaSource URL: $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 -> { - Log.w("VideoPlayer", "Unknown URL format: $url") - // 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/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..c6efc7ae14e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/ui/FloatingVideoOverlay.kt @@ -0,0 +1,213 @@ +/* + * 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: () -> Unit, + currentVideoData : MediaViewerPageData.MediaViewerData?, + isMaximized : Boolean , + windowManager : WindowManager?, + windowLayoutParams : WindowManager.LayoutParams, + floatingView : View? +) { + 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) + } + + // Update window size when isMaximized changes + LaunchedEffect(isMaximized) { + updateWindowSize(currentAspectRatio) + } + + 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() + } + + } + }, + 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() + updateWindowSize(currentAspectRatio) + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = CompoundIcons.Expand(), + //action full screen needs to be added to CommonsString + contentDescription = stringResource(CommonStrings.action_view), + tint = Color.White + ) + } + + 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 + } +} From b33720c45e0e49566a776ac56a83aa5a416cead7 Mon Sep 17 00:00:00 2001 From: isazadeh Date: Mon, 13 Oct 2025 17:01:22 +0330 Subject: [PATCH 12/15] fix(video): stabilize floating video behavior - Disabled minimize action until video is ready to prevent crash - Fixed minimized video size issue after refactoring FloatingVideoService --- .../floatingvideo/FloatingVideoService.kt | 54 ++++++++++++------- .../floatingvideo/ui/FloatingVideoOverlay.kt | 20 ++++--- .../impl/viewer/MediaViewerView.kt | 1 + 3 files changed, 45 insertions(+), 30 deletions(-) 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 index d9c31abb406..e08f6202a6a 100644 --- 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 @@ -14,7 +14,9 @@ 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 @@ -35,7 +37,6 @@ import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.core.net.toUri -import dev.zacsweers.metro.Inject 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 @@ -191,8 +192,7 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav }, onToggleFullScreen = { Timber.tag("onToggleFullScreen").d(isMaximized.toString()) - toggleFullScreen(windowLayoutParams , windowManager) - }, + this@FloatingVideoService.toggleFullScreen(it) }, floatingView = floatingView, isMaximized = isMaximized , currentVideoData = currentVideoData, @@ -236,39 +236,55 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav return (dp * resources.displayMetrics.density).toInt() } - private fun toggleFullScreen(layoutParams : WindowManager.LayoutParams, windowManager: WindowManager?) { + + private fun toggleFullScreen( aspectRatio : Float ) { + val layoutParams = windowLayoutParams + val wm = windowManager ?: return + val view = floatingView ?: return + isMaximized = !isMaximized - if (floatingView?.parent == null) return + if (view.parent == null) return - if (!isMaximized) { - // Full screen with margin - val margin = dpToPx(24) - // shrink the container size by margins - layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT - layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT - layoutParams.x = 0 - layoutParams.y = 0 + 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() - // video fills the container, container itself is inset by margins - val screenWidth = Resources.getSystem().displayMetrics.widthPixels + + + 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 { - val scaledWidth = windowManager.getScreenWidth() * 0.3f - + // Minimized + val scaledWidth = wm.getScreenWidth() * 0.3f layoutParams.width = scaledWidth.toInt() layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT layoutParams.x = 0 layoutParams.y = 0 } - windowManager?.updateViewLayout(floatingView, layoutParams) - } + 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/ui/FloatingVideoOverlay.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/ui/FloatingVideoOverlay.kt index c6efc7ae14e..28c135d5f18 100644 --- 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 @@ -48,9 +48,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun FloatingVideoOverlay( onClose: () -> Unit, - onToggleFullScreen: () -> Unit, + onToggleFullScreen: (Float) -> Unit, currentVideoData : MediaViewerPageData.MediaViewerData?, - isMaximized : Boolean , + isMaximized : Boolean, windowManager : WindowManager?, windowLayoutParams : WindowManager.LayoutParams, floatingView : View? @@ -91,10 +91,6 @@ fun FloatingVideoOverlay( updateWindowSize(16f / 9f) } - // Update window size when isMaximized changes - LaunchedEffect(isMaximized) { - updateWindowSize(currentAspectRatio) - } Box( modifier = Modifier @@ -185,16 +181,18 @@ fun FloatingVideoOverlay( ) { IconButton( onClick = { - onToggleFullScreen() - updateWindowSize(currentAspectRatio) + onToggleFullScreen ( currentAspectRatio ) +// updateWindowSize(currentAspectRatio) }, - modifier = Modifier.size(32.dp) + //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.action_view), - tint = Color.White + contentDescription = stringResource(CommonStrings.a11y_expand_message_text_field), + tint = Color.White, + modifier = Modifier.padding(4.dp) ) } 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 cac342c4a15..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 @@ -490,6 +490,7 @@ private fun MediaViewerTopBar( actions = { if (mimeType.isMimeTypeVideo()) { IconButton( + enabled = actionsEnabled, onClick = { setMinimize(true) onBackClick() From d4228a87e103f051138001d7d853e72d0262935e Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 16 Oct 2025 20:06:36 +0330 Subject: [PATCH 13/15] -Converted VideoDataRepository to @SingleIn(AppScope::class) with @Inject. -Created FloatingVideoServiceBindings (inject(service), videoDataRepository()). -Injected VideoDataRepository in FloatingVideoService, used bindings<...>().inject(this) in onCreate, and replaced singleton calls with DI. --- .../floatingvideo/FloatingVideoService.kt | 13 +++++++++---- .../FloatingVideoServiceBindings.kt | 19 +++++++++++++++++++ .../impl/floatingvideo/VideoDataRepository.kt | 15 +++++---------- 3 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/floatingvideo/FloatingVideoServiceBindings.kt 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 index e08f6202a6a..cf2d471bba1 100644 --- 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 @@ -40,6 +40,8 @@ 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 @@ -77,8 +79,8 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav // Generate unique ID for this video session val videoId = "floating_video_${System.currentTimeMillis()}" - // Store the video data in repository - VideoDataRepository.getInstance().storeVideoData(videoId, videoData) + // 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 @@ -89,6 +91,8 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav } } + @Inject lateinit var videoDataRepository: VideoDataRepository + override fun onBind(intent: Intent?): IBinder? = null override val viewModelStore = ViewModelStore() @@ -102,6 +106,7 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav override fun onCreate() { super.onCreate() + bindings().inject(this) windowManager = getSystemService(WINDOW_SERVICE) as WindowManager // 1. Attach controller savedStateRegistryController.performAttach() @@ -124,7 +129,7 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav if (videoId != null) { // Get video data from repository using the ID - val videoData = VideoDataRepository.getInstance().getVideoData(videoId) + val videoData = videoDataRepository.getVideoData(videoId) if (videoData != null) { eventId = videoData.eventId?.value ?: "" currentVideoData = videoData @@ -138,7 +143,7 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav ACTION_STOP_FLOATING -> { // Clean up stored data currentVideoId?.let { videoId -> - VideoDataRepository.getInstance().removeVideoData(videoId) + videoDataRepository.removeVideoData(videoId) } removeFloatingView() stopSelf() 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 index 65b61d1f6dc..5d9c208c3fb 100644 --- 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 @@ -7,19 +7,14 @@ 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 { - companion object { - @Volatile - private var INSTANCE: VideoDataRepository? = null - - fun getInstance(): VideoDataRepository { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: VideoDataRepository().also { INSTANCE = it } - } - } - } private val videoDataMap = mutableMapOf() From 19defb1221b64119011ed14e8b11f56faec5a343 Mon Sep 17 00:00:00 2001 From: jesus Date: Fri, 17 Oct 2025 18:08:30 +0330 Subject: [PATCH 14/15] -on video completes the minimized video gose away --- .../impl/floatingvideo/FloatingVideoService.kt | 13 ++++++++++--- .../impl/floatingvideo/ui/FloatingVideoOverlay.kt | 7 +++++-- 2 files changed, 15 insertions(+), 5 deletions(-) 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 index cf2d471bba1..24cf7fbaef4 100644 --- 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 @@ -198,6 +198,10 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav onToggleFullScreen = { Timber.tag("onToggleFullScreen").d(isMaximized.toString()) this@FloatingVideoService.toggleFullScreen(it) }, + onCompleted = { + removeFloatingView() + stopSelf() + }, floatingView = floatingView, isMaximized = isMaximized , currentVideoData = currentVideoData, @@ -232,15 +236,18 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav override fun onDestroy() { super.onDestroy() - removeFloatingView() - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - viewModelStore.clear() + 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 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 index 28c135d5f18..1e92b1574bc 100644 --- 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 @@ -53,7 +53,8 @@ fun FloatingVideoOverlay( isMaximized : Boolean, windowManager : WindowManager?, windowLayoutParams : WindowManager.LayoutParams, - floatingView : View? + floatingView : View?, + onCompleted : () -> Unit ) { var currentAspectRatio by remember { mutableFloatStateOf(16f / 9f) } val videoViewRef = remember { mutableStateOf(null) } @@ -136,6 +137,9 @@ fun FloatingVideoOverlay( } start() } + setOnCompletionListener { + onCompleted() + } } }, @@ -182,7 +186,6 @@ fun FloatingVideoOverlay( IconButton( onClick = { onToggleFullScreen ( currentAspectRatio ) -// updateWindowSize(currentAspectRatio) }, //it seems the CompoundIcons.Expand() is bigger than the CompoundIcons.Close(), modifier = Modifier.size(28.dp) From 2cf92d5651adacea44c2a710faa0ee0bc67b8715 Mon Sep 17 00:00:00 2001 From: jesus Date: Fri, 17 Oct 2025 18:22:58 +0330 Subject: [PATCH 15/15] -Toast for display over apps persmission(string needs to be added to commonStrings --- .../impl/floatingvideo/FloatingVideoService.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 index 24cf7fbaef4..879d47b266b 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -65,6 +66,10 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav 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() @@ -197,13 +202,14 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav }, onToggleFullScreen = { Timber.tag("onToggleFullScreen").d(isMaximized.toString()) - this@FloatingVideoService.toggleFullScreen(it) }, + this@FloatingVideoService.toggleFullScreen(it) + }, onCompleted = { removeFloatingView() stopSelf() }, floatingView = floatingView, - isMaximized = isMaximized , + isMaximized = isMaximized, currentVideoData = currentVideoData, windowManager = windowManager, windowLayoutParams = windowLayoutParams @@ -243,13 +249,13 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav return (dp * resources.displayMetrics.density).toInt() } - private fun onVideoComplete(){ + private fun onVideoComplete() { removeFloatingView() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) viewModelStore.clear() } - private fun toggleFullScreen( aspectRatio : Float ) { + private fun toggleFullScreen(aspectRatio: Float) { val layoutParams = windowLayoutParams val wm = windowManager ?: return val view = floatingView ?: return @@ -258,7 +264,6 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav if (view.parent == null) return - val widthFrac = if (aspectRatio > 1f) 0.6f else 0.3f val width = if (isMaximized) { (windowManager.getScreenWidth() * widthFrac).toInt() @@ -296,7 +301,5 @@ class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, Sav Handler(Looper.getMainLooper()).post { wm.updateViewLayout(view, layoutParams) } - } - }