Skip to content

Commit ddbbd50

Browse files
Leuconoetrkimsisyphus-dev-ai
authored
feat: v0.3.0 — permission guidance, layout refactor, and thumbnail helpers (#8)
* feat: v0.3.0 — permission guidance, layout refactor, and thumbnail helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: add v0.3.0 release notes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: trkim <trkim@trkim> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent bc1b07d commit ddbbd50

13 files changed

Lines changed: 459 additions & 305 deletions

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ android {
3535
applicationId = "sw2.io.mediafloat"
3636
minSdk = 29
3737
targetSdk = 35
38-
versionCode = 5
39-
versionName = "0.2.3"
38+
versionCode = 7
39+
versionName = "0.3.0"
4040

4141
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
4242
}

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

Lines changed: 10 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,16 @@ 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
75
import android.media.session.MediaController
86
import android.media.MediaMetadata
97
import android.media.session.MediaSessionManager
108
import android.media.session.PlaybackState
11-
import android.net.Uri
129
import android.os.Handler
1310
import android.os.Looper
1411
import android.util.Log
1512
import sw2.io.mediafloat.debug.DebugLogWriter
1613
import sw2.io.mediafloat.debug.NoOpDebugLogWriter
1714
import sw2.io.mediafloat.model.MediaArtwork
18-
import sw2.io.mediafloat.model.MediaArtworkSource
1915
import sw2.io.mediafloat.model.MediaCommand
2016
import sw2.io.mediafloat.model.MediaSessionErrorReason
2117
import sw2.io.mediafloat.model.MediaSessionLimitReason
@@ -37,14 +33,17 @@ class AndroidMediaSessionRepository(
3733
appContext.getSystemService(MediaSessionManager::class.java)
3834
private val listenerComponent = ComponentName(appContext, MediaNotificationListenerService::class.java)
3935
private val handler = Handler(Looper.getMainLooper())
36+
private val controllerSelector = MediaControllerSelector
37+
private val recoveryPolicy = MediaRecoveryPolicy
38+
private val artworkResolver = MediaArtworkCandidateResolver(appContext.contentResolver)
4039
private val listeners = linkedSetOf<MediaSessionStateListener>()
4140
private val activeSessionsListener = MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
4241
handleControllersChanged(controllers.orEmpty())
4342
}
4443
private val controllerCallback = object : MediaController.Callback() {
4544
override fun onPlaybackStateChanged(state: PlaybackState?) {
4645
publishState(resolveState(currentController))
47-
if (shouldRecoverAfterPlaybackStateChange(state)) {
46+
if (recoveryPolicy.shouldRecoverAfterPlaybackStateChange(state)) {
4847
requestRecovery("playback_state_${state?.state ?: "unknown"}")
4948
}
5049
}
@@ -221,25 +220,14 @@ class AndroidMediaSessionRepository(
221220
registerController(nextController)
222221
val nextState = resolveState(nextController)
223222
publishState(nextState)
224-
if (shouldContinueRecovery(nextState)) {
223+
if (recoveryPolicy.shouldContinueRecovery(nextState)) {
225224
requestRecovery("controller_state_changed")
226225
} else {
227226
clearRecovery()
228227
}
229228
}
230229

231-
private fun selectController(controllers: List<MediaController>): MediaController? {
232-
if (controllers.isEmpty()) {
233-
return null
234-
}
235-
236-
return controllers
237-
.sortedWith(
238-
compareByDescending<MediaController> { it.playbackState.isActivelyPlaying() }
239-
.thenByDescending { it.playbackState?.lastPositionUpdateTime ?: 0L }
240-
)
241-
.firstOrNull()
242-
}
230+
private fun selectController(controllers: List<MediaController>): MediaController? = controllerSelector.select(controllers)
243231

244232
private fun registerController(controller: MediaController) {
245233
if (currentController?.sessionToken == controller.sessionToken) {
@@ -288,78 +276,7 @@ class AndroidMediaSessionRepository(
288276
}
289277

290278
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-
)
279+
return artworkResolver.resolve(controller, notificationArtworkByPackage)
363280
}
364281

365282
private fun publishState(state: MediaSessionState) {
@@ -377,7 +294,7 @@ class AndroidMediaSessionRepository(
377294
val nextState = refresh(reason = "recovery_${attempt + 1}_$reason")
378295
synchronized(this) {
379296
recoveryRunnable = null
380-
if (connected && attempt + 1 < MAX_RECOVERY_ATTEMPTS && shouldContinueRecovery(nextState)) {
297+
if (connected && attempt + 1 < MAX_RECOVERY_ATTEMPTS && recoveryPolicy.shouldContinueRecovery(nextState)) {
381298
scheduleRecovery(reason = reason, attempt = attempt + 1)
382299
}
383300
}
@@ -393,33 +310,9 @@ class AndroidMediaSessionRepository(
393310
recoveryRunnable = null
394311
}
395312

396-
private fun shouldRecoverAfterPlaybackStateChange(state: PlaybackState?): Boolean {
397-
if (state == null) {
398-
return true
399-
}
313+
private fun shouldRecoverAfterPlaybackStateChange(state: PlaybackState?): Boolean = recoveryPolicy.shouldRecoverAfterPlaybackStateChange(state)
400314

401-
return when (state.state) {
402-
PlaybackState.STATE_NONE,
403-
PlaybackState.STATE_STOPPED,
404-
PlaybackState.STATE_ERROR -> true
405-
else -> state.actions.toSupportedCommands().isEmpty()
406-
}
407-
}
408-
409-
private fun shouldContinueRecovery(state: MediaSessionState): Boolean {
410-
return when (state) {
411-
is MediaSessionState.Active -> false
412-
is MediaSessionState.Limited -> state.reason == MediaSessionLimitReason.PlaybackStateUnknown ||
413-
state.reason == MediaSessionLimitReason.MissingTransportControls
414-
MediaSessionState.Discovering,
415-
MediaSessionState.Unavailable -> true
416-
is MediaSessionState.Error -> false
417-
}
418-
}
419-
420-
private fun PlaybackState?.isActivelyPlaying(): Boolean {
421-
return this?.state == PlaybackState.STATE_PLAYING || this?.state == PlaybackState.STATE_BUFFERING
422-
}
315+
private fun shouldContinueRecovery(state: MediaSessionState): Boolean = recoveryPolicy.shouldContinueRecovery(state)
423316

424317
internal companion object {
425318
fun buildMediaSessionState(
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package sw2.io.mediafloat.media
2+
3+
import android.content.ContentResolver
4+
import android.graphics.Bitmap
5+
import android.graphics.BitmapFactory
6+
import android.media.MediaMetadata
7+
import android.media.session.MediaController
8+
import android.net.Uri
9+
import sw2.io.mediafloat.model.MediaArtwork
10+
import sw2.io.mediafloat.model.MediaArtworkSource
11+
12+
internal class MediaArtworkCandidateResolver(
13+
private val contentResolver: ContentResolver
14+
) {
15+
fun resolve(controller: MediaController, notificationArtworkByPackage: Map<String, MediaArtwork.BitmapSource>): List<MediaArtwork> {
16+
val metadata = controller.metadata
17+
return buildArtworkCandidates(
18+
metadataDisplayIconUri = resolveArtworkUriCandidate(metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI), MediaArtworkSource.MetadataDisplayIconUri),
19+
metadataArtUri = resolveArtworkUriCandidate(metadata?.getString(MediaMetadata.METADATA_KEY_ART_URI), MediaArtworkSource.MetadataArtUri),
20+
metadataAlbumArtUri = resolveArtworkUriCandidate(metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI), MediaArtworkSource.MetadataAlbumArtUri),
21+
metadataDisplayIconBitmap = resolveArtworkBitmapCandidate(metadata?.getBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON), MediaArtworkSource.MetadataDisplayIconBitmap),
22+
metadataArtBitmap = resolveArtworkBitmapCandidate(metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART), MediaArtworkSource.MetadataArtBitmap),
23+
metadataAlbumArtBitmap = resolveArtworkBitmapCandidate(metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART), MediaArtworkSource.MetadataAlbumArtBitmap),
24+
notificationLargeIcon = notificationArtworkByPackage[controller.packageName]
25+
)
26+
}
27+
28+
private fun resolveArtworkUriCandidate(rawUri: String?, source: MediaArtworkSource): MediaArtwork.UriSource? {
29+
val normalizedUri = rawUri?.trim()?.takeIf { it.isNotEmpty() } ?: return null
30+
val artworkBounds = runCatching {
31+
contentResolver.openInputStream(Uri.parse(normalizedUri))?.use { stream ->
32+
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
33+
BitmapFactory.decodeStream(stream, null, options)
34+
if (options.outWidth > 0 && options.outHeight > 0) options.outWidth to options.outHeight else null
35+
}
36+
}.getOrNull() ?: return null
37+
return MediaArtwork.UriSource(source = source, uri = normalizedUri, widthPx = artworkBounds.first, heightPx = artworkBounds.second)
38+
}
39+
40+
private fun resolveArtworkBitmapCandidate(bitmap: Bitmap?, source: MediaArtworkSource): MediaArtwork.BitmapSource? {
41+
val resolvedBitmap = bitmap?.takeIf { it.width > 0 && it.height > 0 } ?: return null
42+
return MediaArtwork.BitmapSource(source = source, bitmap = resolvedBitmap, widthPx = resolvedBitmap.width, heightPx = resolvedBitmap.height)
43+
}
44+
45+
companion object {
46+
fun buildArtworkCandidates(
47+
metadataDisplayIconUri: MediaArtwork.UriSource?,
48+
metadataArtUri: MediaArtwork.UriSource?,
49+
metadataAlbumArtUri: MediaArtwork.UriSource?,
50+
metadataDisplayIconBitmap: MediaArtwork.BitmapSource?,
51+
metadataArtBitmap: MediaArtwork.BitmapSource?,
52+
metadataAlbumArtBitmap: MediaArtwork.BitmapSource?,
53+
notificationLargeIcon: MediaArtwork.BitmapSource?
54+
): List<MediaArtwork> {
55+
return listOfNotNull(
56+
metadataDisplayIconUri,
57+
metadataArtUri,
58+
metadataAlbumArtUri,
59+
metadataDisplayIconBitmap,
60+
metadataArtBitmap,
61+
metadataAlbumArtBitmap,
62+
notificationLargeIcon
63+
)
64+
}
65+
}
66+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package sw2.io.mediafloat.media
2+
3+
import android.media.session.MediaController
4+
import android.media.session.PlaybackState
5+
6+
internal object MediaControllerSelector {
7+
fun select(controllers: List<MediaController>): MediaController? {
8+
if (controllers.isEmpty()) return null
9+
return controllers
10+
.sortedWith(
11+
compareByDescending<MediaController> { it.playbackState.isActivelyPlaying() }
12+
.thenByDescending { it.playbackState?.lastPositionUpdateTime ?: 0L }
13+
)
14+
.firstOrNull()
15+
}
16+
17+
private fun PlaybackState?.isActivelyPlaying(): Boolean {
18+
return this?.state == PlaybackState.STATE_PLAYING || this?.state == PlaybackState.STATE_BUFFERING
19+
}
20+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package sw2.io.mediafloat.media
2+
3+
import android.media.session.PlaybackState
4+
import sw2.io.mediafloat.model.MediaCommand
5+
import sw2.io.mediafloat.model.MediaSessionLimitReason
6+
import sw2.io.mediafloat.model.MediaSessionState
7+
8+
internal object MediaRecoveryPolicy {
9+
fun shouldRecoverAfterPlaybackStateChange(state: PlaybackState?): Boolean {
10+
if (state == null) return true
11+
return when (state.state) {
12+
PlaybackState.STATE_NONE,
13+
PlaybackState.STATE_STOPPED,
14+
PlaybackState.STATE_ERROR -> true
15+
else -> state.actions.toSupportedCommands().isEmpty()
16+
}
17+
}
18+
19+
fun shouldContinueRecovery(state: MediaSessionState): Boolean {
20+
return when (state) {
21+
is MediaSessionState.Active -> false
22+
is MediaSessionState.Limited -> state.reason == MediaSessionLimitReason.PlaybackStateUnknown ||
23+
state.reason == MediaSessionLimitReason.MissingTransportControls
24+
MediaSessionState.Discovering,
25+
MediaSessionState.Unavailable -> true
26+
is MediaSessionState.Error -> false
27+
}
28+
}
29+
30+
private fun Long.toSupportedCommands(): Set<MediaCommand> {
31+
val commands = linkedSetOf<MediaCommand>()
32+
if (this and PlaybackState.ACTION_SKIP_TO_PREVIOUS != 0L) commands += MediaCommand.Previous
33+
if (this and PlaybackState.ACTION_PLAY != 0L || this and PlaybackState.ACTION_PAUSE != 0L || this and PlaybackState.ACTION_PLAY_PAUSE != 0L) {
34+
commands += MediaCommand.TogglePlayPause
35+
}
36+
if (this and PlaybackState.ACTION_SKIP_TO_NEXT != 0L) commands += MediaCommand.Next
37+
return commands
38+
}
39+
}

app/src/main/java/com/mediacontrol/floatingwidget/model/CapabilityState.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,10 @@ data class CapabilityState(
7474
}
7575

7676
fun isReadyForPersistentOverlay(): Boolean = unavailableReasons().isEmpty()
77+
78+
fun hasAnyPermissionGranted(): Boolean {
79+
return overlayAccess == CapabilityGrantState.Granted ||
80+
notificationListenerAccess == CapabilityGrantState.Granted ||
81+
notificationPosture == NotificationPosture.Visible
82+
}
7783
}

0 commit comments

Comments
 (0)