-
Notifications
You must be signed in to change notification settings - Fork 331
Feature: Add minimized video service (YouTube-style PiP) #5423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/bma/minimizeVideoPlayer
Are you sure you want to change the base?
Changes from all commits
9f024c7
a111b5e
16db3ab
bbc5458
0f3fec9
fe18ef7
49a765d
daf7461
46d226b
d07dc1e
b99e67f
2f9f56d
7c13c1f
b9f415f
a90c19a
6a3cb61
f91d167
1466e74
b33720c
d4228a8
19defb1
2cf92d5
44105d2
1978751
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <!-- | ||
| ~ Copyright 2025 New Vector Ltd. | ||
| ~ | ||
| ~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial | ||
| ~ Please see LICENSE files in the repository root for full details. | ||
| --> | ||
|
|
||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||
| package="io.element.android.libraries.mediaviewer.impl"> | ||
| <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> | ||
|
|
||
| <application> | ||
| <service | ||
| android:name=".floatingvideo.FloatingVideoService" | ||
| android:enabled="true" | ||
| android:exported="false" /> | ||
| </application> | ||
| </manifest> |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,305 @@ | ||||||
| /* | ||||||
| * Copyright 2025 New Vector Ltd. | ||||||
| * | ||||||
| * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial | ||||||
| * Please see LICENSE files in the repository root for full details. | ||||||
| */ | ||||||
|
|
||||||
| package io.element.android.libraries.mediaviewer.impl.floatingvideo | ||||||
|
|
||||||
| import android.annotation.SuppressLint | ||||||
| import android.app.Service | ||||||
| import android.content.Context | ||||||
| import android.content.Intent | ||||||
| import android.content.res.Resources | ||||||
| import android.graphics.PixelFormat | ||||||
| import android.os.Build | ||||||
| import android.os.Handler | ||||||
| import android.os.IBinder | ||||||
| import android.os.Looper | ||||||
| import android.provider.Settings | ||||||
| import android.view.Gravity | ||||||
| import android.view.View | ||||||
| import android.view.WindowManager | ||||||
| import android.widget.Toast | ||||||
| import android.widget.VideoView | ||||||
| import androidx.compose.ui.platform.ComposeView | ||||||
| import androidx.lifecycle.Lifecycle | ||||||
| import androidx.lifecycle.LifecycleOwner | ||||||
| import androidx.lifecycle.LifecycleRegistry | ||||||
| import androidx.lifecycle.ViewModelStore | ||||||
| import androidx.lifecycle.ViewModelStoreOwner | ||||||
| import androidx.lifecycle.setViewTreeLifecycleOwner | ||||||
| import androidx.lifecycle.setViewTreeViewModelStoreOwner | ||||||
| import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData | ||||||
| import timber.log.Timber | ||||||
| import androidx.savedstate.SavedStateRegistry | ||||||
| import androidx.savedstate.SavedStateRegistryController | ||||||
| import androidx.savedstate.SavedStateRegistryOwner | ||||||
| import androidx.savedstate.setViewTreeSavedStateRegistryOwner | ||||||
| import androidx.core.net.toUri | ||||||
| import io.element.android.libraries.mediaviewer.impl.floatingvideo.ui.FloatingVideoOverlay | ||||||
| import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getScreenHeight | ||||||
| import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getScreenWidth | ||||||
| import dev.zacsweers.metro.Inject | ||||||
| import io.element.android.libraries.architecture.bindings | ||||||
|
|
||||||
| class FloatingVideoService : Service(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this service need to implement |
||||||
| private var windowManager: WindowManager? = null | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The code will be simplified by this change, and I believe that the system always provides the |
||||||
| 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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's possible to remove lateinit, change to val and initialize the value here. |
||||||
|
|
||||||
| companion object { | ||||||
| const val ACTION_START_FLOATING = "START_FLOATING" | ||||||
| const val ACTION_STOP_FLOATING = "STOP_FLOATING" | ||||||
| const val ACTION_UPDATE_POSITION = "UPDATE_POSITION" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It appears to be dead code, can you remove please? |
||||||
| const val EXTRA_VIDEO_ID = "video_id" | ||||||
| const val EXTRA_POSITION = "position" | ||||||
|
|
||||||
| @SuppressLint("ObsoleteSdkInt") | ||||||
| fun startFloating( | ||||||
| context: Context, videoData: MediaViewerPageData.MediaViewerData, position: Long = 0L | ||||||
| ) { | ||||||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) { | ||||||
|
|
||||||
| //the message needs to be added into commonStrings as notice for permission needed | ||||||
| Toast.makeText(context, "To show the floating video, please allow 'Display over other apps' permission.", Toast.LENGTH_LONG).show() | ||||||
|
|
||||||
| // Request overlay permission | ||||||
| val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply { | ||||||
| data = "package:${context.packageName}".toUri() | ||||||
| addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| context.startActivity(intent) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code should be moved to |
||||||
| return | ||||||
| } | ||||||
|
|
||||||
| // Generate unique ID for this video session | ||||||
| val videoId = "floating_video_${System.currentTimeMillis()}" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better to use |
||||||
|
|
||||||
| // Store the video data in repository via DI | ||||||
| context.bindings<FloatingVideoServiceBindings>().videoDataRepository().storeVideoData(videoId, videoData) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of doing this, |
||||||
|
|
||||||
| val intent = Intent(context, FloatingVideoService::class.java).apply { | ||||||
| action = ACTION_START_FLOATING | ||||||
| putExtra(EXTRA_VIDEO_ID, videoId) // Pass only the ID, not the whole object | ||||||
| putExtra(EXTRA_POSITION, position) | ||||||
| } | ||||||
| context.startService(intent) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| @Inject lateinit var videoDataRepository: VideoDataRepository | ||||||
|
|
||||||
| override fun onBind(intent: Intent?): IBinder? = null | ||||||
|
|
||||||
| override val viewModelStore = ViewModelStore() | ||||||
| private val lifecycleRegistry = LifecycleRegistry(this) | ||||||
| private val savedStateRegistryController = SavedStateRegistryController.create(this) | ||||||
| override val lifecycle: Lifecycle | ||||||
| get() = lifecycleRegistry | ||||||
|
|
||||||
| override val savedStateRegistry: SavedStateRegistry | ||||||
| get() = savedStateRegistryController.savedStateRegistry | ||||||
|
|
||||||
| override fun onCreate() { | ||||||
| super.onCreate() | ||||||
| bindings<FloatingVideoServiceBindings>().inject(this) | ||||||
| windowManager = getSystemService(WINDOW_SERVICE) as WindowManager | ||||||
| // 1. Attach controller | ||||||
| savedStateRegistryController.performAttach() | ||||||
|
|
||||||
| // 2. Restore state (if any) | ||||||
| savedStateRegistryController.performRestore(null) | ||||||
|
|
||||||
| // 3. Now move lifecycle forward | ||||||
| lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) | ||||||
| } | ||||||
|
|
||||||
| private var currentVideoId: String? = null | ||||||
| private var eventId: String? = null | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not used, remove? |
||||||
|
|
||||||
| override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||||
| when (intent?.action) { | ||||||
| ACTION_START_FLOATING -> { | ||||||
| val videoId = intent.getStringExtra(EXTRA_VIDEO_ID) | ||||||
| val position = intent.getLongExtra(EXTRA_POSITION, 0L) | ||||||
|
|
||||||
| if (videoId != null) { | ||||||
| // Get video data from repository using the ID | ||||||
| val videoData = videoDataRepository.getVideoData(videoId) | ||||||
| if (videoData != null) { | ||||||
| eventId = videoData.eventId?.value ?: "" | ||||||
| currentVideoData = videoData | ||||||
| currentVideoId = videoId | ||||||
| currentPosition = position | ||||||
| createFloatingView() | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| ACTION_STOP_FLOATING -> { | ||||||
| // Clean up stored data | ||||||
| currentVideoId?.let { videoId -> | ||||||
| videoDataRepository.removeVideoData(videoId) | ||||||
| } | ||||||
| removeFloatingView() | ||||||
| stopSelf() | ||||||
| } | ||||||
|
|
||||||
| ACTION_UPDATE_POSITION -> { | ||||||
| val position = intent.getLongExtra(EXTRA_POSITION, 0L) | ||||||
| currentPosition = position | ||||||
| videoView?.seekTo(position.toInt()) | ||||||
| } | ||||||
| } | ||||||
| return START_STICKY | ||||||
| } | ||||||
|
|
||||||
| private fun createFloatingView() { | ||||||
| removeFloatingView() | ||||||
| windowManager = getSystemService(WINDOW_SERVICE) as WindowManager | ||||||
|
|
||||||
| windowLayoutParams = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||||||
| WindowManager.LayoutParams( | ||||||
| WindowManager.LayoutParams.WRAP_CONTENT, | ||||||
| WindowManager.LayoutParams.WRAP_CONTENT, | ||||||
| WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, | ||||||
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, | ||||||
| PixelFormat.TRANSLUCENT | ||||||
| ) | ||||||
| } else { | ||||||
| @Suppress("DEPRECATION") | ||||||
| WindowManager.LayoutParams( | ||||||
| WindowManager.LayoutParams.WRAP_CONTENT, | ||||||
| WindowManager.LayoutParams.WRAP_CONTENT, | ||||||
| WindowManager.LayoutParams.TYPE_PHONE, | ||||||
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, | ||||||
| PixelFormat.TRANSLUCENT | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| windowLayoutParams.gravity = Gravity.TOP or Gravity.START | ||||||
| windowLayoutParams.x = 0 | ||||||
| windowLayoutParams.y = windowManager.getScreenHeight() - dpToPx(300) | ||||||
|
|
||||||
| val composeView = ComposeView(this).apply { | ||||||
| setViewTreeLifecycleOwner(this@FloatingVideoService) | ||||||
| setViewTreeViewModelStoreOwner(this@FloatingVideoService) | ||||||
| setViewTreeSavedStateRegistryOwner(this@FloatingVideoService) | ||||||
| setContent { | ||||||
| FloatingVideoOverlay( | ||||||
| onClose = { | ||||||
| removeFloatingView() | ||||||
| stopSelf() | ||||||
| }, | ||||||
| onToggleFullScreen = { | ||||||
| Timber.tag("onToggleFullScreen").d(isMaximized.toString()) | ||||||
| [email protected](it) | ||||||
| }, | ||||||
| onCompleted = { | ||||||
| removeFloatingView() | ||||||
| stopSelf() | ||||||
| }, | ||||||
| floatingView = floatingView, | ||||||
| isMaximized = isMaximized, | ||||||
| currentVideoData = currentVideoData, | ||||||
| windowManager = windowManager, | ||||||
| windowLayoutParams = windowLayoutParams | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| floatingView = composeView | ||||||
|
|
||||||
|
|
||||||
| try { | ||||||
| windowManager?.addView(floatingView, windowLayoutParams) | ||||||
| } catch (e: Exception) { | ||||||
| Timber.tag("FloatingVideoService").e(e, "Error adding floating view") | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private fun removeFloatingView() { | ||||||
| floatingView?.let { view -> | ||||||
| try { | ||||||
| windowManager?.removeView(view) | ||||||
| } catch (e: Exception) { | ||||||
| Timber.tag("FloatingVideoService").e(e, "Error removing floating view") | ||||||
| } | ||||||
| floatingView = null | ||||||
| videoView = null | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| override fun onDestroy() { | ||||||
| super.onDestroy() | ||||||
| onVideoComplete() | ||||||
| } | ||||||
|
|
||||||
| private fun dpToPx(dp: Int): Int { | ||||||
| return (dp * resources.displayMetrics.density).toInt() | ||||||
| } | ||||||
|
|
||||||
| private fun onVideoComplete() { | ||||||
| removeFloatingView() | ||||||
| lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) | ||||||
| viewModelStore.clear() | ||||||
| } | ||||||
|
|
||||||
| private fun toggleFullScreen(aspectRatio: Float) { | ||||||
| val layoutParams = windowLayoutParams | ||||||
| val wm = windowManager ?: return | ||||||
| val view = floatingView ?: return | ||||||
|
|
||||||
| isMaximized = !isMaximized | ||||||
|
|
||||||
| if (view.parent == null) return | ||||||
|
|
||||||
| val widthFrac = if (aspectRatio > 1f) 0.6f else 0.3f | ||||||
| val width = if (isMaximized) { | ||||||
| (windowManager.getScreenWidth() * widthFrac).toInt() | ||||||
| } else { | ||||||
| (windowManager.getScreenWidth() * 0.9f).toInt() | ||||||
| } | ||||||
| val height = (width / aspectRatio).toInt() | ||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| if (isMaximized) { | ||||||
| // Go full screen | ||||||
| val margin = dpToPx(24) | ||||||
| val screenWidth = Resources.getSystem().displayMetrics.widthPixels | ||||||
| layoutParams.width = screenWidth - margin * 2 | ||||||
| layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT | ||||||
| layoutParams.x = margin | ||||||
| layoutParams.y = margin | ||||||
| } else { | ||||||
| // Minimized | ||||||
| val scaledWidth = wm.getScreenWidth() * 0.3f | ||||||
| layoutParams.width = scaledWidth.toInt() | ||||||
| layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT | ||||||
| layoutParams.x = 0 | ||||||
| layoutParams.y = 0 | ||||||
| } | ||||||
|
|
||||||
| windowLayoutParams.width = width | ||||||
| windowLayoutParams.height = height | ||||||
| windowLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or | ||||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS | ||||||
| windowManager?.updateViewLayout(floatingView, windowLayoutParams) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems to be done twice (here and below), can you check? |
||||||
|
|
||||||
| Handler(Looper.getMainLooper()).post { | ||||||
| wm.updateViewLayout(view, layoutParams) | ||||||
| } | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please replace by: coroutineDispatchers can be injected. Same thing for the Looper used in |
||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| /* | ||
| * Copyright 2025 New Vector Ltd. | ||
| * | ||
| * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial | ||
| * Please see LICENSE files in the repository root for full details. | ||
| */ | ||
|
|
||
| package io.element.android.libraries.mediaviewer.impl.floatingvideo | ||
|
|
||
| import dev.zacsweers.metro.AppScope | ||
| import dev.zacsweers.metro.ContributesTo | ||
|
|
||
| @ContributesTo(AppScope::class) | ||
| interface FloatingVideoServiceBindings { | ||
| fun inject(service: FloatingVideoService) | ||
| fun videoDataRepository(): VideoDataRepository | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fun can be removed if you handle my other remarks. |
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| /* | ||
| * Copyright 2025 New Vector Ltd. | ||
| * | ||
| * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial | ||
| * Please see LICENSE files in the repository root for full details. | ||
| */ | ||
|
|
||
| package io.element.android.libraries.mediaviewer.impl.floatingvideo | ||
|
|
||
| import dev.zacsweers.metro.AppScope | ||
| import dev.zacsweers.metro.Inject | ||
| import dev.zacsweers.metro.SingleIn | ||
| import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData | ||
|
|
||
| @SingleIn(AppScope::class) | ||
| @Inject | ||
| class VideoDataRepository { | ||
|
|
||
| private val videoDataMap = mutableMapOf<String, MediaViewerPageData.MediaViewerData>() | ||
|
|
||
| fun storeVideoData(videoId: String, data: MediaViewerPageData.MediaViewerData) { | ||
| videoDataMap[videoId] = data | ||
| } | ||
|
|
||
| fun getVideoData(videoId: String): MediaViewerPageData.MediaViewerData? { | ||
| return videoDataMap[videoId] | ||
| } | ||
|
|
||
| fun removeVideoData(videoId: String) { | ||
| videoDataMap.remove(videoId) | ||
| } | ||
|
|
||
| fun clear() { | ||
| videoDataMap.clear() | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We try to avoid long file. Please split the code into multiple file. The UI can be extracted, it will force the code to be a bit more decoupled.
Also it will allow to add preview of the UI, for instance:
The method
getVideoUriFromMediaSourcecould be in a separate file too.