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)