From e38df7165aee69d49c3ae5259f61e8ebf271f69b Mon Sep 17 00:00:00 2001 From: mesmerverse Date: Sun, 28 Jun 2026 15:37:13 -0400 Subject: [PATCH 1/2] Add now-playing media widget for registered apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in, two-line media widget below the home date/battery row that shows the current track (scrolling title — artist) and rewind 15s / play-pause / forward 30s controls. Tapping the title opens the playing app. Reads and controls playback by connecting to user-registered apps via MediaBrowserCompat, so it needs no notification-access or other special permission — apps that allow outside connections appear in a picker under Settings → Configure widgets and are connected on demand. - media/MediaRepository: discovers MediaBrowserService apps, manages per-app browser/controller connections, exposes now-playing state and transport controls (play/pause, seek ±, open app). - ui/components/MediaWidget: the two-line Compose widget. - Wires media state/controls through Home and Settings ViewModels; adds a show-media preference and a registered-apps set. - Grants the background-activity-start mode when opening via the session's PendingIntent so it works on Android 14+. - Auto-hides a session that stays paused past a 60s grace period, so apps that leave a paused session alive after closing don't keep the widget up. - Unit tests for the seek-offset math. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 4 + .../data/local/PreferencesManager.kt | 13 + .../fokuslauncher/media/MediaRepository.kt | 300 ++++++++++++++++++ .../ui/components/MediaWidget.kt | 135 ++++++++ .../lu4p/fokuslauncher/ui/home/HomeScreen.kt | 41 +++ .../fokuslauncher/ui/home/HomeViewModel.kt | 56 +++- .../ui/settings/SettingsScreen.kt | 98 ++++++ .../ui/settings/SettingsViewModel.kt | 31 +- app/src/main/res/values/strings.xml | 15 + .../lu4p/fokuslauncher/media/MediaSeekTest.kt | 31 ++ .../ui/home/HomeViewModelTest.kt | 11 +- gradle/libs.versions.toml | 1 + 13 files changed, 732 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/lu4p/fokuslauncher/media/MediaRepository.kt create mode 100644 app/src/main/java/com/lu4p/fokuslauncher/ui/components/MediaWidget.kt create mode 100644 app/src/test/java/com/lu4p/fokuslauncher/media/MediaSeekTest.kt 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..c6192308 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,10 @@ + + + + = 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 registeredMediaAppsFlow: Flow> = + prefFlow(REGISTERED_MEDIA_APPS_KEY, emptySet()) + suspend fun setRegisteredMediaApps(packages: Set) = + setPref(REGISTERED_MEDIA_APPS_KEY, packages) + val homeWidgetVisibilityFlow: Flow = combine( showHomeClockFlow, 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..8cc8c71c --- /dev/null +++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaRepository.kt @@ -0,0 +1,300 @@ +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.os.Handler +import android.os.Build +import android.os.Looper +import android.os.SystemClock +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat +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 + +/** An installed app that exposes a MediaBrowserService we can attempt to connect to. */ +data class MediaAppInfo(val packageName: String, val label: String) + +/** Now-playing snapshot for the home media widget; null when nothing is actively playing. */ +data class MediaPlaybackUiState( + val title: String, + val artist: String?, + val isPlaying: Boolean, + /** False when the active app does not advertise [PlaybackStateCompat.ACTION_SEEK_TO]. */ + val canSeek: Boolean, +) + +/** + * Surfaces the now-playing session for user-registered media apps and forwards transport controls + * to them. Unlike a notification listener, this connects directly to each app's MediaBrowserService + * via [MediaBrowserCompat], so it needs no special permission — but an app only appears if it allows + * outside connections (its `onGetRoot` accepts us). Apps that whitelist only system callers (some + * mainstream players) simply never connect, which is why registration is per-app and opt-in. + * + * 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() + + /** Live connections keyed by package name. */ + private val connections = LinkedHashMap() + + /** 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 val hideRunnable = Runnable { + hideScheduled = false + pausedGraceExpired = true + publishState() + } + + /** Installed apps advertising a MediaBrowserService, for the registration picker. */ + fun discoverMediaApps(): List { + val pm = context.packageManager + return pm.queryIntentServices(Intent(SERVICE_INTERFACE), 0) + .mapNotNull { it.serviceInfo } + .filter { it.packageName != context.packageName } + .distinctBy { it.packageName } + .map { MediaAppInfo(it.packageName, it.loadLabel(pm).toString()) } + .sortedBy { it.label.lowercase() } + } + + /** Reconcile live connections with the registered set: drop removed apps, connect new ones. */ + @MainThread + fun setRegisteredApps(packages: Set) { + (connections.keys - packages).toList().forEach { disconnect(it) } + (packages - connections.keys).forEach { connect(it) } + publishState() + } + + @MainThread + fun stop() { + connections.keys.toList().forEach { disconnect(it) } + resetPauseGrace() + _state.value = null + } + + @MainThread fun playPause() { + val controller = activeController() ?: return + if (controller.playbackState?.state == PlaybackStateCompat.STATE_PLAYING) { + 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 controller = activeController() ?: return + controller.sessionActivity?.let { pending -> + try { + // Android 14+ drops a sent PendingIntent's activity start unless the sender grants + // it, even from the foreground; opt in so the app's now-playing screen opens. + 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. + } + } + val launch = + context.packageManager.getLaunchIntentForPackage(controller.packageName) ?: return + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(launch) + } catch (_: Exception) {} + } + + @MainThread fun rewind() = seekBy(-REWIND_MS) + + @MainThread fun forward() = seekBy(FORWARD_MS) + + private fun seekBy(deltaMs: Long) { + val controller = activeController() ?: return + val playbackState = controller.playbackState ?: return + if (playbackState.actions and PlaybackStateCompat.ACTION_SEEK_TO == 0L) return + controller.transportControls.seekTo(seekTarget(playbackState.currentPosition(), deltaMs)) + } + + private fun connect(packageName: String) { + val component = resolveServiceComponent(packageName) ?: return + val connection = AppConnection(packageName) + connections[packageName] = connection + val browser = MediaBrowserCompat(context, component, connection.browserCallback, null) + connection.browser = browser + try { + browser.connect() + } catch (_: IllegalStateException) { + // Already connecting/connected. + } + } + + private fun disconnect(packageName: String) { + connections.remove(packageName)?.release() + } + + private fun resolveServiceComponent(packageName: String): ComponentName? { + val intent = Intent(SERVICE_INTERFACE).setPackage(packageName) + val service = + context.packageManager.queryIntentServices(intent, 0).firstOrNull()?.serviceInfo + ?: return null + return ComponentName(service.packageName, service.name) + } + + /** Active = a connected controller, preferring one that is playing, then most recently updated. */ + private fun activeController(): MediaControllerCompat? = + connections.values + .mapNotNull { it.controller } + .filter { it.playbackState.isShowable() } + .maxWithOrNull( + compareBy( + { if (it.playbackState?.state == PlaybackStateCompat.STATE_PLAYING) 1 else 0 }, + { it.playbackState?.lastPositionUpdateTime ?: 0L }, + ) + ) + + private fun publishState() { + val controller = activeController() + val metadata = controller?.metadata + val playbackState = controller?.playbackState + val title = + metadata?.getString(MediaMetadataCompat.METADATA_KEY_TITLE) + ?: metadata?.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE) + if (controller == null || playbackState == null || title.isNullOrBlank()) { + resetPauseGrace() + _state.value = null + return + } + val isPlaying = playbackState.state == PlaybackStateCompat.STATE_PLAYING + if (isPlaying) { + // Playing again cancels any pending hide and clears the expired flag. + resetPauseGrace() + } else if (pausedGraceExpired) { + // Still paused after the grace period: keep the stale session hidden. + _state.value = null + return + } else { + schedulePauseHide() + } + _state.value = + MediaPlaybackUiState( + title = title, + artist = metadata?.getString(MediaMetadataCompat.METADATA_KEY_ARTIST), + isPlaying = isPlaying, + canSeek = playbackState.actions and PlaybackStateCompat.ACTION_SEEK_TO != 0L, + ) + } + + 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) + } + + /** One app's browser + controller pair, with callbacks that republish on any change. */ + private inner class AppConnection(val packageName: String) { + var browser: MediaBrowserCompat? = null + var controller: MediaControllerCompat? = null + + val browserCallback = + object : MediaBrowserCompat.ConnectionCallback() { + override fun onConnected() { + val token = browser?.sessionToken ?: return + val ctrl = MediaControllerCompat(context, token) + controller = ctrl + ctrl.registerCallback(controllerCallback, mainHandler) + publishState() + } + + override fun onConnectionSuspended() { + detachController() + publishState() + } + + override fun onConnectionFailed() { + // The app refused our connection; leave it disconnected. + } + } + + private val controllerCallback = + object : MediaControllerCompat.Callback() { + override fun onPlaybackStateChanged(state: PlaybackStateCompat?) = publishState() + override fun onMetadataChanged(metadata: MediaMetadataCompat?) = publishState() + override fun onSessionDestroyed() { + detachController() + publishState() + } + } + + private fun detachController() { + controller?.unregisterCallback(controllerCallback) + controller = null + } + + fun release() { + detachController() + try { + browser?.disconnect() + } catch (_: Exception) {} + browser = null + } + } + + private fun PlaybackStateCompat?.isShowable(): Boolean = + when (this?.state) { + null, + PlaybackStateCompat.STATE_NONE, + PlaybackStateCompat.STATE_STOPPED, + PlaybackStateCompat.STATE_ERROR -> false + else -> true + } + + private fun PlaybackStateCompat.currentPosition(): Long { + if (state != PlaybackStateCompat.STATE_PLAYING) return position + val elapsed = SystemClock.elapsedRealtime() - lastPositionUpdateTime + return position + (elapsed * playbackSpeed).toLong() + } + + companion object { + const val REWIND_MS = 15_000L + const val FORWARD_MS = 30_000L + /** 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 + private const val SERVICE_INTERFACE = "android.media.browse.MediaBrowserService" + + /** Seek destination clamped to the start of the track; extracted for unit testing. */ + fun seekTarget(currentPosition: Long, deltaMs: Long): Long = + (currentPosition + deltaMs).coerceAtLeast(0L) + } +} 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..73504ace --- /dev/null +++ b/app/src/main/java/com/lu4p/fokuslauncher/ui/components/MediaWidget.kt @@ -0,0 +1,135 @@ +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +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.unit.dp +import com.lu4p.fokuslauncher.R +import com.lu4p.fokuslauncher.ui.util.clickableNoRippleWithSystemSound + +/** + * Two-line now-playing widget shown below the date/battery row when audio is active. + * Top line scrolls the track title (and artist when present); the bottom line offers + * rewind 15s / play-pause / forward 30s controls for the active media session. + * + * The seek buttons are dimmed and inert when [canSeek] is false (e.g. live streams). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MediaWidget( + title: String, + artist: String?, + isPlaying: Boolean, + canSeek: Boolean, + modifier: Modifier = Modifier, + outlined: Boolean = false, + onOpenApp: () -> Unit = {}, + onRewind: () -> Unit = {}, + onPlayPause: () -> Unit = {}, + onForward: () -> Unit = {}, +) { + val color = MaterialTheme.colorScheme.onBackground + val titleStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + val nowPlaying = if (artist.isNullOrBlank()) title else "$title — $artist" + val iconSize = with(LocalDensity.current) { (titleStyle.fontSize * 1.5f).toDp() } + + Column(modifier = modifier.testTag("media_widget")) { + // Tapping the title opens the playing app (its now-playing screen when offered). + val titleModifier = + Modifier.testTag("media_now_playing") + .fillMaxWidth() + .clickableNoRippleWithSystemSound(onClick = onOpenApp) + .basicMarquee() + if (outlined) { + OutlinedText( + text = nowPlaying, + style = titleStyle, + color = color, + maxLines = 1, + modifier = titleModifier, + ) + } else { + Text( + text = nowPlaying, + style = titleStyle, + color = color, + maxLines = 1, + modifier = titleModifier, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + val seekColor = if (canSeek) color else color.copy(alpha = 0.38f) + LauncherIcon( + imageVector = Icons.Filled.FastRewind, + contentDescription = stringResource(R.string.media_rewind_15), + iconSize = iconSize, + tint = seekColor, + outlined = outlined, + modifier = + Modifier.testTag("media_rewind") + .then( + if (canSeek) { + Modifier.clickableNoRippleWithSystemSound( + onClick = onRewind + ) + } else { + Modifier + } + ), + ) + LauncherIcon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = + stringResource( + if (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), + ) + LauncherIcon( + imageVector = Icons.Filled.FastForward, + contentDescription = stringResource(R.string.media_forward_30), + iconSize = iconSize, + tint = seekColor, + outlined = outlined, + modifier = + Modifier.testTag("media_forward") + .then( + if (canSeek) { + Modifier.clickableNoRippleWithSystemSound( + onClick = onForward + ) + } else { + Modifier + } + ), + ) + } + } +} 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..6a7cd4c2 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,10 @@ fun HomeScreen( onClockClick = onClockClick, onDateClick = onDateClick, onWeatherClick = onWeatherClick, + onMediaOpenApp = viewModel::mediaOpenApp, + onMediaRewind = viewModel::mediaRewind, + onMediaPlayPause = viewModel::mediaPlayPause, + onMediaForward = viewModel::mediaForward, doubleTapEmptyLockEnabled = uiState.doubleTapEmptyLockEnabled, onDoubleTapEmptyLock = onDoubleTapEmptyLock, ) @@ -216,6 +224,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 +232,10 @@ fun HomeScreenContent( onClockClick: () -> Unit = {}, onDateClick: () -> Unit = {}, onWeatherClick: () -> Unit = {}, + onMediaOpenApp: () -> Unit = {}, + onMediaRewind: () -> Unit = {}, + onMediaPlayPause: () -> Unit = {}, + onMediaForward: () -> Unit = {}, doubleTapEmptyLockEnabled: Boolean = false, onDoubleTapEmptyLock: () -> Unit = {}, ) { @@ -262,9 +275,14 @@ fun HomeScreenContent( uiState = uiState, clockUiState = clockUiState, weatherUiState = weatherUiState, + mediaUiState = mediaUiState, onClockClick = onClockClick, onDateClick = onDateClick, onWeatherClick = onWeatherClick, + onMediaOpenApp = onMediaOpenApp, + onMediaRewind = onMediaRewind, + onMediaPlayPause = onMediaPlayPause, + onMediaForward = onMediaForward, outlined = uiState.usesPhotoWallpaper, ) @@ -357,9 +375,14 @@ private fun HomeWidgetsSection( uiState: HomeUiState, clockUiState: HomeClockUiState, weatherUiState: HomeWeatherUiState, + mediaUiState: HomeMediaUiState, onClockClick: () -> Unit, onDateClick: () -> Unit, onWeatherClick: () -> Unit, + onMediaOpenApp: () -> Unit, + onMediaRewind: () -> Unit, + onMediaPlayPause: () -> Unit, + onMediaForward: () -> Unit, outlined: Boolean, ) { val showClock = uiState.showHomeClock @@ -408,6 +431,24 @@ 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, + canSeek = playback.canSeek, + outlined = outlined, + onOpenApp = onMediaOpenApp, + onRewind = onMediaRewind, + onPlayPause = onMediaPlayPause, + onForward = onMediaForward, + 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..c7ce6370 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,8 @@ 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.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 +111,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 +144,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 +285,7 @@ class HomeViewModel @Inject constructor( observeTemperatureUnit() observeHomeWidgetItemPreferences() observeWeatherRefreshTriggers() + observeMedia() observeDoubleTapEmptyLock() checkDefaultLauncher() refreshInstalledApps(includeShortcuts = true) @@ -286,6 +304,7 @@ class HomeViewModel @Inject constructor( } } weatherTickerJob?.cancel() + mediaRepository.stop() super.onCleared() } @@ -800,6 +819,41 @@ class HomeViewModel @Inject constructor( observeFlow(preferencesManager.doubleTapEmptyLockFlow, ::recomputeDoubleTapEmptyLockUi) } + // ── Media widget ──────────────────────────────────────────────── + + private var mediaEnabled = false + private var registeredMediaApps: Set = emptySet() + + private fun observeMedia() { + observeFlow( + combine( + preferencesManager.showHomeMediaFlow, + preferencesManager.registeredMediaAppsFlow, + ) { enabled, apps -> enabled to apps } + ) { (enabled, apps) -> + mediaEnabled = enabled + registeredMediaApps = apps + _mediaUiState.value = _mediaUiState.value.copy(enabled = enabled) + if (enabled) mediaRepository.setRegisteredApps(apps) else mediaRepository.stop() + } + observeFlow(mediaRepository.state) { playback -> + _mediaUiState.value = _mediaUiState.value.copy(playback = playback) + } + } + + /** Re-applies registered apps on resume so newly added apps connect promptly. */ + fun refreshMedia() { + if (mediaEnabled) mediaRepository.setRegisteredApps(registeredMediaApps) + } + + fun mediaOpenApp() = mediaRepository.openMediaApp() + + fun mediaPlayPause() = mediaRepository.playPause() + + fun mediaRewind() = mediaRepository.rewind() + + fun mediaForward() = mediaRepository.forward() + 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..af95cd25 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.MediaAppInfo import com.lu4p.fokuslauncher.ui.components.FokusAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -851,6 +855,13 @@ fun HomeWidgetsSettingsScreen( val (hasCoarseLocationPermission, requestCoarseLocation) = rememberCoarseLocationPermission(context, activity) + var showMediaAppPicker by remember { mutableStateOf(false) } + // Discovery touches PackageManager, so only run it while the picker is open. + val discoveredMediaApps = + remember(showMediaAppPicker) { + if (showMediaAppPicker) viewModel.discoverMediaApps() else emptyList() + } + Column( modifier = Modifier.fillMaxSize() @@ -905,6 +916,33 @@ fun HomeWidgetsSettingsScreen( onCheckedChange = onChange, ) } + item { + SettingsToggleRow( + label = stringResource(R.string.settings_show_home_media), + subtitle = stringResource(R.string.settings_show_home_media_subtitle), + checked = uiState.showHomeMedia, + onCheckedChange = viewModel::setShowHomeMedia, + ) + } + if (uiState.showHomeMedia) { + item { + val count = uiState.registeredMediaApps.size + SettingsRow( + label = stringResource(R.string.settings_media_apps), + subtitle = + if (count == 0) { + stringResource(R.string.settings_media_apps_none) + } else { + pluralStringResource( + R.plurals.settings_media_apps_count, + count, + count, + ) + }, + onClick = { showMediaAppPicker = true }, + ) + } + } item { SettingsDivider() } item { WeatherAppSettingRow( @@ -971,6 +1009,66 @@ fun HomeWidgetsSettingsScreen( profileDisplayNameOverrides = uiState.profileDisplayNameOverrides, ) } + + if (showMediaAppPicker) { + MediaAppsPickerDialog( + apps = discoveredMediaApps, + registered = uiState.registeredMediaApps, + onToggle = { packageName, checked -> + val next = + if (checked) uiState.registeredMediaApps + packageName + else uiState.registeredMediaApps - packageName + viewModel.setRegisteredMediaApps(next) + }, + onDismiss = { showMediaAppPicker = false }, + ) + } +} + +/** Multi-select checklist of installed media apps the widget can connect to. */ +@Composable +private fun MediaAppsPickerDialog( + apps: List, + registered: Set, + onToggle: (String, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + FokusAlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(R.string.settings_media_apps_picker_title), + color = MaterialTheme.colorScheme.onBackground, + ) + }, + text = { + if (apps.isEmpty()) { + Text( + stringResource(R.string.settings_media_apps_empty), + color = MaterialTheme.colorScheme.onBackground, + ) + } else { + Column( + modifier = + Modifier.heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()) + ) { + apps.forEach { app -> + SettingsToggleRow( + label = app.label, + checked = app.packageName in registered, + onCheckedChange = { onToggle(app.packageName, it) }, + ) + } + } + } + }, + dismissButton = { + FokusTextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_done)) + } + }, + ) } @OptIn(ExperimentalMaterial3Api::class) 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..7bf98a38 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 @@ -33,6 +33,8 @@ import com.lu4p.fokuslauncher.data.model.ShortcutTarget import com.lu4p.fokuslauncher.data.model.WidgetTapTarget import com.lu4p.fokuslauncher.data.repository.AppRepository import com.lu4p.fokuslauncher.data.util.AppLocaleHelper +import com.lu4p.fokuslauncher.media.MediaAppInfo +import com.lu4p.fokuslauncher.media.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel import android.app.WallpaperManager import android.content.ClipData @@ -84,6 +86,8 @@ data class SettingsUiState( val showHomeDate: Boolean = true, val showHomeWeather: Boolean = true, val showHomeBattery: Boolean = true, + val showHomeMedia: Boolean = false, + val registeredMediaApps: Set = emptySet(), val homeDateFormatStyle: HomeDateFormatStyle = HomeDateFormatStyle.SYSTEM_DEFAULT, val temperatureUnit: TemperatureUnit = TemperatureUnit.SYSTEM_DEFAULT, /** Vertical category sidebar in the app drawer. */ @@ -157,6 +161,7 @@ constructor( private val preferencesManager: PreferencesManager, private val privateSpaceManager: PrivateSpaceManager, private val customFontStore: CustomFontStore, + private val mediaRepository: MediaRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -230,19 +235,29 @@ constructor( suppressedCategories = suppressed, ) } - val homeWidgetItemsFlow = + // Media visibility and registered apps are bundled with the widget-visibility flow to + // stay within the five-argument combine; all feed the same HomeWidgetItemSettings. + val visibilityAndMediaFlow = combine( preferencesManager.homeWidgetVisibilityFlow, + preferencesManager.showHomeMediaFlow, + preferencesManager.registeredMediaAppsFlow, + ) { vis, showMedia, apps -> Triple(vis, showMedia, apps) } + val homeWidgetItemsFlow = + combine( + visibilityAndMediaFlow, preferencesManager.preferredClockTapFlow, preferencesManager.preferredCalendarTapFlow, preferencesManager.homeDateFormatStyleFlow, preferencesManager.temperatureUnitFlow, - ) { vis, clk, cal, fmt, tempUnit -> + ) { (vis, showMedia, mediaApps), clk, cal, fmt, tempUnit -> HomeWidgetItemSettings( showClock = vis.showClock, showDate = vis.showDate, showWeather = vis.showWeather, showBattery = vis.showBattery, + showMedia = showMedia, + registeredMediaApps = mediaApps, preferredClockTap = clk, preferredCalendarTap = cal, homeDateFormatStyle = fmt, @@ -427,6 +442,8 @@ constructor( showHomeDate = homeWidgetItems.showDate, showHomeWeather = homeWidgetItems.showWeather, showHomeBattery = homeWidgetItems.showBattery, + showHomeMedia = homeWidgetItems.showMedia, + registeredMediaApps = homeWidgetItems.registeredMediaApps, homeDateFormatStyle = homeWidgetItems.homeDateFormatStyle, temperatureUnit = homeWidgetItems.temperatureUnit, drawerSidebarCategories = drawer.drawerSidebarCategories, @@ -465,6 +482,8 @@ constructor( val showDate: Boolean, val showWeather: Boolean, val showBattery: Boolean, + val showMedia: Boolean, + val registeredMediaApps: Set, val preferredClockTap: WidgetTapTarget?, val preferredCalendarTap: WidgetTapTarget?, val homeDateFormatStyle: HomeDateFormatStyle, @@ -771,6 +790,14 @@ constructor( fun setShowHomeBattery(show: Boolean) = launchPreferences { setShowHomeBattery(show) } + fun setShowHomeMedia(show: Boolean) = launchPreferences { setShowHomeMedia(show) } + + fun setRegisteredMediaApps(packages: Set) = + launchPreferences { setRegisteredMediaApps(packages) } + + /** Installed apps exposing a connectable media browser service, for the registration picker. */ + fun discoverMediaApps(): List = mediaRepository.discoverMediaApps() + 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..406a83c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -194,6 +194,21 @@ Fahrenheit (°F) Show weather Show battery level + Show media controls + A now-playing line with rewind, play/pause and forward, shown when audio is playing + Media apps + Tap to choose which apps to control + Choose media apps + No compatible media apps found. Apps must allow other apps to connect. + + %d app + %d apps + + + Rewind 15 seconds + Play + Pause + Forward 30 seconds Clock app Calendar app Double tap to lock diff --git a/app/src/test/java/com/lu4p/fokuslauncher/media/MediaSeekTest.kt b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaSeekTest.kt new file mode 100644 index 00000000..790992ef --- /dev/null +++ b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaSeekTest.kt @@ -0,0 +1,31 @@ +package com.lu4p.fokuslauncher.media + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaSeekTest { + + @Test + fun rewindSubtractsFifteenSeconds() { + val target = MediaRepository.seekTarget(60_000L, -MediaRepository.REWIND_MS) + assertEquals(45_000L, target) + } + + @Test + fun forwardAddsThirtySeconds() { + val target = MediaRepository.seekTarget(60_000L, MediaRepository.FORWARD_MS) + assertEquals(90_000L, target) + } + + @Test + fun rewindClampsAtTrackStart() { + val target = MediaRepository.seekTarget(5_000L, -MediaRepository.REWIND_MS) + assertEquals(0L, target) + } + + @Test + fun forwardFromStartIsExactlyThirtySeconds() { + val target = MediaRepository.seekTarget(0L, MediaRepository.FORWARD_MS) + assertEquals(30_000L, target) + } +} 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..d339ae65 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,8 @@ 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.registeredMediaAppsFlow } returns flowOf(emptySet()) every { preferencesManager.homeDateFormatStyleFlow } returns flowOf(HomeDateFormatStyle.SYSTEM_DEFAULT) every { preferencesManager.doubleTapEmptyLockFlow } returns flowOf(false) @@ -136,11 +143,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) From a41b20ed259c87dab10006fef84a5118a4eb3ff8 Mon Sep 17 00:00:00 2001 From: Paul Scheduikat Date: Wed, 1 Jul 2026 12:19:07 +0200 Subject: [PATCH 2/2] Add home media widget via notification access sessions. Drive now-playing controls from MediaSessionManager through a notification listener, with skip/like/save actions, buffering support, and settings that gate the feature on notification access in a single toggle. --- app/src/main/AndroidManifest.xml | 14 +- .../data/local/PreferencesManager.kt | 6 - .../media/MediaCustomActionsReader.kt | 217 +++++++++ .../media/MediaMetadataReader.kt | 55 +++ .../media/MediaNotificationHelper.kt | 24 + .../media/MediaNotificationListenerService.kt | 29 ++ .../fokuslauncher/media/MediaPlaybackState.kt | 23 + .../fokuslauncher/media/MediaRepository.kt | 439 +++++++++++------- .../ui/components/MediaWidget.kt | 270 +++++++---- .../lu4p/fokuslauncher/ui/home/HomeScreen.kt | 36 +- .../fokuslauncher/ui/home/HomeViewModel.kt | 31 +- .../ui/settings/SettingsScreen.kt | 122 ++--- .../ui/settings/SettingsViewModel.kt | 20 +- app/src/main/res/values/strings.xml | 21 +- .../media/MediaCustomActionsReaderTest.kt | 106 +++++ .../fokuslauncher/media/MediaMetadataTest.kt | 62 +++ .../media/MediaPlaybackStateTest.kt | 29 ++ .../lu4p/fokuslauncher/media/MediaSeekTest.kt | 31 -- .../ui/home/HomeViewModelTest.kt | 1 - 19 files changed, 1109 insertions(+), 427 deletions(-) create mode 100644 app/src/main/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReader.kt create mode 100644 app/src/main/java/com/lu4p/fokuslauncher/media/MediaMetadataReader.kt create mode 100644 app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationHelper.kt create mode 100644 app/src/main/java/com/lu4p/fokuslauncher/media/MediaNotificationListenerService.kt create mode 100644 app/src/main/java/com/lu4p/fokuslauncher/media/MediaPlaybackState.kt create mode 100644 app/src/test/java/com/lu4p/fokuslauncher/media/MediaCustomActionsReaderTest.kt create mode 100644 app/src/test/java/com/lu4p/fokuslauncher/media/MediaMetadataTest.kt create mode 100644 app/src/test/java/com/lu4p/fokuslauncher/media/MediaPlaybackStateTest.kt delete mode 100644 app/src/test/java/com/lu4p/fokuslauncher/media/MediaSeekTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c6192308..c5e6b8d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,10 +21,6 @@ - - - - + + + + + + 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 3f6a2981..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 @@ -94,7 +94,6 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v /** 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. */ - private val REGISTERED_MEDIA_APPS_KEY = stringSetPreferencesKey("registered_media_apps") /** Vertical category sidebar in the drawer instead of chips + search bar. */ private val DRAWER_SIDEBAR_CATEGORIES_KEY = booleanPreferencesKey("drawer_sidebar_categories") @@ -395,11 +394,6 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v val showHomeMediaFlow: Flow = prefFlow(SHOW_HOME_MEDIA_KEY, false) suspend fun setShowHomeMedia(show: Boolean) = setPref(SHOW_HOME_MEDIA_KEY, show) - val registeredMediaAppsFlow: Flow> = - prefFlow(REGISTERED_MEDIA_APPS_KEY, emptySet()) - suspend fun setRegisteredMediaApps(packages: Set) = - setPref(REGISTERED_MEDIA_APPS_KEY, packages) - 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 index 8cc8c71c..bc3695e3 100644 --- a/app/src/main/java/com/lu4p/fokuslauncher/media/MediaRepository.kt +++ b/app/src/main/java/com/lu4p/fokuslauncher/media/MediaRepository.kt @@ -5,13 +5,14 @@ import android.app.PendingIntent import android.content.ComponentName import android.content.Context import android.content.Intent -import android.os.Handler +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.os.SystemClock -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaMetadataCompat 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 @@ -21,24 +22,25 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** An installed app that exposes a MediaBrowserService we can attempt to connect to. */ -data class MediaAppInfo(val packageName: String, val label: String) - /** 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, - /** False when the active app does not advertise [PlaybackStateCompat.ACTION_SEEK_TO]. */ - val canSeek: 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. Unlike a notification listener, this connects directly to each app's MediaBrowserService - * via [MediaBrowserCompat], so it needs no special permission — but an app only appears if it allows - * outside connections (its `onGetRoot` accepts us). Apps that whitelist only system callers (some - * mainstream players) simply never connect, which is why registration is per-app and opt-in. + * to them via notification access ([MediaNotificationListenerService] + + * [MediaSessionManager.getActiveSessions]). * * All session interaction happens on the main thread. */ @@ -50,48 +52,104 @@ class MediaRepository @Inject constructor(@param:ApplicationContext private val private val _state = MutableStateFlow(null) val state: StateFlow = _state.asStateFlow() - /** Live connections keyed by package name. */ - private val connections = LinkedHashMap() + /** 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() } - /** Installed apps advertising a MediaBrowserService, for the registration picker. */ - fun discoverMediaApps(): List { - val pm = context.packageManager - return pm.queryIntentServices(Intent(SERVICE_INTERFACE), 0) - .mapNotNull { it.serviceInfo } - .filter { it.packageName != context.packageName } - .distinctBy { it.packageName } - .map { MediaAppInfo(it.packageName, it.loadLabel(pm).toString()) } - .sortedBy { it.label.lowercase() } - } + private val sessionCallback = + object : MediaControllerCompat.Callback() { + override fun onPlaybackStateChanged(state: PlaybackStateCompat?) = publishState() + + override fun onMetadataChanged(metadata: android.support.v4.media.MediaMetadataCompat?) = + publishState() - /** Reconcile live connections with the registered set: drop removed apps, connect new ones. */ + override fun onSessionDestroyed() { + refreshNotificationSessions() + } + } + + /** Enable or disable the home media widget; requires notification access when enabling. */ @MainThread - fun setRegisteredApps(packages: Set) { - (connections.keys - packages).toList().forEach { disconnect(it) } - (packages - connections.keys).forEach { connect(it) } + fun setWidgetEnabled(enabled: Boolean) { + widgetEnabled = enabled + if (!enabled || !MediaNotificationHelper.isListenerEnabled(context)) { + resetPauseGrace() + optimisticToggle = null + _state.value = null + return + } + refreshNotificationSessions() publishState() } @MainThread fun stop() { - connections.keys.toList().forEach { disconnect(it) } - resetPauseGrace() - _state.value = null + 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 (controller.playbackState?.state == PlaybackStateCompat.STATE_PLAYING) { + if (MediaPlaybackState.isActivelyPlaying(controller.playbackState?.state)) { controller.transportControls.pause() } else { controller.transportControls.play() @@ -101,11 +159,10 @@ class MediaRepository @Inject constructor(@param:ApplicationContext private val /** Opens the playing app — its now-playing screen via [MediaControllerCompat.getSessionActivity] * when offered, otherwise the app's launcher entry. */ @MainThread fun openMediaApp() { - val controller = activeController() ?: return + val playback = resolveActivePlayback() ?: return + val controller = playback.controller controller.sessionActivity?.let { pending -> try { - // Android 14+ drops a sent PendingIntent's activity start unless the sender grants - // it, even from the foreground; opt in so the app's now-playing screen opens. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val options = ActivityOptions.makeBasic() @@ -122,94 +179,206 @@ class MediaRepository @Inject constructor(@param:ApplicationContext private val // Stale PendingIntent; fall through to a plain launch. } } - val launch = - context.packageManager.getLaunchIntentForPackage(controller.packageName) ?: return - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - try { - context.startActivity(launch) - } catch (_: Exception) {} + launchPackage(controller.packageName) } - @MainThread fun rewind() = seekBy(-REWIND_MS) - - @MainThread fun forward() = seekBy(FORWARD_MS) + @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() + } - private fun seekBy(deltaMs: Long) { + @MainThread + fun skipToNext() { val controller = activeController() ?: return - val playbackState = controller.playbackState ?: return - if (playbackState.actions and PlaybackStateCompat.ACTION_SEEK_TO == 0L) return - controller.transportControls.seekTo(seekTarget(playbackState.currentPosition(), deltaMs)) + val actions = controller.playbackState?.actions ?: return + if (actions and PlaybackStateCompat.ACTION_SKIP_TO_NEXT == 0L) return + controller.transportControls.skipToNext() } - private fun connect(packageName: String) { - val component = resolveServiceComponent(packageName) ?: return - val connection = AppConnection(packageName) - connections[packageName] = connection - val browser = MediaBrowserCompat(context, component, connection.browserCallback, null) - connection.browser = browser - try { - browser.connect() - } catch (_: IllegalStateException) { - // Already connecting/connected. - } + @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) } - private fun disconnect(packageName: String) { - connections.remove(packageName)?.release() + @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 resolveServiceComponent(packageName: String): ComponentName? { - val intent = Intent(SERVICE_INTERFACE).setPackage(packageName) - val service = - context.packageManager.queryIntentServices(intent, 0).firstOrNull()?.serviceInfo - ?: return null - return ComponentName(service.packageName, service.name) + private fun invokeCustomAction(button: MediaCustomActionButton) { + val controller = activeController() ?: return + controller.transportControls.sendCustomAction(button.actionId, button.extras ?: Bundle()) } - /** Active = a connected controller, preferring one that is playing, then most recently updated. */ - private fun activeController(): MediaControllerCompat? = - connections.values - .mapNotNull { it.controller } - .filter { it.playbackState.isShowable() } - .maxWithOrNull( - compareBy( - { if (it.playbackState?.state == PlaybackStateCompat.STATE_PLAYING) 1 else 0 }, - { it.playbackState?.lastPositionUpdateTime ?: 0L }, - ) - ) + private fun updateSessionControllers(frameworkControllers: List?) { + val incoming = + frameworkControllers.orEmpty().filter { it.packageName != context.packageName } + val incomingPackages = incoming.map { it.packageName }.toSet() - private fun publishState() { + (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 = - metadata?.getString(MediaMetadataCompat.METADATA_KEY_TITLE) - ?: metadata?.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE) - if (controller == null || playbackState == null || title.isNullOrBlank()) { + 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 } - val isPlaying = playbackState.state == PlaybackStateCompat.STATE_PLAYING - if (isPlaying) { - // Playing again cancels any pending hide and clears the expired flag. + + if (playback.isPlaying) { resetPauseGrace() } else if (pausedGraceExpired) { - // Still paused after the grace period: keep the stale session hidden. _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 = title, - artist = metadata?.getString(MediaMetadataCompat.METADATA_KEY_ARTIST), - isPlaying = isPlaying, - canSeek = playbackState.actions and PlaybackStateCompat.ACTION_SEEK_TO != 0L, + 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 @@ -222,79 +391,27 @@ class MediaRepository @Inject constructor(@param:ApplicationContext private val mainHandler.removeCallbacks(hideRunnable) } - /** One app's browser + controller pair, with callbacks that republish on any change. */ - private inner class AppConnection(val packageName: String) { - var browser: MediaBrowserCompat? = null - var controller: MediaControllerCompat? = null - - val browserCallback = - object : MediaBrowserCompat.ConnectionCallback() { - override fun onConnected() { - val token = browser?.sessionToken ?: return - val ctrl = MediaControllerCompat(context, token) - controller = ctrl - ctrl.registerCallback(controllerCallback, mainHandler) - publishState() - } - - override fun onConnectionSuspended() { - detachController() - publishState() - } - - override fun onConnectionFailed() { - // The app refused our connection; leave it disconnected. - } - } - - private val controllerCallback = - object : MediaControllerCompat.Callback() { - override fun onPlaybackStateChanged(state: PlaybackStateCompat?) = publishState() - override fun onMetadataChanged(metadata: MediaMetadataCompat?) = publishState() - override fun onSessionDestroyed() { - detachController() - publishState() - } - } - - private fun detachController() { - controller?.unregisterCallback(controllerCallback) - controller = null - } - - fun release() { - detachController() - try { - browser?.disconnect() - } catch (_: Exception) {} - browser = null - } - } - - private fun PlaybackStateCompat?.isShowable(): Boolean = - when (this?.state) { - null, - PlaybackStateCompat.STATE_NONE, - PlaybackStateCompat.STATE_STOPPED, - PlaybackStateCompat.STATE_ERROR -> false - else -> true - } - - private fun PlaybackStateCompat.currentPosition(): Long { - if (state != PlaybackStateCompat.STATE_PLAYING) return position - val elapsed = SystemClock.elapsedRealtime() - lastPositionUpdateTime - return position + (elapsed * playbackSpeed).toLong() - } + 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 { - const val REWIND_MS = 15_000L - const val FORWARD_MS = 30_000L /** 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 - private const val SERVICE_INTERFACE = "android.media.browse.MediaBrowserService" - /** Seek destination clamped to the start of the track; extracted for unit testing. */ - fun seekTarget(currentPosition: Long, deltaMs: Long): Long = - (currentPosition + deltaMs).coerceAtLeast(0L) + /** 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 index 73504ace..ddf55d07 100644 --- a/app/src/main/java/com/lu4p/fokuslauncher/ui/components/MediaWidget.kt +++ b/app/src/main/java/com/lu4p/fokuslauncher/ui/components/MediaWidget.kt @@ -5,13 +5,18 @@ 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.FastForward -import androidx.compose.material.icons.filled.FastRewind +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 @@ -21,16 +26,18 @@ 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 /** - * Two-line now-playing widget shown below the date/battery row when audio is active. - * Top line scrolls the track title (and artist when present); the bottom line offers - * rewind 15s / play-pause / forward 30s controls for the active media session. + * 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. * - * The seek buttons are dimmed and inert when [canSeek] is false (e.g. live streams). + * Buttons are hidden when the active app does not advertise the matching session action. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -38,98 +45,199 @@ fun MediaWidget( title: String, artist: String?, isPlaying: Boolean, - canSeek: Boolean, + isBuffering: Boolean = false, + canSkipToPrevious: Boolean, + canSkipToNext: Boolean, + like: MediaCustomActionButton? = null, + save: MediaCustomActionButton? = null, modifier: Modifier = Modifier, outlined: Boolean = false, onOpenApp: () -> Unit = {}, - onRewind: () -> Unit = {}, + onLike: () -> Unit = {}, + onPrevious: () -> Unit = {}, onPlayPause: () -> Unit = {}, - onForward: () -> Unit = {}, + onNext: () -> Unit = {}, + onSave: () -> Unit = {}, ) { val color = MaterialTheme.colorScheme.onBackground val titleStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) - val nowPlaying = if (artist.isNullOrBlank()) title else "$title — $artist" + 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")) { - // Tapping the title opens the playing app (its now-playing screen when offered). - val titleModifier = - Modifier.testTag("media_now_playing") - .fillMaxWidth() - .clickableNoRippleWithSystemSound(onClick = onOpenApp) - .basicMarquee() - if (outlined) { - OutlinedText( - text = nowPlaying, - style = titleStyle, - color = color, - maxLines = 1, - modifier = titleModifier, - ) - } else { - Text( - text = nowPlaying, + Column( + modifier = + Modifier.fillMaxWidth() + .clickableNoRippleWithSystemSound(onClick = onOpenApp), + ) { + MediaWidgetMarqueeText( + text = title, style = titleStyle, color = color, - maxLines = 1, - modifier = titleModifier, + 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, - horizontalArrangement = Arrangement.spacedBy(20.dp), - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) { - val seekColor = if (canSeek) color else color.copy(alpha = 0.38f) - LauncherIcon( - imageVector = Icons.Filled.FastRewind, - contentDescription = stringResource(R.string.media_rewind_15), - iconSize = iconSize, - tint = seekColor, - outlined = outlined, - modifier = - Modifier.testTag("media_rewind") - .then( - if (canSeek) { - Modifier.clickableNoRippleWithSystemSound( - onClick = onRewind - ) - } else { - Modifier - } - ), - ) - LauncherIcon( - imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = - stringResource( - if (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), - ) - LauncherIcon( - imageVector = Icons.Filled.FastForward, - contentDescription = stringResource(R.string.media_forward_30), - iconSize = iconSize, - tint = seekColor, - outlined = outlined, - modifier = - Modifier.testTag("media_forward") - .then( - if (canSeek) { - Modifier.clickableNoRippleWithSystemSound( - onClick = onForward - ) - } else { - Modifier - } - ), - ) + 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 6a7cd4c2..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 @@ -144,9 +144,11 @@ fun HomeScreen( onDateClick = onDateClick, onWeatherClick = onWeatherClick, onMediaOpenApp = viewModel::mediaOpenApp, - onMediaRewind = viewModel::mediaRewind, + onMediaPrevious = viewModel::mediaSkipToPrevious, onMediaPlayPause = viewModel::mediaPlayPause, - onMediaForward = viewModel::mediaForward, + onMediaNext = viewModel::mediaSkipToNext, + onMediaLike = viewModel::mediaLike, + onMediaSave = viewModel::mediaSave, doubleTapEmptyLockEnabled = uiState.doubleTapEmptyLockEnabled, onDoubleTapEmptyLock = onDoubleTapEmptyLock, ) @@ -233,9 +235,11 @@ fun HomeScreenContent( onDateClick: () -> Unit = {}, onWeatherClick: () -> Unit = {}, onMediaOpenApp: () -> Unit = {}, - onMediaRewind: () -> Unit = {}, + onMediaPrevious: () -> Unit = {}, onMediaPlayPause: () -> Unit = {}, - onMediaForward: () -> Unit = {}, + onMediaNext: () -> Unit = {}, + onMediaLike: () -> Unit = {}, + onMediaSave: () -> Unit = {}, doubleTapEmptyLockEnabled: Boolean = false, onDoubleTapEmptyLock: () -> Unit = {}, ) { @@ -280,9 +284,11 @@ fun HomeScreenContent( onDateClick = onDateClick, onWeatherClick = onWeatherClick, onMediaOpenApp = onMediaOpenApp, - onMediaRewind = onMediaRewind, + onMediaPrevious = onMediaPrevious, onMediaPlayPause = onMediaPlayPause, - onMediaForward = onMediaForward, + onMediaNext = onMediaNext, + onMediaLike = onMediaLike, + onMediaSave = onMediaSave, outlined = uiState.usesPhotoWallpaper, ) @@ -380,9 +386,11 @@ private fun HomeWidgetsSection( onDateClick: () -> Unit, onWeatherClick: () -> Unit, onMediaOpenApp: () -> Unit, - onMediaRewind: () -> Unit, + onMediaPrevious: () -> Unit, onMediaPlayPause: () -> Unit, - onMediaForward: () -> Unit, + onMediaNext: () -> Unit, + onMediaLike: () -> Unit, + onMediaSave: () -> Unit, outlined: Boolean, ) { val showClock = uiState.showHomeClock @@ -438,12 +446,18 @@ private fun HomeWidgetsSection( title = playback.title, artist = playback.artist, isPlaying = playback.isPlaying, - canSeek = playback.canSeek, + isBuffering = playback.isBuffering, + canSkipToPrevious = playback.canSkipToPrevious, + canSkipToNext = playback.canSkipToNext, + like = playback.like, + save = playback.save, outlined = outlined, onOpenApp = onMediaOpenApp, - onRewind = onMediaRewind, + onLike = onMediaLike, + onPrevious = onMediaPrevious, onPlayPause = onMediaPlayPause, - onForward = onMediaForward, + onNext = onMediaNext, + onSave = onMediaSave, modifier = Modifier.fillMaxWidth() .padding(top = if (showDateOrBattery || showClock) 8.dp else 0.dp), 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 c7ce6370..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,7 @@ 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 @@ -822,37 +823,35 @@ class HomeViewModel @Inject constructor( // ── Media widget ──────────────────────────────────────────────── private var mediaEnabled = false - private var registeredMediaApps: Set = emptySet() - private fun observeMedia() { - observeFlow( - combine( - preferencesManager.showHomeMediaFlow, - preferencesManager.registeredMediaAppsFlow, - ) { enabled, apps -> enabled to apps } - ) { (enabled, apps) -> - mediaEnabled = enabled - registeredMediaApps = apps - _mediaUiState.value = _mediaUiState.value.copy(enabled = enabled) - if (enabled) mediaRepository.setRegisteredApps(apps) else mediaRepository.stop() + 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-applies registered apps on resume so newly added apps connect promptly. */ + /** Re-reads active sessions on resume so newly started playback appears promptly. */ fun refreshMedia() { - if (mediaEnabled) mediaRepository.setRegisteredApps(registeredMediaApps) + if (mediaEnabled) { + mediaRepository.refreshNotificationSessions() + } } fun mediaOpenApp() = mediaRepository.openMediaApp() fun mediaPlayPause() = mediaRepository.playPause() - fun mediaRewind() = mediaRepository.rewind() + fun mediaSkipToPrevious() = mediaRepository.skipToPrevious() + + fun mediaSkipToNext() = mediaRepository.skipToNext() + + fun mediaLike() = mediaRepository.invokeLikeAction() - fun mediaForward() = mediaRepository.forward() + fun mediaSave() = mediaRepository.invokeSaveAction() private fun observeCategoryOptions() { observeFlow( 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 af95cd25..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 @@ -49,7 +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.MediaAppInfo +import com.lu4p.fokuslauncher.media.MediaNotificationHelper import com.lu4p.fokuslauncher.ui.components.FokusAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -855,12 +855,22 @@ fun HomeWidgetsSettingsScreen( val (hasCoarseLocationPermission, requestCoarseLocation) = rememberCoarseLocationPermission(context, activity) - var showMediaAppPicker by remember { mutableStateOf(false) } - // Discovery touches PackageManager, so only run it while the picker is open. - val discoveredMediaApps = - remember(showMediaAppPicker) { - if (showMediaAppPicker) viewModel.discoverMediaApps() else emptyList() + 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 = @@ -919,30 +929,28 @@ fun HomeWidgetsSettingsScreen( item { SettingsToggleRow( label = stringResource(R.string.settings_show_home_media), - subtitle = stringResource(R.string.settings_show_home_media_subtitle), + 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 = viewModel::setShowHomeMedia, + onCheckedChange = { checked -> + if (checked) { + if (mediaNotificationAccessEnabled) { + viewModel.setShowHomeMedia(true) + } else { + pendingMediaEnable = true + MediaNotificationHelper.openListenerSettings(context) + } + } else { + pendingMediaEnable = false + viewModel.setShowHomeMedia(false) + } + }, ) } - if (uiState.showHomeMedia) { - item { - val count = uiState.registeredMediaApps.size - SettingsRow( - label = stringResource(R.string.settings_media_apps), - subtitle = - if (count == 0) { - stringResource(R.string.settings_media_apps_none) - } else { - pluralStringResource( - R.plurals.settings_media_apps_count, - count, - count, - ) - }, - onClick = { showMediaAppPicker = true }, - ) - } - } item { SettingsDivider() } item { WeatherAppSettingRow( @@ -1009,66 +1017,6 @@ fun HomeWidgetsSettingsScreen( profileDisplayNameOverrides = uiState.profileDisplayNameOverrides, ) } - - if (showMediaAppPicker) { - MediaAppsPickerDialog( - apps = discoveredMediaApps, - registered = uiState.registeredMediaApps, - onToggle = { packageName, checked -> - val next = - if (checked) uiState.registeredMediaApps + packageName - else uiState.registeredMediaApps - packageName - viewModel.setRegisteredMediaApps(next) - }, - onDismiss = { showMediaAppPicker = false }, - ) - } -} - -/** Multi-select checklist of installed media apps the widget can connect to. */ -@Composable -private fun MediaAppsPickerDialog( - apps: List, - registered: Set, - onToggle: (String, Boolean) -> Unit, - onDismiss: () -> Unit, -) { - FokusAlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - stringResource(R.string.settings_media_apps_picker_title), - color = MaterialTheme.colorScheme.onBackground, - ) - }, - text = { - if (apps.isEmpty()) { - Text( - stringResource(R.string.settings_media_apps_empty), - color = MaterialTheme.colorScheme.onBackground, - ) - } else { - Column( - modifier = - Modifier.heightIn(max = 360.dp) - .verticalScroll(rememberScrollState()) - ) { - apps.forEach { app -> - SettingsToggleRow( - label = app.label, - checked = app.packageName in registered, - onCheckedChange = { onToggle(app.packageName, it) }, - ) - } - } - } - }, - dismissButton = { - FokusTextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_done)) - } - }, - ) } @OptIn(ExperimentalMaterial3Api::class) 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 7bf98a38..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 @@ -33,8 +33,6 @@ import com.lu4p.fokuslauncher.data.model.ShortcutTarget import com.lu4p.fokuslauncher.data.model.WidgetTapTarget import com.lu4p.fokuslauncher.data.repository.AppRepository import com.lu4p.fokuslauncher.data.util.AppLocaleHelper -import com.lu4p.fokuslauncher.media.MediaAppInfo -import com.lu4p.fokuslauncher.media.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel import android.app.WallpaperManager import android.content.ClipData @@ -87,7 +85,6 @@ data class SettingsUiState( val showHomeWeather: Boolean = true, val showHomeBattery: Boolean = true, val showHomeMedia: Boolean = false, - val registeredMediaApps: Set = emptySet(), val homeDateFormatStyle: HomeDateFormatStyle = HomeDateFormatStyle.SYSTEM_DEFAULT, val temperatureUnit: TemperatureUnit = TemperatureUnit.SYSTEM_DEFAULT, /** Vertical category sidebar in the app drawer. */ @@ -161,7 +158,6 @@ constructor( private val preferencesManager: PreferencesManager, private val privateSpaceManager: PrivateSpaceManager, private val customFontStore: CustomFontStore, - private val mediaRepository: MediaRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -235,14 +231,11 @@ constructor( suppressedCategories = suppressed, ) } - // Media visibility and registered apps are bundled with the widget-visibility flow to - // stay within the five-argument combine; all feed the same HomeWidgetItemSettings. val visibilityAndMediaFlow = combine( preferencesManager.homeWidgetVisibilityFlow, preferencesManager.showHomeMediaFlow, - preferencesManager.registeredMediaAppsFlow, - ) { vis, showMedia, apps -> Triple(vis, showMedia, apps) } + ) { vis, showMedia -> vis to showMedia } val homeWidgetItemsFlow = combine( visibilityAndMediaFlow, @@ -250,14 +243,13 @@ constructor( preferencesManager.preferredCalendarTapFlow, preferencesManager.homeDateFormatStyleFlow, preferencesManager.temperatureUnitFlow, - ) { (vis, showMedia, mediaApps), 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, - registeredMediaApps = mediaApps, preferredClockTap = clk, preferredCalendarTap = cal, homeDateFormatStyle = fmt, @@ -443,7 +435,6 @@ constructor( showHomeWeather = homeWidgetItems.showWeather, showHomeBattery = homeWidgetItems.showBattery, showHomeMedia = homeWidgetItems.showMedia, - registeredMediaApps = homeWidgetItems.registeredMediaApps, homeDateFormatStyle = homeWidgetItems.homeDateFormatStyle, temperatureUnit = homeWidgetItems.temperatureUnit, drawerSidebarCategories = drawer.drawerSidebarCategories, @@ -483,7 +474,6 @@ constructor( val showWeather: Boolean, val showBattery: Boolean, val showMedia: Boolean, - val registeredMediaApps: Set, val preferredClockTap: WidgetTapTarget?, val preferredCalendarTap: WidgetTapTarget?, val homeDateFormatStyle: HomeDateFormatStyle, @@ -792,12 +782,6 @@ constructor( fun setShowHomeMedia(show: Boolean) = launchPreferences { setShowHomeMedia(show) } - fun setRegisteredMediaApps(packages: Set) = - launchPreferences { setRegisteredMediaApps(packages) } - - /** Installed apps exposing a connectable media browser service, for the registration picker. */ - fun discoverMediaApps(): List = mediaRepository.discoverMediaApps() - 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 406a83c1..a684c278 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,20 +195,19 @@ Show weather Show battery level Show media controls - A now-playing line with rewind, play/pause and forward, shown when audio is playing - Media apps - Tap to choose which apps to control - Choose media apps - No compatible media apps found. Apps must allow other apps to connect. - - %d app - %d apps - + Now playing with transport controls when audio is active + Requires notification access — turn on to grant in system settings + Fokus now playing - Rewind 15 seconds + Previous track Play Pause - Forward 30 seconds + 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/media/MediaSeekTest.kt b/app/src/test/java/com/lu4p/fokuslauncher/media/MediaSeekTest.kt deleted file mode 100644 index 790992ef..00000000 --- a/app/src/test/java/com/lu4p/fokuslauncher/media/MediaSeekTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.lu4p.fokuslauncher.media - -import org.junit.Assert.assertEquals -import org.junit.Test - -class MediaSeekTest { - - @Test - fun rewindSubtractsFifteenSeconds() { - val target = MediaRepository.seekTarget(60_000L, -MediaRepository.REWIND_MS) - assertEquals(45_000L, target) - } - - @Test - fun forwardAddsThirtySeconds() { - val target = MediaRepository.seekTarget(60_000L, MediaRepository.FORWARD_MS) - assertEquals(90_000L, target) - } - - @Test - fun rewindClampsAtTrackStart() { - val target = MediaRepository.seekTarget(5_000L, -MediaRepository.REWIND_MS) - assertEquals(0L, target) - } - - @Test - fun forwardFromStartIsExactlyThirtySeconds() { - val target = MediaRepository.seekTarget(0L, MediaRepository.FORWARD_MS) - assertEquals(30_000L, target) - } -} 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 d339ae65..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 @@ -120,7 +120,6 @@ class HomeViewModelTest { every { preferencesManager.showHomeWeatherFlow } returns flowOf(true) every { preferencesManager.showHomeBatteryFlow } returns flowOf(true) every { preferencesManager.showHomeMediaFlow } returns flowOf(false) - every { preferencesManager.registeredMediaAppsFlow } returns flowOf(emptySet()) every { preferencesManager.homeDateFormatStyleFlow } returns flowOf(HomeDateFormatStyle.SYSTEM_DEFAULT) every { preferencesManager.doubleTapEmptyLockFlow } returns flowOf(false)