Skip to content

Commit a3228ac

Browse files
author
jesus
committed
feat(floating-video): add mini-player implementation
1 parent 29d4dfb commit a3228ac

File tree

12 files changed

+795
-10
lines changed

12 files changed

+795
-10
lines changed

.idea/codeStyles/Project.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src/main/AndroidManifest.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
<!-- To be able to install APK from the application -->
1212
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
13-
1413
<application
1514
android:name=".ElementXApplication"
1615
android:allowBackup="false"

libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,24 @@ fun Context.openGooglePlay(
196196
}
197197
}
198198

199+
/**
200+
* Opens the system settings for the current app to allow drawing over other apps.
201+
*/
202+
fun Context.openSystemOverlaySettings() {
203+
val intent = Intent(
204+
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
205+
"package:$packageName".toUri()
206+
).apply {
207+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
208+
}
209+
try {
210+
startActivity(intent)
211+
} catch (e: ActivityNotFoundException) {
212+
toast(getString(R.string.error_no_compatible_app_found))
213+
}
214+
}
215+
216+
199217
// Not in KTX anymore
200218
fun Context.toast(resId: Int) {
201219
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright 2025 New Vector Ltd.
4+
~
5+
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6+
~ Please see LICENSE files in the repository root for full details.
7+
-->
8+
9+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
10+
package="io.element.android.libraries.mediaviewer.impl">
11+
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
12+
13+
<application>
14+
<service
15+
android:name=".floatingvideo.FloatingVideoService"
16+
android:enabled="true"
17+
android:exported="false" />
18+
</application>
19+
</manifest>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.libraries.mediaviewer.impl.floatingvideo
9+
10+
import android.annotation.SuppressLint
11+
import android.app.Service
12+
import android.content.Context
13+
import android.content.Intent
14+
import android.graphics.PixelFormat
15+
import android.os.Build
16+
import android.os.IBinder
17+
import android.provider.Settings
18+
import android.view.Gravity
19+
import android.view.View
20+
import android.view.WindowManager
21+
import android.widget.Toast
22+
import android.widget.VideoView
23+
import androidx.compose.ui.platform.ComposeView
24+
import androidx.lifecycle.Lifecycle
25+
import androidx.lifecycle.LifecycleOwner
26+
import androidx.lifecycle.LifecycleRegistry
27+
import androidx.lifecycle.setViewTreeLifecycleOwner
28+
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData
29+
import timber.log.Timber
30+
import androidx.savedstate.SavedStateRegistry
31+
import androidx.savedstate.SavedStateRegistryController
32+
import androidx.savedstate.SavedStateRegistryOwner
33+
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
34+
import io.element.android.libraries.androidutils.system.openSystemOverlaySettings
35+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.ui.FloatingVideoOverlay
36+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getScreenHeight
37+
import dev.zacsweers.metro.Inject
38+
import io.element.android.libraries.architecture.bindings
39+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.dpToPx
40+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.getUri
41+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.maximizeWindowHelper
42+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.minimizeWindowHelper
43+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.movePosition
44+
import io.element.android.libraries.mediaviewer.impl.floatingvideo.util.updateWindowSize
45+
46+
class FloatingVideoService : Service(), LifecycleOwner, SavedStateRegistryOwner {
47+
private var windowManager: WindowManager? = null
48+
private var floatingView: View? = null
49+
private var videoView: VideoView? = null
50+
private var currentVideoData: MediaViewerPageData.MediaViewerData? = null
51+
private var currentPosition: Long = 0L
52+
private var isMinimized = true
53+
54+
private lateinit var windowLayoutParams: WindowManager.LayoutParams
55+
56+
companion object {
57+
const val ACTION_START_FLOATING = "START_FLOATING"
58+
const val EXTRA_VIDEO_ID = "video_id"
59+
const val EXTRA_POSITION = "position"
60+
61+
private const val INITIAL_FLOATING_WINDOW_OFFSET_Y = 300
62+
63+
@SuppressLint("ObsoleteSdkInt")
64+
fun startFloating(
65+
context: Context, videoId: String, position: Long = 0L
66+
) {
67+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {
68+
69+
//the message needs to be added into commonStrings as notice for permission needed
70+
Toast.makeText(context, "To show the floating video, please allow 'Display over other apps' permission.", Toast.LENGTH_LONG).show()
71+
72+
// Request overlay permission
73+
context.openSystemOverlaySettings()
74+
return
75+
}
76+
77+
val intent = Intent(context, FloatingVideoService::class.java).apply {
78+
action = ACTION_START_FLOATING
79+
putExtra(EXTRA_VIDEO_ID, videoId)
80+
putExtra(EXTRA_POSITION, position)
81+
}
82+
context.startService(intent)
83+
}
84+
}
85+
86+
@Inject lateinit var videoDataRepository: VideoDataRepository
87+
88+
override fun onBind(intent: Intent?): IBinder? = null
89+
90+
private val lifecycleRegistry = LifecycleRegistry(this)
91+
private val savedStateRegistryController = SavedStateRegistryController.create(this)
92+
override val lifecycle: Lifecycle
93+
get() = lifecycleRegistry
94+
95+
override val savedStateRegistry: SavedStateRegistry
96+
get() = savedStateRegistryController.savedStateRegistry
97+
98+
override fun onCreate() {
99+
super.onCreate()
100+
bindings<FloatingVideoServiceBindings>().inject(this)
101+
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
102+
// 1. Attach controller
103+
savedStateRegistryController.performAttach()
104+
105+
// 2. Restore state (if any)
106+
savedStateRegistryController.performRestore(null)
107+
108+
// 3. Now move lifecycle forward
109+
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
110+
}
111+
112+
private var currentVideoId: String? = null
113+
private var eventId: String? = null
114+
115+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
116+
when (intent?.action) {
117+
ACTION_START_FLOATING -> {
118+
val videoId = intent.getStringExtra(EXTRA_VIDEO_ID)
119+
val position = intent.getLongExtra(EXTRA_POSITION, 0L)
120+
121+
if (videoId != null) {
122+
// Get video data from repository using the ID
123+
val videoData = videoDataRepository.getVideoData(videoId)
124+
if (videoData != null) {
125+
eventId = videoData.eventId?.value ?: ""
126+
currentVideoData = videoData
127+
currentVideoId = videoId
128+
currentPosition = position
129+
createFloatingView()
130+
}
131+
}
132+
}
133+
}
134+
return START_STICKY
135+
}
136+
137+
private fun createFloatingView() {
138+
removeFloatingView()
139+
140+
windowLayoutParams = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
141+
WindowManager.LayoutParams(
142+
WindowManager.LayoutParams.WRAP_CONTENT,
143+
WindowManager.LayoutParams.WRAP_CONTENT,
144+
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
145+
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
146+
PixelFormat.TRANSLUCENT
147+
)
148+
} else {
149+
@Suppress("DEPRECATION")
150+
WindowManager.LayoutParams(
151+
WindowManager.LayoutParams.WRAP_CONTENT,
152+
WindowManager.LayoutParams.WRAP_CONTENT,
153+
WindowManager.LayoutParams.TYPE_PHONE,
154+
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
155+
PixelFormat.TRANSLUCENT
156+
)
157+
}
158+
159+
windowLayoutParams.gravity = Gravity.TOP or Gravity.START
160+
windowLayoutParams.x = 0
161+
windowLayoutParams.y = windowManager.getScreenHeight() - dpToPx(INITIAL_FLOATING_WINDOW_OFFSET_Y)
162+
163+
val composeView = ComposeView(this).apply {
164+
setViewTreeLifecycleOwner(this@FloatingVideoService)
165+
setViewTreeSavedStateRegistryOwner(this@FloatingVideoService)
166+
setContent {
167+
FloatingVideoOverlay(
168+
onClose = {
169+
removeFloatingView()
170+
stopSelf()
171+
},
172+
onToggleFullScreen = {aspectRatio ->
173+
if (isMinimized) {
174+
maximizeWindow(aspectRatio)
175+
} else {
176+
minimizeWindow(aspectRatio)
177+
}
178+
},
179+
onCompleted = {
180+
removeFloatingView()
181+
stopSelf()
182+
},
183+
updateAspectRatio = {
184+
updateWindowSize(
185+
aspectRatio = it,
186+
isMinimized = isMinimized,
187+
floatingView = floatingView,
188+
windowManager = windowManager,
189+
windowLayoutParams = windowLayoutParams
190+
)
191+
},
192+
uri = currentVideoData.getUri(),
193+
movePosition = { x, y ->
194+
movePosition(x = x, y = y, windowLayoutParams = windowLayoutParams, floatingView = floatingView, windowManager = windowManager)
195+
}
196+
)
197+
}
198+
}
199+
200+
201+
floatingView = composeView
202+
203+
204+
try {
205+
windowManager?.addView(floatingView, windowLayoutParams)
206+
} catch (e: Exception) {
207+
Timber.tag("FloatingVideoService").e(e, "Error adding floating view")
208+
}
209+
}
210+
211+
private fun removeFloatingView() {
212+
floatingView?.let { view ->
213+
try {
214+
windowManager?.removeView(view)
215+
} catch (e: Exception) {
216+
Timber.tag("FloatingVideoService").e(e, "Error removing floating view")
217+
}
218+
floatingView = null
219+
videoView = null
220+
}
221+
}
222+
223+
override fun onDestroy() {
224+
super.onDestroy()
225+
onVideoComplete()
226+
}
227+
228+
private fun onVideoComplete() {
229+
removeFloatingView()
230+
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
231+
}
232+
233+
private fun minimizeWindow(aspectRatio: Float) {
234+
isMinimized = true
235+
minimizeWindowHelper(
236+
aspectRatio = aspectRatio,
237+
windowManager = windowManager,
238+
windowLayoutParams = windowLayoutParams,
239+
floatingView = floatingView
240+
)
241+
}
242+
private fun maximizeWindow(aspectRatio: Float) {
243+
isMinimized = false
244+
maximizeWindowHelper(
245+
aspectRatio = aspectRatio,
246+
windowManager = windowManager,
247+
windowLayoutParams = windowLayoutParams,
248+
floatingView = floatingView
249+
)
250+
}
251+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.libraries.mediaviewer.impl.floatingvideo
9+
10+
import dev.zacsweers.metro.AppScope
11+
import dev.zacsweers.metro.ContributesTo
12+
13+
@ContributesTo(AppScope::class)
14+
interface FloatingVideoServiceBindings {
15+
fun inject(service: FloatingVideoService)
16+
fun videoDataRepository(): VideoDataRepository
17+
}
18+
19+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.libraries.mediaviewer.impl.floatingvideo
9+
10+
import dev.zacsweers.metro.AppScope
11+
import dev.zacsweers.metro.Inject
12+
import dev.zacsweers.metro.SingleIn
13+
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPageData
14+
import java.util.UUID
15+
16+
@SingleIn(AppScope::class)
17+
@Inject
18+
class VideoDataRepository {
19+
20+
private val videoDataMap = mutableMapOf<String, MediaViewerPageData.MediaViewerData>()
21+
22+
fun storeVideoData(data: MediaViewerPageData.MediaViewerData) : String{
23+
val id = UUID.randomUUID().toString()
24+
videoDataMap[id] = data
25+
return id
26+
}
27+
28+
fun getVideoData(videoId: String): MediaViewerPageData.MediaViewerData? {
29+
return videoDataMap[videoId]
30+
}
31+
32+
fun removeVideoData(videoId: String) {
33+
videoDataMap.remove(videoId)
34+
}
35+
36+
fun clear() {
37+
videoDataMap.clear()
38+
}
39+
}
40+

0 commit comments

Comments
 (0)