diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e742a123..f08e3b91 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -208,6 +208,7 @@ dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.media)
implementation(libs.google.material)
// Compose
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0ab9848d..c5e6b8d5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,6 +50,16 @@
android:resource="@xml/lock_screen_accessibility_config" />
+
+
+
+
+
+
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/data/local/PreferencesManager.kt b/app/src/main/java/com/lu4p/fokuslauncher/data/local/PreferencesManager.kt
index 42eaaa24..9efdeb7c 100644
--- a/app/src/main/java/com/lu4p/fokuslauncher/data/local/PreferencesManager.kt
+++ b/app/src/main/java/com/lu4p/fokuslauncher/data/local/PreferencesManager.kt
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.lu4p.fokuslauncher.data.model.DrawerAppSortMode
import com.lu4p.fokuslauncher.data.model.FavoriteApp
import com.lu4p.fokuslauncher.data.model.drawerOpenCountKey
@@ -90,6 +91,9 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v
private val TEMPERATURE_UNIT_KEY = stringPreferencesKey("temperature_unit")
private val SHOW_HOME_WEATHER_KEY = booleanPreferencesKey("show_home_weather")
private val SHOW_HOME_BATTERY_KEY = booleanPreferencesKey("show_home_battery")
+ /** Opt-in media widget; off by default since no apps are registered yet. */
+ private val SHOW_HOME_MEDIA_KEY = booleanPreferencesKey("show_home_media")
+ /** Package names of media apps the user registered for the widget to connect to. */
/** Vertical category sidebar in the drawer instead of chips + search bar. */
private val DRAWER_SIDEBAR_CATEGORIES_KEY =
booleanPreferencesKey("drawer_sidebar_categories")
@@ -387,6 +391,9 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v
val showHomeBatteryFlow: Flow = prefFlow(SHOW_HOME_BATTERY_KEY, true)
suspend fun setShowHomeBattery(show: Boolean) = setPref(SHOW_HOME_BATTERY_KEY, show)
+ val showHomeMediaFlow: Flow = prefFlow(SHOW_HOME_MEDIA_KEY, false)
+ suspend fun setShowHomeMedia(show: Boolean) = setPref(SHOW_HOME_MEDIA_KEY, show)
+
val homeWidgetVisibilityFlow: Flow =
combine(
showHomeClockFlow,
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReader.kt b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReader.kt
new file mode 100644
index 00000000..2ffeb2e8
--- /dev/null
+++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReader.kt
@@ -0,0 +1,217 @@
+package com.lu4p.fokuslauncher.media
+
+import android.os.Bundle
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.RatingCompat
+import android.support.v4.media.session.PlaybackStateCompat
+
+/** A like or save control advertised by the active app through [PlaybackStateCompat] custom actions. */
+data class MediaCustomActionButton(
+ val actionId: String,
+ val label: String,
+ val active: Boolean,
+ val extras: Bundle?,
+)
+
+/** Maps session custom actions to like/save buttons using action id and label heuristics. */
+object MediaCustomActionsReader {
+
+ fun likeButton(
+ state: PlaybackStateCompat?,
+ metadata: MediaMetadataCompat? = null,
+ ): MediaCustomActionButton? =
+ bestMatch(state?.customActions.orEmpty(), ActionKind.LIKE, metadata)
+
+ fun saveButton(
+ state: PlaybackStateCompat?,
+ metadata: MediaMetadataCompat? = null,
+ ): MediaCustomActionButton? =
+ bestMatch(state?.customActions.orEmpty(), ActionKind.SAVE, metadata)
+
+ private enum class ActionKind {
+ LIKE,
+ SAVE,
+ }
+
+ private fun bestMatch(
+ actions: List,
+ kind: ActionKind,
+ metadata: MediaMetadataCompat?,
+ ): MediaCustomActionButton? =
+ actions
+ .mapNotNull { action -> score(action, kind, metadata) }
+ .maxByOrNull { it.second }
+ ?.first
+
+ private fun score(
+ action: PlaybackStateCompat.CustomAction,
+ kind: ActionKind,
+ metadata: MediaMetadataCompat?,
+ ): Pair? {
+ val actionId = action.action.orEmpty()
+ val label = action.name?.toString().orEmpty()
+ val haystack = "$actionId $label".lowercase()
+ if (haystack.isBlank()) return null
+
+ val otherKind = if (kind == ActionKind.LIKE) ActionKind.SAVE else ActionKind.LIKE
+ val thisScore = keywordScore(haystack, keywords(kind))
+ if (thisScore <= 0) return null
+ val otherScore = keywordScore(haystack, keywords(otherKind))
+ if (otherScore >= thisScore) return null
+
+ val active = resolveActive(haystack, action.extras, metadata, kind)
+ return MediaCustomActionButton(
+ actionId = actionId,
+ label = label.ifBlank { defaultLabel(kind, active) },
+ active = active,
+ extras = action.extras,
+ ) to thisScore
+ }
+
+ private fun resolveActive(
+ haystack: String,
+ extras: Bundle?,
+ metadata: MediaMetadataCompat?,
+ kind: ActionKind,
+ ): Boolean {
+ extrasIndicateActive(extras)?.let { return it }
+ if (kind == ActionKind.LIKE) {
+ metadataIndicatesLiked(metadata)?.let { return it }
+ }
+ return isActiveFromLabel(haystack)
+ }
+
+ private fun extrasIndicateActive(extras: Bundle?): Boolean? {
+ extras ?: return null
+ for (key in extras.keySet()) {
+ val normalizedKey = key.lowercase()
+ if (
+ normalizedKey !in ACTIVE_EXTRA_KEYS &&
+ normalizedKey !in INACTIVE_EXTRA_KEYS
+ ) {
+ continue
+ }
+ when (val value = extras.get(key)) {
+ is Boolean ->
+ return if (normalizedKey in INACTIVE_EXTRA_KEYS) !value else value
+ is Int ->
+ return when {
+ value == 1 -> normalizedKey !in INACTIVE_EXTRA_KEYS
+ value == 0 -> false
+ else -> null
+ }
+ is String -> {
+ val normalized = value.lowercase()
+ if (normalized in ACTIVE_EXTRA_VALUES) return true
+ if (normalized in INACTIVE_EXTRA_VALUES) return false
+ }
+ }
+ }
+ return null
+ }
+
+ private fun metadataIndicatesLiked(metadata: MediaMetadataCompat?): Boolean? {
+ val rating = metadata?.getRating(MediaMetadataCompat.METADATA_KEY_USER_RATING) ?: return null
+ if (!rating.isRated) return false
+ return when (rating.ratingStyle) {
+ RatingCompat.RATING_HEART -> rating.hasHeart()
+ RatingCompat.RATING_THUMB_UP_DOWN -> rating.isThumbUp
+ else -> null
+ }
+ }
+
+ /** Liked/saved items usually expose a remove action; new items expose add/save. */
+ private fun isActiveFromLabel(haystack: String): Boolean {
+ if (INACTIVE_LABEL_PATTERNS.any { it in haystack }) return false
+ return ACTIVE_LABEL_PATTERNS.any { it in haystack }
+ }
+
+ private fun keywords(kind: ActionKind): List =
+ when (kind) {
+ ActionKind.LIKE ->
+ listOf(
+ "like",
+ "thumb",
+ "favorite",
+ "favourite",
+ "heart",
+ "love",
+ "add_to_liked",
+ "liked_songs",
+ )
+ ActionKind.SAVE ->
+ listOf(
+ "save",
+ "library",
+ "collection",
+ "bookmark",
+ "add_to_library",
+ "add_to_collection",
+ "add_song",
+ "your_episodes",
+ )
+ }
+
+ private fun keywordScore(haystack: String, keywords: List): Int {
+ var score = 0
+ for (keyword in keywords) {
+ if (keyword in haystack) score += 10 + keyword.length
+ }
+ return score
+ }
+
+ private fun defaultLabel(kind: ActionKind, active: Boolean): String =
+ when (kind) {
+ ActionKind.LIKE -> if (active) "Liked" else "Like"
+ ActionKind.SAVE -> if (active) "Saved" else "Save"
+ }
+
+ private val ACTIVE_LABEL_PATTERNS =
+ listOf(
+ "remove from",
+ "remove_from",
+ "delete from",
+ "delete_from",
+ "unlike",
+ "unsave",
+ "remove",
+ )
+
+ private val INACTIVE_LABEL_PATTERNS =
+ listOf(
+ "add to",
+ "add_to",
+ "save to",
+ "save_to",
+ "download",
+ )
+
+ private val ACTIVE_EXTRA_KEYS =
+ setOf(
+ "active",
+ "enabled",
+ "liked",
+ "saved",
+ "is_liked",
+ "is_saved",
+ "favorite",
+ "favourite",
+ "in_library",
+ "checked",
+ "selected",
+ )
+
+ private val INACTIVE_EXTRA_KEYS =
+ setOf(
+ "inactive",
+ "disabled",
+ "unchecked",
+ "unselected",
+ )
+
+ private val ACTIVE_EXTRA_VALUES =
+ setOf("true", "1", "on", "yes", "liked", "saved", "active", "checked", "selected")
+
+ private val INACTIVE_EXTRA_VALUES =
+ setOf("false", "0", "off", "no", "inactive", "unchecked", "unselected")
+}
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/media/MediaMetadataReader.kt b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaMetadataReader.kt
new file mode 100644
index 00000000..408f5436
--- /dev/null
+++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaMetadataReader.kt
@@ -0,0 +1,55 @@
+package com.lu4p.fokuslauncher.media
+
+import android.support.v4.media.MediaMetadataCompat
+
+/** Reads now-playing fields the way Android media notifications do (getText + description). */
+object MediaMetadataReader {
+
+ fun trackTitle(metadata: MediaMetadataCompat?): String? {
+ metadata ?: return null
+ return metadata.readText(MediaMetadataCompat.METADATA_KEY_TITLE)
+ ?: metadata.readText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE)
+ ?: metadata.description.title?.toString()?.trim()?.takeIf { it.isNotBlank() }
+ }
+
+ fun artistName(metadata: MediaMetadataCompat?, title: String? = trackTitle(metadata)): String? {
+ metadata ?: return null
+ val normalizedTitle = title?.trim()?.takeIf { it.isNotBlank() }
+
+ for (key in PRIMARY_ARTIST_KEYS) {
+ metadata.readText(key)?.let { return it }
+ }
+
+ metadata.description.subtitle?.toString()?.trim()?.takeIf { it.isNotBlank() }?.let { subtitle ->
+ if (normalizedTitle == null || !subtitle.equals(normalizedTitle, ignoreCase = true)) {
+ return subtitle
+ }
+ }
+
+ for (key in SECONDARY_ARTIST_KEYS) {
+ metadata.readText(key)?.let { candidate ->
+ if (normalizedTitle == null || !candidate.equals(normalizedTitle, ignoreCase = true)) {
+ return candidate
+ }
+ }
+ }
+ return null
+ }
+
+ private fun MediaMetadataCompat.readText(key: String): String? =
+ getString(key)?.trim()?.takeIf { it.isNotBlank() }
+ ?: getText(key)?.toString()?.trim()?.takeIf { it.isNotBlank() }
+
+ private val PRIMARY_ARTIST_KEYS =
+ listOf(
+ MediaMetadataCompat.METADATA_KEY_ARTIST,
+ MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
+ MediaMetadataCompat.METADATA_KEY_AUTHOR,
+ )
+
+ private val SECONDARY_ARTIST_KEYS =
+ listOf(
+ MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE,
+ MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
+ )
+}
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationHelper.kt b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationHelper.kt
new file mode 100644
index 00000000..5468b8f2
--- /dev/null
+++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationHelper.kt
@@ -0,0 +1,24 @@
+package com.lu4p.fokuslauncher.media
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings
+import androidx.core.app.NotificationManagerCompat
+
+object MediaNotificationHelper {
+
+ fun componentName(context: Context): ComponentName =
+ ComponentName(context, MediaNotificationListenerService::class.java)
+
+ fun isListenerEnabled(context: Context): Boolean =
+ context.packageName in
+ NotificationManagerCompat.getEnabledListenerPackages(context)
+
+ fun openListenerSettings(context: Context) {
+ context.startActivity(
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+}
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationListenerService.kt b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationListenerService.kt
new file mode 100644
index 00000000..adf16a94
--- /dev/null
+++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationListenerService.kt
@@ -0,0 +1,29 @@
+package com.lu4p.fokuslauncher.media
+
+import android.content.ComponentName
+import android.service.notification.NotificationListenerService
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+/**
+ * Lets [MediaRepository] read active media sessions (including Spotify) via
+ * [android.media.session.MediaSessionManager]. The user must grant notification access in system
+ * settings.
+ */
+@AndroidEntryPoint
+class MediaNotificationListenerService : NotificationListenerService() {
+
+ @Inject lateinit var mediaRepository: MediaRepository
+
+ override fun onListenerConnected() {
+ super.onListenerConnected()
+ mediaRepository.onNotificationListenerConnected(
+ ComponentName(this, MediaNotificationListenerService::class.java)
+ )
+ }
+
+ override fun onListenerDisconnected() {
+ mediaRepository.onNotificationListenerDisconnected()
+ super.onListenerDisconnected()
+ }
+}
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/media/MediaPlaybackState.kt b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaPlaybackState.kt
new file mode 100644
index 00000000..92a84a05
--- /dev/null
+++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaPlaybackState.kt
@@ -0,0 +1,23 @@
+package com.lu4p.fokuslauncher.media
+
+import android.support.v4.media.session.PlaybackStateCompat
+
+/** Helpers for interpreting [PlaybackStateCompat] session states in the media widget. */
+object MediaPlaybackState {
+
+ /** True for playing or buffering — session is actively engaged with media. */
+ fun isActivelyPlaying(state: Int?): Boolean =
+ state == PlaybackStateCompat.STATE_PLAYING ||
+ state == PlaybackStateCompat.STATE_BUFFERING
+
+ fun isBuffering(state: Int?): Boolean = state == PlaybackStateCompat.STATE_BUFFERING
+
+ fun isShowable(state: Int?): Boolean =
+ when (state) {
+ null,
+ PlaybackStateCompat.STATE_NONE,
+ PlaybackStateCompat.STATE_STOPPED,
+ PlaybackStateCompat.STATE_ERROR -> false
+ else -> true
+ }
+}
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/media/MediaRepository.kt b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaRepository.kt
new file mode 100644
index 00000000..bc3695e3
--- /dev/null
+++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaRepository.kt
@@ -0,0 +1,417 @@
+package com.lu4p.fokuslauncher.media
+
+import android.app.ActivityOptions
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.support.v4.media.session.MediaControllerCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.annotation.MainThread
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Now-playing snapshot for the home media widget; null when nothing is actively playing. */
+data class MediaPlaybackUiState(
+ val title: String,
+ val artist: String?,
+ /** True for [PlaybackStateCompat.STATE_PLAYING] and [PlaybackStateCompat.STATE_BUFFERING]. */
+ val isPlaying: Boolean,
+ val isBuffering: Boolean = false,
+ /** False when the active app does not advertise [PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS]. */
+ val canSkipToPrevious: Boolean,
+ /** False when the active app does not advertise [PlaybackStateCompat.ACTION_SKIP_TO_NEXT]. */
+ val canSkipToNext: Boolean,
+ val like: MediaCustomActionButton? = null,
+ val save: MediaCustomActionButton? = null,
+)
+
+/**
+ * Surfaces the now-playing session for user-registered media apps and forwards transport controls
+ * to them via notification access ([MediaNotificationListenerService] +
+ * [MediaSessionManager.getActiveSessions]).
+ *
+ * All session interaction happens on the main thread.
+ */
+@Singleton
+class MediaRepository @Inject constructor(@param:ApplicationContext private val context: Context) {
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ private val _state = MutableStateFlow(null)
+ val state: StateFlow = _state.asStateFlow()
+
+ /** Session controllers from notification access, keyed by package name. */
+ private val sessionControllers = LinkedHashMap()
+
+ private var widgetEnabled = false
+ private var listenerComponent: ComponentName? = null
+ private var sessionsListener: MediaSessionManager.OnActiveSessionsChangedListener? = null
+
+ /** True once a session has stayed paused past the grace period, so the widget hides until it
+ * plays again. Many apps leave a paused session alive after they're closed; this clears it up. */
+ private var pausedGraceExpired = false
+ private var hideScheduled = false
+ private var optimisticToggle: OptimisticCustomActionToggle? = null
+ private val hideRunnable = Runnable {
+ hideScheduled = false
+ pausedGraceExpired = true
+ publishState()
+ }
+
+ private val sessionCallback =
+ object : MediaControllerCompat.Callback() {
+ override fun onPlaybackStateChanged(state: PlaybackStateCompat?) = publishState()
+
+ override fun onMetadataChanged(metadata: android.support.v4.media.MediaMetadataCompat?) =
+ publishState()
+
+ override fun onSessionDestroyed() {
+ refreshNotificationSessions()
+ }
+ }
+
+ /** Enable or disable the home media widget; requires notification access when enabling. */
+ @MainThread
+ fun setWidgetEnabled(enabled: Boolean) {
+ widgetEnabled = enabled
+ if (!enabled || !MediaNotificationHelper.isListenerEnabled(context)) {
+ resetPauseGrace()
+ optimisticToggle = null
+ _state.value = null
+ return
+ }
+ refreshNotificationSessions()
+ publishState()
+ }
+
+ @MainThread
+ fun stop() {
+ setWidgetEnabled(false)
+ }
+
+ @MainThread
+ fun onNotificationListenerConnected(component: ComponentName) {
+ listenerComponent = component
+ val manager = context.getSystemService(MediaSessionManager::class.java) ?: return
+ sessionsListener =
+ MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
+ mainHandler.post { updateSessionControllers(controllers) }
+ }
+ try {
+ manager.addOnActiveSessionsChangedListener(
+ sessionsListener!!,
+ component,
+ mainHandler,
+ )
+ updateSessionControllers(manager.getActiveSessions(component))
+ } catch (_: SecurityException) {
+ onNotificationListenerDisconnected()
+ }
+ }
+
+ @MainThread
+ fun onNotificationListenerDisconnected() {
+ sessionsListener?.let { listener ->
+ try {
+ context.getSystemService(MediaSessionManager::class.java)
+ ?.removeOnActiveSessionsChangedListener(listener)
+ } catch (_: Exception) {}
+ }
+ sessionsListener = null
+ listenerComponent = null
+ sessionControllers.values.forEach { it.unregisterCallback(sessionCallback) }
+ sessionControllers.clear()
+ publishState()
+ }
+
+ /** Re-read active sessions when the home screen resumes or registered apps change. */
+ @MainThread
+ fun refreshNotificationSessions() {
+ if (!widgetEnabled || !MediaNotificationHelper.isListenerEnabled(context)) return
+ val component = listenerComponent ?: MediaNotificationHelper.componentName(context)
+ val manager = context.getSystemService(MediaSessionManager::class.java) ?: return
+ try {
+ updateSessionControllers(manager.getActiveSessions(component))
+ } catch (_: SecurityException) {}
+ }
+
+ @MainThread fun playPause() {
+ val controller = activeController() ?: return
+ if (MediaPlaybackState.isActivelyPlaying(controller.playbackState?.state)) {
+ controller.transportControls.pause()
+ } else {
+ controller.transportControls.play()
+ }
+ }
+
+ /** Opens the playing app — its now-playing screen via [MediaControllerCompat.getSessionActivity]
+ * when offered, otherwise the app's launcher entry. */
+ @MainThread fun openMediaApp() {
+ val playback = resolveActivePlayback() ?: return
+ val controller = playback.controller
+ controller.sessionActivity?.let { pending ->
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ val options =
+ ActivityOptions.makeBasic()
+ .setPendingIntentBackgroundActivityStartMode(
+ ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
+ )
+ .toBundle()
+ pending.send(context, 0, null, null, null, null, options)
+ } else {
+ pending.send()
+ }
+ return
+ } catch (_: PendingIntent.CanceledException) {
+ // Stale PendingIntent; fall through to a plain launch.
+ }
+ }
+ launchPackage(controller.packageName)
+ }
+
+ @MainThread
+ fun skipToPrevious() {
+ val controller = activeController() ?: return
+ val actions = controller.playbackState?.actions ?: return
+ if (actions and PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS == 0L) return
+ controller.transportControls.skipToPrevious()
+ }
+
+ @MainThread
+ fun skipToNext() {
+ val controller = activeController() ?: return
+ val actions = controller.playbackState?.actions ?: return
+ if (actions and PlaybackStateCompat.ACTION_SKIP_TO_NEXT == 0L) return
+ controller.transportControls.skipToNext()
+ }
+
+ @MainThread
+ fun invokeLikeAction() {
+ val current = _state.value ?: return
+ val like = current.like ?: return
+ val trackKey = trackKey(current.title, current.artist)
+ val previous = optimisticToggle?.takeIf { it.trackKey == trackKey }
+ optimisticToggle =
+ OptimisticCustomActionToggle(
+ trackKey = trackKey,
+ likeActive = !like.active,
+ saveActive = previous?.saveActive,
+ )
+ _state.value = current.copy(like = like.copy(active = !like.active))
+ invokeCustomAction(like)
+ }
+
+ @MainThread
+ fun invokeSaveAction() {
+ val current = _state.value ?: return
+ val save = current.save ?: return
+ val trackKey = trackKey(current.title, current.artist)
+ val previous = optimisticToggle?.takeIf { it.trackKey == trackKey }
+ optimisticToggle =
+ OptimisticCustomActionToggle(
+ trackKey = trackKey,
+ likeActive = previous?.likeActive,
+ saveActive = !save.active,
+ )
+ _state.value = current.copy(save = save.copy(active = !save.active))
+ invokeCustomAction(save)
+ }
+
+ private fun invokeCustomAction(button: MediaCustomActionButton) {
+ val controller = activeController() ?: return
+ controller.transportControls.sendCustomAction(button.actionId, button.extras ?: Bundle())
+ }
+
+ private fun updateSessionControllers(frameworkControllers: List?) {
+ val incoming =
+ frameworkControllers.orEmpty().filter { it.packageName != context.packageName }
+ val incomingPackages = incoming.map { it.packageName }.toSet()
+
+ (sessionControllers.keys - incomingPackages).toList().forEach { packageName ->
+ sessionControllers.remove(packageName)?.unregisterCallback(sessionCallback)
+ }
+
+ for (frameworkController in incoming) {
+ val packageName = frameworkController.packageName
+ val compatToken = MediaSessionCompat.Token.fromToken(frameworkController.sessionToken)
+ val existing = sessionControllers[packageName]
+ if (existing != null && existing.sessionToken == compatToken) continue
+ existing?.unregisterCallback(sessionCallback)
+ val compat = MediaControllerCompat(context, compatToken)
+ compat.registerCallback(sessionCallback, mainHandler)
+ sessionControllers[packageName] = compat
+ }
+ publishState()
+ }
+
+ private fun allShowableControllers(): List =
+ sessionControllers.values.filter {
+ MediaPlaybackState.isShowable(it.playbackState?.state)
+ }
+
+ private fun activeController(): MediaControllerCompat? {
+ val controllers = allShowableControllers()
+ val active =
+ controllers.filter {
+ MediaPlaybackState.isActivelyPlaying(it.playbackState?.state)
+ }
+ if (active.isNotEmpty()) {
+ return active.maxWithOrNull(
+ compareBy { it.playbackState?.lastPositionUpdateTime ?: 0L }
+ )
+ }
+ return controllers.maxWithOrNull(
+ compareBy { it.playbackState?.lastPositionUpdateTime ?: 0L }
+ )
+ }
+
+ private fun resolveActivePlayback(): ActivePlayback? {
+ val controller = activeController()
+ val metadata = controller?.metadata
+ val playbackState = controller?.playbackState
+ val title = MediaMetadataReader.trackTitle(metadata)
+ if (controller == null || playbackState == null || title.isNullOrBlank()) return null
+
+ val state = playbackState.state
+ return ActivePlayback(
+ title = title,
+ artist = MediaMetadataReader.artistName(metadata, title),
+ isPlaying = MediaPlaybackState.isActivelyPlaying(state),
+ isBuffering = MediaPlaybackState.isBuffering(state),
+ controller = controller,
+ packageName = controller.packageName,
+ )
+ }
+
+ private fun publishState() {
+ if (!widgetEnabled || !MediaNotificationHelper.isListenerEnabled(context)) {
+ _state.value = null
+ return
+ }
+
+ val playback = resolveActivePlayback()
+ if (playback == null) {
+ resetPauseGrace()
+ optimisticToggle = null
+ _state.value = null
+ return
+ }
+
+ if (playback.isPlaying) {
+ resetPauseGrace()
+ } else if (pausedGraceExpired) {
+ _state.value = null
+ return
+ } else {
+ schedulePauseHide()
+ }
+
+ val actions = playback.controller.playbackState?.actions ?: 0L
+ val playbackState = playback.controller.playbackState
+ val metadata = playback.controller.metadata
+ val trackKey = trackKey(playback.title, playback.artist)
+ var like = MediaCustomActionsReader.likeButton(playbackState, metadata)
+ var save = MediaCustomActionsReader.saveButton(playbackState, metadata)
+ reconcileOptimisticToggle(trackKey, like, save)
+ val optimistic = optimisticToggle?.takeIf { it.trackKey == trackKey }
+ like = applyOptimisticToggle(like, optimistic?.likeActive)
+ save = applyOptimisticToggle(save, optimistic?.saveActive)
+ _state.value =
+ MediaPlaybackUiState(
+ title = playback.title,
+ artist = playback.artist,
+ isPlaying = playback.isPlaying,
+ isBuffering = playback.isBuffering,
+ canSkipToPrevious =
+ actions and PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS != 0L,
+ canSkipToNext =
+ actions and PlaybackStateCompat.ACTION_SKIP_TO_NEXT != 0L,
+ like = like,
+ save = save,
+ )
+ }
+
+ private fun trackKey(title: String, artist: String?): String = "$title|${artist.orEmpty()}"
+
+ private fun applyOptimisticToggle(
+ button: MediaCustomActionButton?,
+ activeOverride: Boolean?,
+ ): MediaCustomActionButton? {
+ button ?: return null
+ return if (activeOverride != null) button.copy(active = activeOverride) else button
+ }
+
+ private fun reconcileOptimisticToggle(
+ trackKey: String,
+ like: MediaCustomActionButton?,
+ save: MediaCustomActionButton?,
+ ) {
+ val optimistic = optimisticToggle ?: return
+ if (optimistic.trackKey != trackKey) {
+ optimisticToggle = null
+ return
+ }
+ val likeMatches = optimistic.likeActive == null || like?.active == optimistic.likeActive
+ val saveMatches = optimistic.saveActive == null || save?.active == optimistic.saveActive
+ if (likeMatches && saveMatches) {
+ optimisticToggle = null
+ }
+ }
+
+ private fun launchPackage(packageName: String) {
+ val launch = context.packageManager.getLaunchIntentForPackage(packageName) ?: return
+ launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ try {
+ context.startActivity(launch)
+ } catch (_: Exception) {}
+ }
+
+ private fun schedulePauseHide() {
+ if (hideScheduled) return
+ hideScheduled = true
+ mainHandler.postDelayed(hideRunnable, PAUSE_HIDE_DELAY_MS)
+ }
+
+ private fun resetPauseGrace() {
+ pausedGraceExpired = false
+ hideScheduled = false
+ mainHandler.removeCallbacks(hideRunnable)
+ }
+
+ private data class OptimisticCustomActionToggle(
+ val trackKey: String,
+ val likeActive: Boolean? = null,
+ val saveActive: Boolean? = null,
+ )
+
+ private data class ActivePlayback(
+ val title: String,
+ val artist: String?,
+ val isPlaying: Boolean,
+ val isBuffering: Boolean,
+ val controller: MediaControllerCompat,
+ val packageName: String,
+ )
+
+ companion object {
+ /** Hide a session that stays paused this long, so closed apps don't leave the widget up. */
+ private const val PAUSE_HIDE_DELAY_MS = 60_000L
+
+ /** First non-blank artist-like field; many players only populate display subtitle. */
+ fun artistName(metadata: android.support.v4.media.MediaMetadataCompat?): String? =
+ MediaMetadataReader.artistName(metadata)
+ }
+}
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/ui/components/MediaWidget.kt b/app/src/main/java/com/lu4p/fokuslauncher/ui/components/MediaWidget.kt
new file mode 100644
index 00000000..ddf55d07
--- /dev/null
+++ b/app/src/main/java/com/lu4p/fokuslauncher/ui/components/MediaWidget.kt
@@ -0,0 +1,243 @@
+package com.lu4p.fokuslauncher.ui.components
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material.icons.outlined.BookmarkBorder
+import androidx.compose.material.icons.outlined.FavoriteBorder
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.lu4p.fokuslauncher.R
+import com.lu4p.fokuslauncher.media.MediaCustomActionButton
+import com.lu4p.fokuslauncher.ui.util.clickableNoRippleWithSystemSound
+
+/**
+ * Now-playing widget shown below the date/battery row when audio is active.
+ * The title scrolls on the first line, the artist on a second line when present, and
+ * optional like / save plus previous / play-pause / next controls sit below.
+ *
+ * Buttons are hidden when the active app does not advertise the matching session action.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun MediaWidget(
+ title: String,
+ artist: String?,
+ isPlaying: Boolean,
+ isBuffering: Boolean = false,
+ canSkipToPrevious: Boolean,
+ canSkipToNext: Boolean,
+ like: MediaCustomActionButton? = null,
+ save: MediaCustomActionButton? = null,
+ modifier: Modifier = Modifier,
+ outlined: Boolean = false,
+ onOpenApp: () -> Unit = {},
+ onLike: () -> Unit = {},
+ onPrevious: () -> Unit = {},
+ onPlayPause: () -> Unit = {},
+ onNext: () -> Unit = {},
+ onSave: () -> Unit = {},
+) {
+ val color = MaterialTheme.colorScheme.onBackground
+ val titleStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold)
+ val artistStyle = MaterialTheme.typography.bodyMedium
+ val showArtist = !artist.isNullOrBlank() && !artist.equals(title, ignoreCase = true)
+ val iconSize = with(LocalDensity.current) { (titleStyle.fontSize * 1.5f).toDp() }
+
+ Column(modifier = modifier.testTag("media_widget")) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .clickableNoRippleWithSystemSound(onClick = onOpenApp),
+ ) {
+ MediaWidgetMarqueeText(
+ text = title,
+ style = titleStyle,
+ color = color,
+ outlined = outlined,
+ modifier = Modifier.testTag("media_title"),
+ )
+ if (showArtist) {
+ MediaWidgetMarqueeText(
+ text = artist,
+ style = artistStyle,
+ color = color.copy(alpha = 0.78f),
+ outlined = outlined,
+ modifier =
+ Modifier.testTag("media_artist").padding(top = 2.dp),
+ )
+ }
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ val previousColor = if (canSkipToPrevious) color else color.copy(alpha = 0.38f)
+ LauncherIcon(
+ imageVector = Icons.Filled.SkipPrevious,
+ contentDescription = stringResource(R.string.media_previous_track),
+ iconSize = iconSize,
+ tint = previousColor,
+ outlined = outlined,
+ modifier =
+ Modifier.testTag("media_previous")
+ .then(
+ if (canSkipToPrevious) {
+ Modifier.clickableNoRippleWithSystemSound(
+ onClick = onPrevious
+ )
+ } else {
+ Modifier
+ }
+ ),
+ )
+ LauncherIcon(
+ imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription =
+ stringResource(
+ when {
+ isBuffering -> R.string.media_buffering
+ isPlaying -> R.string.media_pause
+ else -> R.string.media_play
+ }
+ ),
+ iconSize = iconSize,
+ tint = color,
+ outlined = outlined,
+ modifier =
+ Modifier.testTag("media_play_pause")
+ .clickableNoRippleWithSystemSound(onClick = onPlayPause),
+ )
+ val nextColor = if (canSkipToNext) color else color.copy(alpha = 0.38f)
+ LauncherIcon(
+ imageVector = Icons.Filled.SkipNext,
+ contentDescription = stringResource(R.string.media_next_track),
+ iconSize = iconSize,
+ tint = nextColor,
+ outlined = outlined,
+ modifier =
+ Modifier.testTag("media_next")
+ .then(
+ if (canSkipToNext) {
+ Modifier.clickableNoRippleWithSystemSound(
+ onClick = onNext
+ )
+ } else {
+ Modifier
+ }
+ ),
+ )
+ }
+ if (like != null || save != null) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.weight(1f, fill = true),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ if (like != null) {
+ val likeTint =
+ if (like.active) color else color.copy(alpha = 0.5f)
+ LauncherIcon(
+ imageVector =
+ if (like.active) Icons.Filled.Favorite
+ else Icons.Outlined.FavoriteBorder,
+ contentDescription =
+ like.label.ifBlank {
+ stringResource(
+ if (like.active) R.string.media_unlike
+ else R.string.media_like
+ )
+ },
+ iconSize = iconSize,
+ tint = likeTint,
+ outlined = outlined,
+ modifier =
+ Modifier.testTag("media_like")
+ .clickableNoRippleWithSystemSound(onClick = onLike),
+ )
+ }
+ if (save != null) {
+ val saveTint =
+ if (save.active) MaterialTheme.colorScheme.primary
+ else color.copy(alpha = 0.5f)
+ LauncherIcon(
+ imageVector =
+ if (save.active) Icons.Filled.Bookmark
+ else Icons.Outlined.BookmarkBorder,
+ contentDescription =
+ save.label.ifBlank {
+ stringResource(
+ if (save.active) R.string.media_unsave
+ else R.string.media_save
+ )
+ },
+ iconSize = iconSize,
+ tint = saveTint,
+ outlined = outlined,
+ modifier =
+ Modifier.testTag("media_save")
+ .clickableNoRippleWithSystemSound(onClick = onSave),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun MediaWidgetMarqueeText(
+ text: String,
+ style: androidx.compose.ui.text.TextStyle,
+ color: androidx.compose.ui.graphics.Color,
+ outlined: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val marqueeModifier = modifier.fillMaxWidth().basicMarquee()
+ if (outlined) {
+ OutlinedText(
+ text = text,
+ style = style,
+ color = color,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = marqueeModifier,
+ )
+ } else {
+ Text(
+ text = text,
+ style = style,
+ color = color,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = marqueeModifier,
+ )
+ }
+}
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeScreen.kt b/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeScreen.kt
index 5c184192..592ed250 100644
--- a/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeScreen.kt
+++ b/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeScreen.kt
@@ -63,6 +63,7 @@ import com.lu4p.fokuslauncher.data.model.HomeShortcut
import com.lu4p.fokuslauncher.ui.components.ClockWidget
import com.lu4p.fokuslauncher.ui.components.DateBatteryRow
import com.lu4p.fokuslauncher.ui.components.FokusBottomSheet
+import com.lu4p.fokuslauncher.ui.components.MediaWidget
import com.lu4p.fokuslauncher.ui.components.FokusOutlinedButton
import com.lu4p.fokuslauncher.ui.components.LauncherIcon
import com.lu4p.fokuslauncher.ui.components.MinimalIcons
@@ -89,6 +90,7 @@ fun HomeScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val clockUiState by viewModel.clockUiState.collectAsStateWithLifecycle()
val weatherUiState by viewModel.weatherUiState.collectAsStateWithLifecycle()
+ val mediaUiState by viewModel.mediaUiState.collectAsStateWithLifecycle()
val favorites by viewModel.favorites.collectAsStateWithLifecycle()
val rightSideShortcuts by viewModel.rightSideShortcuts.collectAsStateWithLifecycle()
val allInstalledApps by viewModel.allInstalledApps.collectAsStateWithLifecycle()
@@ -120,6 +122,7 @@ fun HomeScreen(
viewModel.recheckDefaultLauncher()
viewModel.refreshDoubleTapLockEffective()
viewModel.refreshWeather()
+ viewModel.refreshMedia()
}
Box(modifier = modifier.fillMaxSize()) {
@@ -127,6 +130,7 @@ fun HomeScreen(
uiState = uiState,
clockUiState = clockUiState,
weatherUiState = weatherUiState,
+ mediaUiState = mediaUiState,
favorites = favorites,
installedApps = allInstalledApps,
rightSideShortcuts = rightSideShortcuts,
@@ -139,6 +143,12 @@ fun HomeScreen(
onClockClick = onClockClick,
onDateClick = onDateClick,
onWeatherClick = onWeatherClick,
+ onMediaOpenApp = viewModel::mediaOpenApp,
+ onMediaPrevious = viewModel::mediaSkipToPrevious,
+ onMediaPlayPause = viewModel::mediaPlayPause,
+ onMediaNext = viewModel::mediaSkipToNext,
+ onMediaLike = viewModel::mediaLike,
+ onMediaSave = viewModel::mediaSave,
doubleTapEmptyLockEnabled = uiState.doubleTapEmptyLockEnabled,
onDoubleTapEmptyLock = onDoubleTapEmptyLock,
)
@@ -216,6 +226,7 @@ fun HomeScreenContent(
onLabelClick: (FavoriteApp) -> Unit,
onIconClick: (HomeShortcut) -> Unit,
modifier: Modifier = Modifier,
+ mediaUiState: HomeMediaUiState = HomeMediaUiState(),
installedApps: List = emptyList(),
onLabelLongPress: (FavoriteApp) -> Unit = {},
onHomeScreenLongPress: () -> Unit = {},
@@ -223,6 +234,12 @@ fun HomeScreenContent(
onClockClick: () -> Unit = {},
onDateClick: () -> Unit = {},
onWeatherClick: () -> Unit = {},
+ onMediaOpenApp: () -> Unit = {},
+ onMediaPrevious: () -> Unit = {},
+ onMediaPlayPause: () -> Unit = {},
+ onMediaNext: () -> Unit = {},
+ onMediaLike: () -> Unit = {},
+ onMediaSave: () -> Unit = {},
doubleTapEmptyLockEnabled: Boolean = false,
onDoubleTapEmptyLock: () -> Unit = {},
) {
@@ -262,9 +279,16 @@ fun HomeScreenContent(
uiState = uiState,
clockUiState = clockUiState,
weatherUiState = weatherUiState,
+ mediaUiState = mediaUiState,
onClockClick = onClockClick,
onDateClick = onDateClick,
onWeatherClick = onWeatherClick,
+ onMediaOpenApp = onMediaOpenApp,
+ onMediaPrevious = onMediaPrevious,
+ onMediaPlayPause = onMediaPlayPause,
+ onMediaNext = onMediaNext,
+ onMediaLike = onMediaLike,
+ onMediaSave = onMediaSave,
outlined = uiState.usesPhotoWallpaper,
)
@@ -357,9 +381,16 @@ private fun HomeWidgetsSection(
uiState: HomeUiState,
clockUiState: HomeClockUiState,
weatherUiState: HomeWeatherUiState,
+ mediaUiState: HomeMediaUiState,
onClockClick: () -> Unit,
onDateClick: () -> Unit,
onWeatherClick: () -> Unit,
+ onMediaOpenApp: () -> Unit,
+ onMediaPrevious: () -> Unit,
+ onMediaPlayPause: () -> Unit,
+ onMediaNext: () -> Unit,
+ onMediaLike: () -> Unit,
+ onMediaSave: () -> Unit,
outlined: Boolean,
) {
val showClock = uiState.showHomeClock
@@ -408,6 +439,30 @@ private fun HomeWidgetsSection(
.testTag("date_battery_row"),
)
}
+
+ val playback = mediaUiState.playback
+ if (mediaUiState.showWidget && playback != null) {
+ MediaWidget(
+ title = playback.title,
+ artist = playback.artist,
+ isPlaying = playback.isPlaying,
+ isBuffering = playback.isBuffering,
+ canSkipToPrevious = playback.canSkipToPrevious,
+ canSkipToNext = playback.canSkipToNext,
+ like = playback.like,
+ save = playback.save,
+ outlined = outlined,
+ onOpenApp = onMediaOpenApp,
+ onLike = onMediaLike,
+ onPrevious = onMediaPrevious,
+ onPlayPause = onMediaPlayPause,
+ onNext = onMediaNext,
+ onSave = onMediaSave,
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(top = if (showDateOrBattery || showClock) 8.dp else 0.dp),
+ )
+ }
}
@Composable
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeViewModel.kt b/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeViewModel.kt
index 2b5f4e6f..21812fc4 100644
--- a/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/com/lu4p/fokuslauncher/ui/home/HomeViewModel.kt
@@ -43,6 +43,9 @@ import com.lu4p.fokuslauncher.data.model.WidgetTapTarget
import com.lu4p.fokuslauncher.R
import com.lu4p.fokuslauncher.data.repository.AppRepository
import com.lu4p.fokuslauncher.data.repository.WeatherRepository
+import com.lu4p.fokuslauncher.media.MediaNotificationHelper
+import com.lu4p.fokuslauncher.media.MediaPlaybackUiState
+import com.lu4p.fokuslauncher.media.MediaRepository
import com.lu4p.fokuslauncher.utils.LockScreenHelper
import com.lu4p.fokuslauncher.utils.registerBroadcastReceiverNotExported
import com.lu4p.fokuslauncher.utils.registerStickyBroadcastReceiverNotExported
@@ -109,12 +112,24 @@ data class HomeWeatherUiState(
val showWeatherWidget: Boolean = false,
)
+data class HomeMediaUiState(
+ /** User preference; the widget is opt-in and off by default. */
+ val enabled: Boolean = false,
+ /** Current now-playing session, or null when nothing is playing. */
+ val playback: MediaPlaybackUiState? = null,
+) {
+ /** The widget is only drawn when enabled and something is actually playing. */
+ val showWidget: Boolean
+ get() = enabled && playback != null
+}
+
@HiltViewModel
class HomeViewModel @Inject constructor(
@param:ApplicationContext private val context: Context,
private val appRepository: AppRepository,
private val preferencesManager: PreferencesManager,
- private val weatherRepository: WeatherRepository
+ private val weatherRepository: WeatherRepository,
+ private val mediaRepository: MediaRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@@ -130,6 +145,9 @@ class HomeViewModel @Inject constructor(
private val _weatherUiState = MutableStateFlow(HomeWeatherUiState())
val weatherUiState: StateFlow = _weatherUiState.asStateFlow()
+ private val _mediaUiState = MutableStateFlow(HomeMediaUiState())
+ val mediaUiState: StateFlow = _mediaUiState.asStateFlow()
+
/** Serializes home app-list refresh so concurrent loads cannot race and prune favorites. */
private val installedAppsRefreshMutex = Mutex()
@@ -268,6 +286,7 @@ class HomeViewModel @Inject constructor(
observeTemperatureUnit()
observeHomeWidgetItemPreferences()
observeWeatherRefreshTriggers()
+ observeMedia()
observeDoubleTapEmptyLock()
checkDefaultLauncher()
refreshInstalledApps(includeShortcuts = true)
@@ -286,6 +305,7 @@ class HomeViewModel @Inject constructor(
}
}
weatherTickerJob?.cancel()
+ mediaRepository.stop()
super.onCleared()
}
@@ -800,6 +820,39 @@ class HomeViewModel @Inject constructor(
observeFlow(preferencesManager.doubleTapEmptyLockFlow, ::recomputeDoubleTapEmptyLockUi)
}
+ // ── Media widget ────────────────────────────────────────────────
+
+ private var mediaEnabled = false
+ private fun observeMedia() {
+ observeFlow(preferencesManager.showHomeMediaFlow) { enabled ->
+ mediaEnabled = enabled && MediaNotificationHelper.isListenerEnabled(context)
+ _mediaUiState.value = _mediaUiState.value.copy(enabled = mediaEnabled)
+ mediaRepository.setWidgetEnabled(mediaEnabled)
+ }
+ observeFlow(mediaRepository.state) { playback ->
+ _mediaUiState.value = _mediaUiState.value.copy(playback = playback)
+ }
+ }
+
+ /** Re-reads active sessions on resume so newly started playback appears promptly. */
+ fun refreshMedia() {
+ if (mediaEnabled) {
+ mediaRepository.refreshNotificationSessions()
+ }
+ }
+
+ fun mediaOpenApp() = mediaRepository.openMediaApp()
+
+ fun mediaPlayPause() = mediaRepository.playPause()
+
+ fun mediaSkipToPrevious() = mediaRepository.skipToPrevious()
+
+ fun mediaSkipToNext() = mediaRepository.skipToNext()
+
+ fun mediaLike() = mediaRepository.invokeLikeAction()
+
+ fun mediaSave() = mediaRepository.invokeSaveAction()
+
private fun observeCategoryOptions() {
observeFlow(
combine(
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsScreen.kt b/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsScreen.kt
index 5565f0b6..2d3fbb15 100644
--- a/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsScreen.kt
@@ -20,6 +20,9 @@ import com.lu4p.fokuslauncher.ui.util.rememberBooleanChangeWithSystemSound
import com.lu4p.fokuslauncher.ui.util.rememberClickWithSystemSound
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
@@ -46,6 +49,7 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Translate
+import com.lu4p.fokuslauncher.media.MediaNotificationHelper
import com.lu4p.fokuslauncher.ui.components.FokusAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -851,6 +855,23 @@ fun HomeWidgetsSettingsScreen(
val (hasCoarseLocationPermission, requestCoarseLocation) =
rememberCoarseLocationPermission(context, activity)
+ var mediaNotificationAccessTick by remember { mutableIntStateOf(0) }
+ var pendingMediaEnable by remember { mutableStateOf(false) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+ OnResumeEffect(lifecycleOwner) { mediaNotificationAccessTick++ }
+ val mediaNotificationAccessEnabled =
+ remember(mediaNotificationAccessTick) {
+ MediaNotificationHelper.isListenerEnabled(context)
+ }
+ LaunchedEffect(mediaNotificationAccessTick, uiState.showHomeMedia, pendingMediaEnable) {
+ if (pendingMediaEnable && mediaNotificationAccessEnabled) {
+ pendingMediaEnable = false
+ viewModel.setShowHomeMedia(true)
+ } else if (uiState.showHomeMedia && !mediaNotificationAccessEnabled) {
+ viewModel.setShowHomeMedia(false)
+ }
+ }
+
Column(
modifier =
Modifier.fillMaxSize()
@@ -905,6 +926,31 @@ fun HomeWidgetsSettingsScreen(
onCheckedChange = onChange,
)
}
+ item {
+ SettingsToggleRow(
+ label = stringResource(R.string.settings_show_home_media),
+ subtitle =
+ if (mediaNotificationAccessEnabled) {
+ stringResource(R.string.settings_show_home_media_subtitle)
+ } else {
+ stringResource(R.string.settings_show_home_media_subtitle_grant_access)
+ },
+ checked = uiState.showHomeMedia,
+ onCheckedChange = { checked ->
+ if (checked) {
+ if (mediaNotificationAccessEnabled) {
+ viewModel.setShowHomeMedia(true)
+ } else {
+ pendingMediaEnable = true
+ MediaNotificationHelper.openListenerSettings(context)
+ }
+ } else {
+ pendingMediaEnable = false
+ viewModel.setShowHomeMedia(false)
+ }
+ },
+ )
+ }
item { SettingsDivider() }
item {
WeatherAppSettingRow(
diff --git a/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsViewModel.kt
index 350d8804..444c31fe 100644
--- a/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/lu4p/fokuslauncher/ui/settings/SettingsViewModel.kt
@@ -84,6 +84,7 @@ data class SettingsUiState(
val showHomeDate: Boolean = true,
val showHomeWeather: Boolean = true,
val showHomeBattery: Boolean = true,
+ val showHomeMedia: Boolean = false,
val homeDateFormatStyle: HomeDateFormatStyle = HomeDateFormatStyle.SYSTEM_DEFAULT,
val temperatureUnit: TemperatureUnit = TemperatureUnit.SYSTEM_DEFAULT,
/** Vertical category sidebar in the app drawer. */
@@ -230,19 +231,25 @@ constructor(
suppressedCategories = suppressed,
)
}
- val homeWidgetItemsFlow =
+ val visibilityAndMediaFlow =
combine(
preferencesManager.homeWidgetVisibilityFlow,
+ preferencesManager.showHomeMediaFlow,
+ ) { vis, showMedia -> vis to showMedia }
+ val homeWidgetItemsFlow =
+ combine(
+ visibilityAndMediaFlow,
preferencesManager.preferredClockTapFlow,
preferencesManager.preferredCalendarTapFlow,
preferencesManager.homeDateFormatStyleFlow,
preferencesManager.temperatureUnitFlow,
- ) { vis, clk, cal, fmt, tempUnit ->
+ ) { (vis, showMedia), clk, cal, fmt, tempUnit ->
HomeWidgetItemSettings(
showClock = vis.showClock,
showDate = vis.showDate,
showWeather = vis.showWeather,
showBattery = vis.showBattery,
+ showMedia = showMedia,
preferredClockTap = clk,
preferredCalendarTap = cal,
homeDateFormatStyle = fmt,
@@ -427,6 +434,7 @@ constructor(
showHomeDate = homeWidgetItems.showDate,
showHomeWeather = homeWidgetItems.showWeather,
showHomeBattery = homeWidgetItems.showBattery,
+ showHomeMedia = homeWidgetItems.showMedia,
homeDateFormatStyle = homeWidgetItems.homeDateFormatStyle,
temperatureUnit = homeWidgetItems.temperatureUnit,
drawerSidebarCategories = drawer.drawerSidebarCategories,
@@ -465,6 +473,7 @@ constructor(
val showDate: Boolean,
val showWeather: Boolean,
val showBattery: Boolean,
+ val showMedia: Boolean,
val preferredClockTap: WidgetTapTarget?,
val preferredCalendarTap: WidgetTapTarget?,
val homeDateFormatStyle: HomeDateFormatStyle,
@@ -771,6 +780,8 @@ constructor(
fun setShowHomeBattery(show: Boolean) = launchPreferences { setShowHomeBattery(show) }
+ fun setShowHomeMedia(show: Boolean) = launchPreferences { setShowHomeMedia(show) }
+
fun setHomeDateFormatStyle(style: HomeDateFormatStyle) =
launchPreferences { setHomeDateFormatStyle(style) }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ee136710..a684c278 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -194,6 +194,20 @@
Fahrenheit (°F)
Show weather
Show battery level
+ Show media controls
+ Now playing with transport controls when audio is active
+ Requires notification access — turn on to grant in system settings
+ Fokus now playing
+
+ Previous track
+ Play
+ Pause
+ Buffering
+ Next track
+ Like
+ Unlike
+ Save to library
+ Remove from library
Clock app
Calendar app
Double tap to lock
diff --git a/app/src/test/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReaderTest.kt b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReaderTest.kt
new file mode 100644
index 00000000..78baff89
--- /dev/null
+++ b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReaderTest.kt
@@ -0,0 +1,106 @@
+package com.lu4p.fokuslauncher.media
+
+import android.os.Bundle
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.RatingCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class MediaCustomActionsReaderTest {
+
+ @Test
+ fun likeButtonMatchesHeartAction() {
+ val state =
+ PlaybackStateCompat.Builder()
+ .addCustomAction("com.spotify.heart", "Like", 0)
+ .build()
+
+ val like = MediaCustomActionsReader.likeButton(state)
+ assertEquals("com.spotify.heart", like?.actionId)
+ assertFalse(like?.active == true)
+ }
+
+ @Test
+ fun saveButtonMatchesLibraryAction() {
+ val state =
+ PlaybackStateCompat.Builder()
+ .addCustomAction("ADD_TO_LIBRARY", "Add to library", 0)
+ .build()
+
+ val save = MediaCustomActionsReader.saveButton(state)
+ assertEquals("ADD_TO_LIBRARY", save?.actionId)
+ assertFalse(save?.active == true)
+ }
+
+ @Test
+ fun likedSongsActionMapsToLikeNotSave() {
+ val state =
+ PlaybackStateCompat.Builder()
+ .addCustomAction("add_to_liked_songs", "Add to Liked Songs", 0)
+ .build()
+
+ val like = MediaCustomActionsReader.likeButton(state)
+ assertEquals("add_to_liked_songs", like?.actionId)
+ assertFalse(like?.active == true)
+ assertNull(MediaCustomActionsReader.saveButton(state))
+ }
+
+ @Test
+ fun removeFromLikedSongsIsActiveLike() {
+ val state =
+ PlaybackStateCompat.Builder()
+ .addCustomAction("remove_from_liked_songs", "Remove from Liked Songs", 0)
+ .build()
+
+ val like = MediaCustomActionsReader.likeButton(state)
+ assertTrue(like?.active == true)
+ }
+
+ @Test
+ fun activeSaveDetectedFromRemoveLabel() {
+ val state =
+ PlaybackStateCompat.Builder()
+ .addCustomAction("REMOVE_FROM_LIBRARY", "Remove from library", 0)
+ .build()
+
+ val save = MediaCustomActionsReader.saveButton(state)
+ assertTrue(save?.active == true)
+ }
+
+ @Test
+ fun activeLikeDetectedFromExtras() {
+ val extras = Bundle().apply { putBoolean("liked", true) }
+ val customAction =
+ PlaybackStateCompat.CustomAction.Builder("heart", "Like", android.R.drawable.btn_star)
+ .setExtras(extras)
+ .build()
+ val state =
+ PlaybackStateCompat.Builder().addCustomAction(customAction).build()
+
+ assertTrue(MediaCustomActionsReader.likeButton(state)?.active == true)
+ }
+
+ @Test
+ fun likedStateFromMetadataRating() {
+ val metadata =
+ MediaMetadataCompat.Builder()
+ .putRating(
+ MediaMetadataCompat.METADATA_KEY_USER_RATING,
+ RatingCompat.newHeartRating(true),
+ )
+ .build()
+ val state =
+ PlaybackStateCompat.Builder()
+ .addCustomAction("heart", "Like", 0)
+ .build()
+
+ assertTrue(MediaCustomActionsReader.likeButton(state, metadata)?.active == true)
+ }
+}
diff --git a/app/src/test/java/com/lu4p/fokuslauncher/media/MediaMetadataTest.kt b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaMetadataTest.kt
new file mode 100644
index 00000000..29688aa9
--- /dev/null
+++ b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaMetadataTest.kt
@@ -0,0 +1,62 @@
+package com.lu4p.fokuslauncher.media
+
+import android.support.v4.media.MediaMetadataCompat
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class MediaMetadataTest {
+
+ @Test
+ fun artistNamePrefersArtistKey() {
+ val metadata =
+ MediaMetadataCompat.Builder()
+ .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "Artist A")
+ .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, "Subtitle B")
+ .build()
+
+ assertEquals("Artist A", MediaMetadataReader.artistName(metadata))
+ }
+
+ @Test
+ fun artistNameFallsBackToDisplaySubtitle() {
+ val metadata =
+ MediaMetadataCompat.Builder()
+ .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, "Artist B")
+ .build()
+
+ assertEquals("Artist B", MediaMetadataReader.artistName(metadata))
+ }
+
+ @Test
+ fun artistNameReadsCharSequenceMetadata() {
+ val metadata =
+ MediaMetadataCompat.Builder()
+ .putText(MediaMetadataCompat.METADATA_KEY_TITLE, "Song Title")
+ .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, "Spotify Artist")
+ .build()
+
+ assertEquals("Song Title", MediaMetadataReader.trackTitle(metadata))
+ assertEquals("Spotify Artist", MediaMetadataReader.artistName(metadata))
+ }
+
+ @Test
+ fun artistNameUsesDescriptionSubtitle() {
+ val metadata =
+ MediaMetadataCompat.Builder()
+ .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, "Song Title")
+ .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, "Artist Name")
+ .build()
+
+ assertEquals("Song Title", MediaMetadataReader.trackTitle(metadata))
+ assertEquals("Artist Name", MediaMetadataReader.artistName(metadata))
+ }
+
+ @Test
+ fun artistNameReturnsNullWhenMissing() {
+ assertNull(MediaMetadataReader.artistName(MediaMetadataCompat.Builder().build()))
+ }
+}
diff --git a/app/src/test/java/com/lu4p/fokuslauncher/media/MediaPlaybackStateTest.kt b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaPlaybackStateTest.kt
new file mode 100644
index 00000000..fbb618a6
--- /dev/null
+++ b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaPlaybackStateTest.kt
@@ -0,0 +1,29 @@
+package com.lu4p.fokuslauncher.media
+
+import android.support.v4.media.session.PlaybackStateCompat
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaPlaybackStateTest {
+
+ @Test
+ fun activelyPlayingIncludesPlayingAndBuffering() {
+ assertTrue(MediaPlaybackState.isActivelyPlaying(PlaybackStateCompat.STATE_PLAYING))
+ assertTrue(MediaPlaybackState.isActivelyPlaying(PlaybackStateCompat.STATE_BUFFERING))
+ assertFalse(MediaPlaybackState.isActivelyPlaying(PlaybackStateCompat.STATE_PAUSED))
+ }
+
+ @Test
+ fun bufferingDetectedSeparately() {
+ assertTrue(MediaPlaybackState.isBuffering(PlaybackStateCompat.STATE_BUFFERING))
+ assertFalse(MediaPlaybackState.isBuffering(PlaybackStateCompat.STATE_PLAYING))
+ }
+
+ @Test
+ fun showableIncludesBufferingAndPaused() {
+ assertTrue(MediaPlaybackState.isShowable(PlaybackStateCompat.STATE_BUFFERING))
+ assertTrue(MediaPlaybackState.isShowable(PlaybackStateCompat.STATE_PAUSED))
+ assertFalse(MediaPlaybackState.isShowable(PlaybackStateCompat.STATE_STOPPED))
+ }
+}
diff --git a/app/src/test/java/com/lu4p/fokuslauncher/ui/home/HomeViewModelTest.kt b/app/src/test/java/com/lu4p/fokuslauncher/ui/home/HomeViewModelTest.kt
index a1d46ea6..dd2bd154 100644
--- a/app/src/test/java/com/lu4p/fokuslauncher/ui/home/HomeViewModelTest.kt
+++ b/app/src/test/java/com/lu4p/fokuslauncher/ui/home/HomeViewModelTest.kt
@@ -27,6 +27,7 @@ import com.lu4p.fokuslauncher.data.model.favoriteAppStableKey
import com.lu4p.fokuslauncher.data.repository.AppRepository
import com.lu4p.fokuslauncher.data.repository.RemovedApp
import com.lu4p.fokuslauncher.data.repository.WeatherRepository
+import com.lu4p.fokuslauncher.media.MediaRepository
import com.lu4p.fokuslauncher.utils.LockScreenHelper
import io.mockk.coEvery
import io.mockk.coVerify
@@ -39,6 +40,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -67,6 +69,7 @@ class HomeViewModelTest {
private lateinit var appRepository: AppRepository
private lateinit var preferencesManager: PreferencesManager
private lateinit var weatherRepository: WeatherRepository
+ private lateinit var mediaRepository: MediaRepository
private lateinit var removedPackages: MutableSharedFlow
private val testDispatcher = StandardTestDispatcher()
private var originalLocale: Locale = Locale.getDefault()
@@ -88,6 +91,8 @@ class HomeViewModelTest {
appRepository = mockk(relaxed = true)
preferencesManager = mockk(relaxed = true)
weatherRepository = mockk(relaxed = true)
+ mediaRepository = mockk(relaxed = true)
+ every { mediaRepository.state } returns MutableStateFlow(null)
removedPackages = MutableSharedFlow(extraBufferCapacity = 1)
// Mock battery intent
@@ -114,6 +119,7 @@ class HomeViewModelTest {
every { preferencesManager.showHomeDateFlow } returns flowOf(true)
every { preferencesManager.showHomeWeatherFlow } returns flowOf(true)
every { preferencesManager.showHomeBatteryFlow } returns flowOf(true)
+ every { preferencesManager.showHomeMediaFlow } returns flowOf(false)
every { preferencesManager.homeDateFormatStyleFlow } returns
flowOf(HomeDateFormatStyle.SYSTEM_DEFAULT)
every { preferencesManager.doubleTapEmptyLockFlow } returns flowOf(false)
@@ -136,11 +142,11 @@ class HomeViewModelTest {
}
private fun createViewModel() = HomeViewModel(
- context, appRepository, preferencesManager, weatherRepository
+ context, appRepository, preferencesManager, weatherRepository, mediaRepository
)
private fun createViewModel(withContext: Context) = HomeViewModel(
- withContext, appRepository, preferencesManager, weatherRepository
+ withContext, appRepository, preferencesManager, weatherRepository, mediaRepository
)
/**
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 58aa863c..4df3f758 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,6 +28,7 @@ robolectric = "4.16.1"
# Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-media = { group = "androidx.media", name = "media", version = "1.7.0" }
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# Compose (versions managed by BOM)