neiam
/media-bubbles
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Drag the bubble to the bottom to dismiss it
86fa652 · gmorell · 2026-11-06 04:26
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