Drag the bubble to the bottom to dismiss it

86fa652 · gmorell · 2026-11-06 04:26

16 files +211 -12
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -30,6 +30,7 @@ export JAVA_HOME=~/.gradle/jdks/eclipse_adoptium-21-amd64-linux.2 # JDK 17–2
30 30 | Double-tap (collapsed, optional) | Play / pause |
31 31 | Drag | Move; snaps to the nearest left/right edge, position remembered |
32 32 | Long-press | Open the playing app |
33 +| Drag to the bottom target | Dismiss (stays off until re-enabled; can be turned off in settings) |
33 34
34 35 Collapsed, the bubble shows album art with a progress ring and a pause glyph when paused.
35 36 Expanded, it grows inward from the docked edge and shows title/artist plus prev / ±10 s / play-pause / next.
modified app/src/main/java/org/neiam/mediabubbles/data/BubblePrefs.kt
+5 −0
@@ -18,6 +18,8 @@ data class BubbleConfig(
18 18 val showProgress: Boolean = true,
19 19 /** Double-tap the collapsed bubble to play/pause (adds a short single-tap delay). */
20 20 val doubleTapPlayPause: Boolean = true,
21 + /** Drag the bubble onto a target at the bottom of the screen to dismiss it. */
22 + val dragToDismiss: Boolean = true,
21 23 /** Hide entirely when no media session exists (paused sessions still show). */
22 24 val hideWhenIdle: Boolean = false,
23 25 /** Collapse the expanded pill after this many idle seconds; 0 = never. */
@@ -84,6 +86,7 @@ class BubblePrefs(context: Context) {
84 86 showTitle = sp.getBoolean(K_TITLE, d.showTitle),
85 87 showProgress = sp.getBoolean(K_PROGRESS, d.showProgress),
86 88 doubleTapPlayPause = sp.getBoolean(K_DOUBLE_TAP, d.doubleTapPlayPause),
89 + dragToDismiss = sp.getBoolean(K_DRAG_DISMISS, d.dragToDismiss),
87 90 hideWhenIdle = sp.getBoolean(K_HIDE_IDLE, d.hideWhenIdle),
88 91 autoCollapseSec = sp.getInt(K_AUTO_COLLAPSE, d.autoCollapseSec),
89 92 )
@@ -98,6 +101,7 @@ class BubblePrefs(context: Context) {
98 101 .putBoolean(K_TITLE, c.showTitle)
99 102 .putBoolean(K_PROGRESS, c.showProgress)
100 103 .putBoolean(K_DOUBLE_TAP, c.doubleTapPlayPause)
104 + .putBoolean(K_DRAG_DISMISS, c.dragToDismiss)
101 105 .putBoolean(K_HIDE_IDLE, c.hideWhenIdle)
102 106 .putInt(K_AUTO_COLLAPSE, c.autoCollapseSec)
103 107 .apply()
@@ -112,6 +116,7 @@ class BubblePrefs(context: Context) {
112 116 const val K_PROGRESS = "show_progress"
113 117 const val K_DOUBLE_TAP = "double_tap_play_pause"
114 118 const val K_HIDE_IDLE = "hide_when_idle"
119 + const val K_DRAG_DISMISS = "drag_to_dismiss"
115 120 const val K_AUTO_COLLAPSE = "auto_collapse_sec"
116 121 const val K_WANTED = "bubble_wanted"
117 122 const val K_POS_RIGHT = "pos_right"
added app/src/main/java/org/neiam/mediabubbles/overlay/DismissTarget.kt
+79 −0
@@ -0,0 +1,79 @@
1 +package org.neiam.mediabubbles.overlay
2 +
3 +import androidx.compose.animation.AnimatedVisibility
4 +import androidx.compose.animation.core.animateFloatAsState
5 +import androidx.compose.animation.fadeIn
6 +import androidx.compose.animation.fadeOut
7 +import androidx.compose.animation.slideInVertically
8 +import androidx.compose.foundation.background
9 +import androidx.compose.foundation.border
10 +import androidx.compose.foundation.layout.Box
11 +import androidx.compose.foundation.layout.fillMaxSize
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.layout.size
14 +import androidx.compose.foundation.shape.CircleShape
15 +import androidx.compose.material.icons.Icons
16 +import androidx.compose.material.icons.rounded.Close
17 +import androidx.compose.material3.Icon
18 +import androidx.compose.material3.MaterialTheme
19 +import androidx.compose.runtime.Composable
20 +import androidx.compose.runtime.getValue
21 +import androidx.compose.ui.Alignment
22 +import androidx.compose.ui.Modifier
23 +import androidx.compose.ui.draw.clip
24 +import androidx.compose.ui.graphics.Brush
25 +import androidx.compose.ui.graphics.Color
26 +import androidx.compose.ui.graphics.graphicsLayer
27 +import androidx.compose.ui.unit.dp
28 +import org.neiam.mediabubbles.ui.theme.LocalAppTheme
29 +
30 +/** Geometry shared by the composable and the service's hit test. */
31 +internal object DismissGeometry {
32 + /** Height of the bottom strip window that hosts the target. */
33 + const val AREA_DP = 200
34 + const val TARGET_DP = 64
35 + const val BOTTOM_DP = 48
36 + /** Finger within this distance of the target centre counts as "over". */
37 + const val CAPTURE_DP = 80
38 +}
39 +
40 +/**
41 + * The "drop here to dismiss" target shown along the bottom edge while the
42 + * bubble is being dragged. Grows and turns to the error colour while the
43 + * bubble is captured.
44 + */
45 +@Composable
46 +fun DismissTarget(shown: Boolean, over: Boolean) {
47 + val theme = LocalAppTheme.current
48 + val scale by animateFloatAsState(if (over) 1.3f else 1f, label = "dismiss-scale")
49 + AnimatedVisibility(
50 + visible = shown,
51 + enter = fadeIn() + slideInVertically { it / 3 },
52 + exit = fadeOut(),
53 + ) {
54 + Box(
55 + Modifier
56 + .fillMaxSize()
57 + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)))),
58 + contentAlignment = Alignment.BottomCenter,
59 + ) {
60 + Box(
61 + Modifier
62 + .padding(bottom = DismissGeometry.BOTTOM_DP.dp)
63 + .size(DismissGeometry.TARGET_DP.dp)
64 + .graphicsLayer { scaleX = scale; scaleY = scale }
65 + .clip(CircleShape)
66 + .background(if (over) MaterialTheme.colorScheme.error else theme.cardBg)
67 + .border(2.dp, if (over) MaterialTheme.colorScheme.error else theme.dim, CircleShape),
68 + contentAlignment = Alignment.Center,
69 + ) {
70 + Icon(
71 + Icons.Rounded.Close,
72 + contentDescription = "Drop to dismiss",
73 + tint = if (over) MaterialTheme.colorScheme.onError else theme.content,
74 + modifier = Modifier.size(32.dp),
75 + )
76 + }
77 + }
78 + }
79 +}
modified app/src/main/java/org/neiam/mediabubbles/overlay/DragFrameLayout.kt
+3 −2
@@ -17,7 +17,8 @@ import kotlin.math.hypot
17 17 class DragFrameLayout(
18 18 context: Context,
19 19 private val onDragStart: () -> Unit,
20 − private val onDragBy: (dx: Float, dy: Float) -> Unit,
20 + /** Deltas plus the finger's absolute screen position, for hit-testing drop targets. */
21 + private val onDragBy: (dx: Float, dy: Float, rawX: Float, rawY: Float) -> Unit,
21 22 private val onDragEnd: () -> Unit,
22 23 ) : FrameLayout(context) {
23 24
@@ -50,7 +51,7 @@ class DragFrameLayout(
50 51 lastX = ev.rawX; lastY = ev.rawY
51 52 onDragStart()
52 53 } else if (dragging) {
53 − onDragBy(ev.rawX - lastX, ev.rawY - lastY)
54 + onDragBy(ev.rawX - lastX, ev.rawY - lastY, ev.rawX, ev.rawY)
54 55 lastX = ev.rawX; lastY = ev.rawY
55 56 }
56 57 }
modified app/src/main/java/org/neiam/mediabubbles/overlay/OverlayService.kt
+117 −9
@@ -10,9 +10,12 @@ import android.os.Build
10 10 import android.provider.Settings
11 11 import android.util.DisplayMetrics
12 12 import android.view.Gravity
13 +import android.view.HapticFeedbackConstants
13 14 import android.view.View
14 15 import android.view.WindowManager
15 16 import android.view.animation.DecelerateInterpolator
17 +import android.widget.FrameLayout
18 +import android.widget.Toast
16 19 import androidx.compose.runtime.collectAsState
17 20 import androidx.compose.runtime.getValue
18 21 import androidx.compose.runtime.mutableStateOf
@@ -33,6 +36,7 @@ import kotlinx.coroutines.flow.StateFlow
33 36 import kotlinx.coroutines.flow.combine
34 37 import kotlinx.coroutines.flow.distinctUntilChanged
35 38 import kotlinx.coroutines.launch
39 +import kotlin.math.hypot
36 40 import org.neiam.mediabubbles.container
37 41 import org.neiam.mediabubbles.data.BubblePosition
38 42 import org.neiam.mediabubbles.ui.theme.MediaBubblesTheme
@@ -63,7 +67,17 @@ class OverlayService : LifecycleService(), SavedStateRegistryOwner {
63 67 private var dragY = 0f
64 68 private var snapAnim: ValueAnimator? = null
65 69
66 − private val margin get() = (6 * resources.displayMetrics.density).toInt()
70 + // Drag-to-dismiss: a NOT_TOUCHABLE strip along the bottom, added *before*
71 + // the bubble window so the bubble draws over its target.
72 + private var dismissHost: FrameLayout? = null
73 + private var dismissShown by mutableStateOf(false)
74 + private var overTarget by mutableStateOf(false)
75 + /** Bubble window's screen position minus its LayoutParams position, captured per drag. */
76 + private var screenOffsetX = 0
77 + private var screenOffsetY = 0
78 +
79 + private val margin get() = dp(6)
80 + private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
67 81
68 82 override fun onCreate() {
69 83 ssrc.performAttach()
@@ -110,6 +124,8 @@ class OverlayService : LifecycleService(), SavedStateRegistryOwner {
110 124 windowAnimations = android.R.style.Animation_Toast
111 125 }
112 126
127 + attachDismissTarget()
128 +
113 129 val compose = ComposeView(this).apply {
114 130 setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
115 131 setContent {
@@ -133,17 +149,20 @@ class OverlayService : LifecycleService(), SavedStateRegistryOwner {
133 149 dragging = true
134 150 dragX = params.x.toFloat()
135 151 dragY = params.y.toFloat()
152 + root?.let { v ->
153 + val loc = IntArray(2).also(v::getLocationOnScreen)
154 + screenOffsetX = loc[0] - params.x
155 + screenOffsetY = loc[1] - params.y
156 + }
157 + if (container.prefs.config.value.dragToDismiss) showDismiss()
136 158 },
137 − onDragBy = { dx, dy ->
138 − dragX += dx
139 − dragY += dy
140 − params.x = dragX.toInt()
141 − params.y = clampY(dragY.toInt())
142 − update()
143 − },
159 + onDragBy = { dx, dy, rawX, rawY -> onDrag(dx, dy, rawX, rawY) },
144 160 onDragEnd = {
145 161 dragging = false
146 − snapToEdge()
162 + val dismiss = overTarget
163 + overTarget = false
164 + hideDismiss()
165 + if (dismiss) dismissBubble() else snapToEdge()
147 166 },
148 167 ).apply {
149 168 addView(compose)
@@ -169,6 +188,93 @@ class OverlayService : LifecycleService(), SavedStateRegistryOwner {
169 188 }
170 189 }
171 190
191 + private fun onDrag(dx: Float, dy: Float, rawX: Float, rawY: Float) {
192 + dragX += dx
193 + dragY += dy
194 + val v = root ?: return
195 + val target = if (dismissShown) dismissCenter() else null
196 + val over = target != null &&
197 + hypot(rawX - target.first, rawY - target.second) < dp(DismissGeometry.CAPTURE_DP)
198 + if (over != overTarget) {
199 + overTarget = over
200 + if (over) v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
201 + }
202 + if (over) {
203 + // Magnetise: centre the bubble on the target while captured.
204 + params.x = target!!.first.toInt() - screenOffsetX - v.width / 2
205 + params.y = target.second.toInt() - screenOffsetY - v.height / 2
206 + } else {
207 + params.x = dragX.toInt()
208 + params.y = clampY(dragY.toInt())
209 + }
210 + update()
211 + }
212 +
213 + private fun attachDismissTarget() {
214 + val compose = ComposeView(this).apply {
215 + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
216 + setContent {
217 + val themeKey by container.prefs.themeKey.collectAsState()
218 + MediaBubblesTheme(appThemeForKey(themeKey)) {
219 + DismissTarget(shown = dismissShown, over = overTarget)
220 + }
221 + }
222 + }
223 + val host = FrameLayout(this).apply {
224 + addView(compose)
225 + setViewTreeLifecycleOwner(this@OverlayService)
226 + setViewTreeSavedStateRegistryOwner(this@OverlayService)
227 + // GONE when idle, so the window isn't "visible" and can't obscure
228 + // touches to the app below (Android 12+ untrusted-touch rules).
229 + visibility = View.GONE
230 + }
231 + val lp = WindowManager.LayoutParams(
232 + WindowManager.LayoutParams.MATCH_PARENT,
233 + dp(DismissGeometry.AREA_DP),
234 + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
235 + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
236 + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
237 + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
238 + PixelFormat.TRANSLUCENT,
239 + ).apply {
240 + gravity = Gravity.BOTTOM
241 + // ≤ 0.8 keeps it below the obscuring-opacity limit even while shown.
242 + alpha = 0.8f
243 + }
244 + runCatching { wm.addView(host, lp) }.onSuccess { dismissHost = host }
245 + }
246 +
247 + private fun showDismiss() {
248 + val host = dismissHost ?: return
249 + host.visibility = View.VISIBLE
250 + dismissShown = true
251 + }
252 +
253 + private fun hideDismiss() {
254 + val host = dismissHost ?: return
255 + dismissShown = false
256 + host.postDelayed({ if (!dismissShown) host.visibility = View.GONE }, 250)
257 + }
258 +
259 + /** Target centre in screen pixels, or null before the strip has been laid out. */
260 + private fun dismissCenter(): Pair<Float, Float>? {
261 + val host = dismissHost ?: return null
262 + if (host.width == 0 || host.height == 0) return null
263 + val loc = IntArray(2).also(host::getLocationOnScreen)
264 + val cx = loc[0] + host.width / 2f
265 + val cy = loc[1] + host.height - dp(DismissGeometry.BOTTOM_DP) - dp(DismissGeometry.TARGET_DP) / 2f
266 + return cx to cy
267 + }
268 +
269 + private fun dismissBubble() {
270 + root?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
271 + Toast.makeText(
272 + this, "Bubble hidden. Bring it back from the app or Quick Settings tile.",
273 + Toast.LENGTH_SHORT,
274 + ).show()
275 + stop(this)
276 + }
277 +
172 278 private fun dockX(width: Int): Int =
173 279 if (dockRight) screenWidth() - width - margin else margin
174 280
@@ -213,6 +319,8 @@ class OverlayService : LifecycleService(), SavedStateRegistryOwner {
213 319 snapAnim?.cancel()
214 320 root?.let { runCatching { wm.removeView(it) } }
215 321 root = null
322 + dismissHost?.let { runCatching { wm.removeView(it) } }
323 + dismissHost = null
216 324 _running.value = false
217 325 super.onDestroy()
218 326 }
modified app/src/main/java/org/neiam/mediabubbles/ui/SettingsScreen.kt
+6 −1
@@ -230,6 +230,11 @@ fun SettingsScreen(themeKey: String, onPickTheme: (String) -> Unit) {
230 230 "When collapsed. Adds a brief delay before a single tap expands.",
231 231 cfg.doubleTapPlayPause,
232 232 ) { v -> c.prefs.update { it.copy(doubleTapPlayPause = v) } }
233 + SwitchRow(
234 + "Drag to bottom to dismiss",
235 + "A target appears at the bottom while dragging; drop the bubble on it to hide",
236 + cfg.dragToDismiss,
237 + ) { v -> c.prefs.update { it.copy(dragToDismiss = v) } }
233 238 SwitchRow(
234 239 "Hide when nothing is playing",
235 240 "Bubble disappears when no media session exists (paused still shows)",
@@ -247,7 +252,7 @@ fun SettingsScreen(themeKey: String, onPickTheme: (String) -> Unit) {
247 252
248 253 Text(
249 254 "Gestures: tap to expand or collapse · drag to move (snaps to the nearest edge) · " +
250 − "long-press to open the playing app.",
255 + "drop on the bottom target to dismiss · long-press to open the playing app.",
251 256 style = MaterialTheme.typography.bodySmall,
252 257 )
253 258 Spacer(Modifier.height(24.dp))

Parents: 3e12e49