Skip to content

Commit 39469c5

Browse files
authored
Merge pull request #7 from Leuconoe/developer/thumbnail
feat: normalize sidebar spacing and add thumbnail support
2 parents 7d79a5a + 04d8c8f commit 39469c5

26 files changed

Lines changed: 1091 additions & 69 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable user-facing changes to MediaFloat are recorded here.
44

5+
## v0.2.4
6+
7+
Layout and thumbnail follow-up release.
8+
9+
- Normalized left drag-handle spacing to match the right side
10+
- Added media thumbnail support with a fallback placeholder when artwork is unavailable
11+
- Kept the title strip always visible to prevent layout flicker during track changes
12+
- Moved sidebar placement and thumbnail toggle controls into Settings for easier access
13+
14+
See also: `docs/releases/v0.2.4.md`
15+
516
## v0.2.3
617

718
Stability and shortcut follow-up release.

app/src/main/java/com/mediacontrol/floatingwidget/MainActivity.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ class MainActivity : AppCompatActivity() {
104104
onSetThemePreset = ::setThemePreset,
105105
onSetOpacity = ::setOpacity,
106106
onSetDragHandlePlacement = ::setDragHandlePlacement,
107+
onSetHorizontalOffsetPreset = ::setHorizontalOffsetPreset,
107108
onSetPersistentOverlayEnabled = ::setPersistentOverlayEnabled,
109+
onSetLowQualityThumbnailFallbackEnabled = ::setThumbnailEnabled,
108110
onStartOverlay = ::startOverlay,
109111
onStopOverlay = ::stopOverlay,
110112
onDispatchPrevious = ::dispatchPrevious,
@@ -189,6 +191,14 @@ class MainActivity : AppCompatActivity() {
189191
widgetConfigStateHolder.setPersistentOverlayEnabled(enabled)
190192
}
191193

194+
private fun setThumbnailEnabled(enabled: Boolean) {
195+
widgetConfigStateHolder.setLowQualityThumbnailFallbackEnabled(enabled)
196+
}
197+
198+
private fun setHorizontalOffsetPreset(xOffsetDp: Int) {
199+
widgetConfigStateHolder.setHorizontalOffsetDp(xOffsetDp)
200+
}
201+
192202
private fun startOverlay() {
193203
val started = debugActions.startOverlay()
194204
runtimeSummaryStateHolder.refresh()

app/src/main/java/com/mediacontrol/floatingwidget/media/AndroidMediaSessionRepository.kt

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,31 @@ package sw2.io.mediafloat.media
22

33
import android.content.ComponentName
44
import android.content.Context
5+
import android.graphics.Bitmap
6+
import android.graphics.BitmapFactory
57
import android.media.session.MediaController
68
import android.media.MediaMetadata
79
import android.media.session.MediaSessionManager
810
import android.media.session.PlaybackState
11+
import android.net.Uri
912
import android.os.Handler
1013
import android.os.Looper
1114
import android.util.Log
1215
import sw2.io.mediafloat.debug.DebugLogWriter
1316
import sw2.io.mediafloat.debug.NoOpDebugLogWriter
17+
import sw2.io.mediafloat.model.MediaArtwork
18+
import sw2.io.mediafloat.model.MediaArtworkSource
1419
import sw2.io.mediafloat.model.MediaCommand
1520
import sw2.io.mediafloat.model.MediaSessionErrorReason
1621
import sw2.io.mediafloat.model.MediaSessionLimitReason
1722
import sw2.io.mediafloat.model.MediaSessionState
1823
import sw2.io.mediafloat.model.PlaybackStatus
1924

25+
internal data class NotificationArtworkSnapshot(
26+
val packageName: String,
27+
val artwork: MediaArtwork.BitmapSource
28+
)
29+
2030
class AndroidMediaSessionRepository(
2131
context: Context,
2232
private val debugLogWriter: DebugLogWriter = NoOpDebugLogWriter
@@ -55,6 +65,9 @@ class AndroidMediaSessionRepository(
5565
@Volatile
5666
private var currentController: MediaController? = null
5767

68+
@Volatile
69+
private var notificationArtworkByPackage: Map<String, MediaArtwork.BitmapSource> = emptyMap()
70+
5871
private var connected = false
5972
private var connectionCount = 0
6073
private var recoveryRunnable: Runnable? = null
@@ -186,9 +199,16 @@ class AndroidMediaSessionRepository(
186199
fun onNotificationListenerDisconnected() {
187200
debugLogWriter.warn(TAG, "Notification listener disconnected; clearing pending media recovery")
188201
clearRecovery()
202+
notificationArtworkByPackage = emptyMap()
189203
publishState(MediaSessionState.Discovering)
190204
}
191205

206+
@Synchronized
207+
internal fun replaceNotificationArtworkSnapshot(snapshots: List<NotificationArtworkSnapshot>) {
208+
notificationArtworkByPackage = snapshots.associate { it.packageName to it.artwork }
209+
currentController?.let { publishState(resolveState(it)) }
210+
}
211+
192212
private fun handleControllersChanged(controllers: List<MediaController>) {
193213
val nextController = selectController(controllers)
194214
if (nextController == null) {
@@ -247,23 +267,101 @@ class AndroidMediaSessionRepository(
247267
displayTitle = controller.metadata?.getText(MediaMetadata.METADATA_KEY_DISPLAY_TITLE),
248268
title = controller.metadata?.getText(MediaMetadata.METADATA_KEY_TITLE)
249269
)
270+
val artworkCandidates = resolveArtworkCandidates(controller)
250271
val sessionId = "${controller.packageName}:${controller.sessionToken}"
251272
val playbackState = controller.playbackState
252273
?: return buildMediaSessionState(
253274
sessionId = sessionId,
254275
title = title,
276+
artworkCandidates = artworkCandidates,
255277
playbackStatus = null,
256278
supportedActions = null
257279
)
258280

259281
return buildMediaSessionState(
260282
sessionId = sessionId,
261283
title = title,
284+
artworkCandidates = artworkCandidates,
262285
playbackStatus = playbackState.state.toPlaybackStatus(),
263286
supportedActions = playbackState.actions.toSupportedCommands()
264287
)
265288
}
266289

290+
private fun resolveArtworkCandidates(controller: MediaController): List<MediaArtwork> {
291+
val metadata = controller.metadata
292+
293+
return buildArtworkCandidates(
294+
metadataDisplayIconUri = resolveArtworkUriCandidate(
295+
rawUri = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI),
296+
source = MediaArtworkSource.MetadataDisplayIconUri
297+
),
298+
metadataArtUri = resolveArtworkUriCandidate(
299+
rawUri = metadata?.getString(MediaMetadata.METADATA_KEY_ART_URI),
300+
source = MediaArtworkSource.MetadataArtUri
301+
),
302+
metadataAlbumArtUri = resolveArtworkUriCandidate(
303+
rawUri = metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI),
304+
source = MediaArtworkSource.MetadataAlbumArtUri
305+
),
306+
metadataDisplayIconBitmap = resolveArtworkBitmapCandidate(
307+
bitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON),
308+
source = MediaArtworkSource.MetadataDisplayIconBitmap
309+
),
310+
metadataArtBitmap = resolveArtworkBitmapCandidate(
311+
bitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART),
312+
source = MediaArtworkSource.MetadataArtBitmap
313+
),
314+
metadataAlbumArtBitmap = resolveArtworkBitmapCandidate(
315+
bitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART),
316+
source = MediaArtworkSource.MetadataAlbumArtBitmap
317+
),
318+
notificationLargeIcon = notificationArtworkByPackage[controller.packageName]
319+
)
320+
}
321+
322+
private fun resolveArtworkUriCandidate(
323+
rawUri: String?,
324+
source: MediaArtworkSource
325+
): MediaArtwork.UriSource? {
326+
val normalizedUri = rawUri
327+
?.trim()
328+
?.takeIf { it.isNotEmpty() }
329+
?: return null
330+
331+
val artworkBounds = runCatching {
332+
appContext.contentResolver.openInputStream(Uri.parse(normalizedUri))?.use { stream ->
333+
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
334+
BitmapFactory.decodeStream(stream, null, options)
335+
if (options.outWidth > 0 && options.outHeight > 0) {
336+
options.outWidth to options.outHeight
337+
} else {
338+
null
339+
}
340+
}
341+
}.getOrNull() ?: return null
342+
343+
return MediaArtwork.UriSource(
344+
source = source,
345+
uri = normalizedUri,
346+
widthPx = artworkBounds.first,
347+
heightPx = artworkBounds.second
348+
)
349+
}
350+
351+
private fun resolveArtworkBitmapCandidate(
352+
bitmap: Bitmap?,
353+
source: MediaArtworkSource
354+
): MediaArtwork.BitmapSource? {
355+
val resolvedBitmap = bitmap?.takeIf { it.width > 0 && it.height > 0 } ?: return null
356+
357+
return MediaArtwork.BitmapSource(
358+
source = source,
359+
bitmap = resolvedBitmap,
360+
widthPx = resolvedBitmap.width,
361+
heightPx = resolvedBitmap.height
362+
)
363+
}
364+
267365
private fun publishState(state: MediaSessionState) {
268366
if (currentState != state) {
269367
debugLogWriter.debug(TAG, "Media session state updated", "state=$state")
@@ -327,13 +425,15 @@ class AndroidMediaSessionRepository(
327425
fun buildMediaSessionState(
328426
sessionId: String,
329427
title: String?,
428+
artworkCandidates: List<MediaArtwork> = emptyList(),
330429
playbackStatus: PlaybackStatus?,
331430
supportedActions: Set<MediaCommand>?
332431
): MediaSessionState {
333432
val resolvedPlaybackStatus = playbackStatus
334433
?: return MediaSessionState.Limited(
335434
reason = MediaSessionLimitReason.PlaybackStateUnknown,
336435
title = title,
436+
artworkCandidates = artworkCandidates,
337437
supportedActions = emptySet()
338438
)
339439

@@ -343,18 +443,40 @@ class AndroidMediaSessionRepository(
343443
MediaSessionState.Limited(
344444
reason = MediaSessionLimitReason.MissingTransportControls,
345445
title = title,
446+
artworkCandidates = artworkCandidates,
346447
supportedActions = emptySet()
347448
)
348449
} else {
349450
MediaSessionState.Active(
350451
sessionId = sessionId,
351452
title = title,
453+
artworkCandidates = artworkCandidates,
352454
supportedActions = resolvedSupportedActions,
353455
playbackStatus = resolvedPlaybackStatus
354456
)
355457
}
356458
}
357459

460+
fun buildArtworkCandidates(
461+
metadataDisplayIconUri: MediaArtwork.UriSource?,
462+
metadataArtUri: MediaArtwork.UriSource?,
463+
metadataAlbumArtUri: MediaArtwork.UriSource?,
464+
metadataDisplayIconBitmap: MediaArtwork.BitmapSource?,
465+
metadataArtBitmap: MediaArtwork.BitmapSource?,
466+
metadataAlbumArtBitmap: MediaArtwork.BitmapSource?,
467+
notificationLargeIcon: MediaArtwork.BitmapSource?
468+
): List<MediaArtwork> {
469+
return listOfNotNull(
470+
metadataDisplayIconUri,
471+
metadataArtUri,
472+
metadataAlbumArtUri,
473+
metadataDisplayIconBitmap,
474+
metadataArtBitmap,
475+
metadataAlbumArtBitmap,
476+
notificationLargeIcon
477+
)
478+
}
479+
358480
fun resolveMediaTitle(displayTitle: CharSequence?, title: CharSequence?): String? {
359481
return displayTitle.normalizedMediaTitle() ?: title.normalizedMediaTitle()
360482
}

app/src/main/java/com/mediacontrol/floatingwidget/media/MediaNotificationListenerService.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package sw2.io.mediafloat.media
22

3+
import android.graphics.Bitmap
4+
import android.graphics.Canvas
35
import android.service.notification.StatusBarNotification
46
import android.service.notification.NotificationListenerService
57
import android.util.Log
68
import sw2.io.mediafloat.MediaControlAppServices
9+
import sw2.io.mediafloat.model.MediaArtwork
10+
import sw2.io.mediafloat.model.MediaArtworkSource
711

812
class MediaNotificationListenerService : NotificationListenerService() {
913

1014
override fun onListenerConnected() {
1115
super.onListenerConnected()
1216
Log.d(TAG, "Notification listener connected")
17+
publishNotificationArtworkSnapshot()
1318
MediaControlAppServices.from(this).mediaRepository.onNotificationListenerConnected()
1419
}
1520

@@ -21,18 +26,50 @@ class MediaNotificationListenerService : NotificationListenerService() {
2126

2227
override fun onNotificationPosted(sbn: StatusBarNotification?) {
2328
super.onNotificationPosted(sbn)
29+
publishNotificationArtworkSnapshot()
2430
MediaControlAppServices.from(this).mediaRepository.requestRecovery(
2531
reason = "notification_posted_${sbn?.packageName ?: "unknown"}"
2632
)
2733
}
2834

2935
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
3036
super.onNotificationRemoved(sbn)
37+
publishNotificationArtworkSnapshot()
3138
MediaControlAppServices.from(this).mediaRepository.requestRecovery(
3239
reason = "notification_removed_${sbn?.packageName ?: "unknown"}"
3340
)
3441
}
3542

43+
private fun publishNotificationArtworkSnapshot() {
44+
val snapshots = activeNotifications.orEmpty().mapNotNull(::resolveNotificationArtworkSnapshot)
45+
MediaControlAppServices.from(this).mediaRepository.replaceNotificationArtworkSnapshot(snapshots)
46+
}
47+
48+
private fun resolveNotificationArtworkSnapshot(sbn: StatusBarNotification): NotificationArtworkSnapshot? {
49+
val largeIconBitmap = sbn.notification.getLargeIcon()?.loadBitmap() ?: return null
50+
51+
return NotificationArtworkSnapshot(
52+
packageName = sbn.packageName,
53+
artwork = MediaArtwork.BitmapSource(
54+
source = MediaArtworkSource.NotificationLargeIcon,
55+
bitmap = largeIconBitmap,
56+
widthPx = largeIconBitmap.width,
57+
heightPx = largeIconBitmap.height
58+
)
59+
)
60+
}
61+
62+
private fun android.graphics.drawable.Icon.loadBitmap(): Bitmap? {
63+
val drawable = loadDrawable(this@MediaNotificationListenerService) ?: return null
64+
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: drawable.minimumWidth.takeIf { it > 0 } ?: 1
65+
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: drawable.minimumHeight.takeIf { it > 0 } ?: 1
66+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
67+
val canvas = Canvas(bitmap)
68+
drawable.setBounds(0, 0, canvas.width, canvas.height)
69+
drawable.draw(canvas)
70+
return bitmap
71+
}
72+
3673
private companion object {
3774
const val TAG = "MediaNotifListener"
3875
}

0 commit comments

Comments
 (0)