Twelve: Now playing widget
Co-authored-by: Luca Stefani <luca.stefani.ge1@gmail.com>
Change-Id: I10508f08b030e6a664a4b3ef5bc5273bd3606aa5
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 788aa07..8448e34 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -57,6 +57,10 @@
</service>
<receiver
+ android:name=".services.PlaybackServiceActionsReceiver"
+ android:exported="false" />
+
+ <receiver
android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
@@ -64,6 +68,19 @@
</intent-filter>
</receiver>
+ <receiver
+ android:name=".ui.widgets.NowPlayingAppWidgetProvider"
+ android:exported="false">
+
+ <intent-filter>
+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
+ </intent-filter>
+
+ <meta-data android:name="android.appwidget.provider"
+ android:resource="@xml/app_widget_now_playing" />
+
+ </receiver>
+
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
diff --git a/app/src/main/java/org/lineageos/twelve/services/PlaybackService.kt b/app/src/main/java/org/lineageos/twelve/services/PlaybackService.kt
index d97bfec..7420316 100644
--- a/app/src/main/java/org/lineageos/twelve/services/PlaybackService.kt
+++ b/app/src/main/java/org/lineageos/twelve/services/PlaybackService.kt
@@ -14,6 +14,7 @@
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ServiceLifecycleDispatcher
import androidx.lifecycle.coroutineScope
+import androidx.lifecycle.lifecycleScope
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
@@ -31,6 +32,7 @@
import org.lineageos.twelve.MainActivity
import org.lineageos.twelve.R
import org.lineageos.twelve.TwelveApplication
+import org.lineageos.twelve.ui.widgets.NowPlayingAppWidgetProvider
@OptIn(UnstableApi::class)
class PlaybackService : MediaLibraryService(), Player.Listener, LifecycleOwner {
@@ -274,6 +276,18 @@
)
}
}
+
+ // Update the now playing widget
+ if (events.containsAny(
+ Player.EVENT_MEDIA_METADATA_CHANGED,
+ Player.EVENT_PLAYBACK_STATE_CHANGED,
+ Player.EVENT_PLAY_WHEN_READY_CHANGED,
+ )
+ ) {
+ lifecycleScope.launch {
+ NowPlayingAppWidgetProvider.update(this@PlaybackService)
+ }
+ }
}
private fun openAudioEffectSession() {
diff --git a/app/src/main/java/org/lineageos/twelve/services/PlaybackServiceActionsReceiver.kt b/app/src/main/java/org/lineageos/twelve/services/PlaybackServiceActionsReceiver.kt
new file mode 100644
index 0000000..97bb7c8
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/services/PlaybackServiceActionsReceiver.kt
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.services
+
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.guava.await
+import kotlinx.coroutines.launch
+
+/**
+ * [BroadcastReceiver] used to handle playback actions from other UI components like widgets.
+ */
+class PlaybackServiceActionsReceiver : BroadcastReceiver() {
+ private val coroutineScope = MainScope()
+
+ private val actionToMethod = mapOf(
+ ACTION_TOGGLE_PLAY_PAUSE to ::togglePlayPause
+ )
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ coroutineScope.launch {
+ actionToMethod[intent?.action]?.invoke(context ?: return@launch)
+ }
+ }
+
+ private suspend fun togglePlayPause(context: Context) {
+ withMediaController(context) { mediaController ->
+ if (mediaController.isPlaying) {
+ mediaController.pause()
+ } else {
+ mediaController.play()
+ }
+ }
+ }
+
+ private suspend fun withMediaController(
+ context: Context,
+ block: suspend (MediaController) -> Unit,
+ ) {
+ val sessionToken = SessionToken(
+ context,
+ ComponentName(context, PlaybackService::class.java)
+ )
+
+ val mediaController = MediaController.Builder(context.applicationContext, sessionToken)
+ .buildAsync()
+ .await()
+
+ block(mediaController)
+
+ mediaController.release()
+ }
+
+ companion object {
+ const val ACTION_TOGGLE_PLAY_PAUSE = "TOGGLE_PLAY_PAUSE"
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ui/widgets/AppWidgetUpdater.kt b/app/src/main/java/org/lineageos/twelve/ui/widgets/AppWidgetUpdater.kt
new file mode 100644
index 0000000..2fb3bce
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ui/widgets/AppWidgetUpdater.kt
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ui.widgets
+
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.widget.RemoteViews
+import androidx.annotation.LayoutRes
+import kotlin.reflect.KClass
+
+/**
+ * A helper class used to update a widget.
+ */
+abstract class AppWidgetUpdater<T : AppWidgetProvider>(
+ private val appWidgetProviderKClass: KClass<T>,
+ @LayoutRes val layoutResId: Int,
+) {
+ abstract suspend fun RemoteViews.update(context: Context)
+
+ suspend fun update(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ appWidgetIds.forEach { appWidgetId ->
+ // Get the layout for the widget
+ val views = RemoteViews(context.packageName, layoutResId).apply {
+ update(context)
+ }
+
+ // Tell the AppWidgetManager to perform an update on the current widget
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+ }
+
+ suspend fun update(context: Context) = AppWidgetManager.getInstance(context).let {
+ update(
+ context,
+ it,
+ it.getAppWidgetIds(
+ ComponentName(context, appWidgetProviderKClass.java)
+ ),
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ui/widgets/BaseAppWidgetProvider.kt b/app/src/main/java/org/lineageos/twelve/ui/widgets/BaseAppWidgetProvider.kt
new file mode 100644
index 0000000..79a8827
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ui/widgets/BaseAppWidgetProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ui.widgets
+
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+
+/**
+ * Base class for all of the app's app widget providers.
+ *
+ * @param updater The widget updater
+ */
+abstract class BaseAppWidgetProvider<T : AppWidgetProvider>(
+ private val updater: AppWidgetUpdater<T>,
+) : AppWidgetProvider() {
+ final override fun onUpdate(
+ context: Context?,
+ appWidgetManager: AppWidgetManager?,
+ appWidgetIds: IntArray?
+ ) {
+ MainScope().launch {
+ updater.update(
+ context ?: return@launch,
+ appWidgetManager ?: return@launch,
+ appWidgetIds ?: return@launch,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ui/widgets/NowPlayingAppWidgetProvider.kt b/app/src/main/java/org/lineageos/twelve/ui/widgets/NowPlayingAppWidgetProvider.kt
new file mode 100644
index 0000000..1b71ede
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ui/widgets/NowPlayingAppWidgetProvider.kt
@@ -0,0 +1,131 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ui.widgets
+
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.view.View
+import android.widget.RemoteViews
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import coil3.toBitmap
+import kotlinx.coroutines.guava.await
+import org.lineageos.twelve.MainActivity
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.typedPlaybackState
+import org.lineageos.twelve.models.PlaybackState
+import org.lineageos.twelve.services.PlaybackService
+import org.lineageos.twelve.services.PlaybackServiceActionsReceiver
+
+class NowPlayingAppWidgetProvider : BaseAppWidgetProvider<NowPlayingAppWidgetProvider>(Companion) {
+ companion object : AppWidgetUpdater<NowPlayingAppWidgetProvider>(
+ NowPlayingAppWidgetProvider::class,
+ R.layout.app_widget_now_playing,
+ ) {
+ override suspend fun RemoteViews.update(context: Context) {
+ withMediaController(context) { mediaController ->
+ val mediaMetadata = mediaController.mediaMetadata
+
+ val openNowPlayingPendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ Intent(context, MainActivity::class.java).apply {
+ putExtra(MainActivity.EXTRA_OPEN_NOW_PLAYING, true)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ setOnClickPendingIntent(R.id.linearLayout, openNowPlayingPendingIntent)
+
+ setTextViewText(R.id.titleTextView, mediaMetadata.title ?: "")
+ setTextViewText(R.id.artistNameTextView, mediaMetadata.artist ?: "")
+
+ setOnClickPendingIntent(
+ R.id.playPauseImageButton,
+ PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(context, PlaybackServiceActionsReceiver::class.java).apply {
+ action = PlaybackServiceActionsReceiver.ACTION_TOGGLE_PLAY_PAUSE
+ }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ setImageViewResource(
+ R.id.playPauseImageButton,
+ when (mediaController.playWhenReady) {
+ true -> R.drawable.ic_pause
+ false -> R.drawable.ic_play_arrow
+ }
+ )
+
+ setViewVisibility(
+ R.id.bufferingProgressBar, when (mediaController.typedPlaybackState) {
+ PlaybackState.BUFFERING -> View.VISIBLE
+ else -> View.GONE
+ }
+ )
+
+ when (mediaController.typedPlaybackState) {
+ PlaybackState.BUFFERING -> {
+ // Do nothing
+ }
+
+ else -> mediaMetadata.artworkData?.let {
+ BitmapFactory.decodeByteArray(it, 0, it.size)?.also { bitmap ->
+ setImageViewBitmap(R.id.thumbnailImageView, bitmap)
+ }
+ } ?: mediaMetadata.artworkUri?.let {
+ downloadBitmap(context, it)?.also { bitmap ->
+ setImageViewBitmap(R.id.thumbnailImageView, bitmap)
+ }
+ } ?: run {
+ setImageViewResource(R.id.thumbnailImageView, R.drawable.ic_music_note)
+ }
+ }
+ }
+ }
+
+ private suspend fun withMediaController(
+ context: Context,
+ block: suspend (MediaController) -> Unit,
+ ) {
+ val sessionToken = SessionToken(
+ context,
+ ComponentName(context, PlaybackService::class.java)
+ )
+
+ val mediaController = MediaController.Builder(
+ context.applicationContext,
+ sessionToken
+ )
+ .buildAsync()
+ .await()
+
+ block(mediaController)
+
+ mediaController.release()
+ }
+
+ private suspend fun downloadBitmap(context: Context, uri: Uri): Bitmap? {
+ val imageLoader = context.imageLoader
+
+ val imageRequest = ImageRequest.Builder(context)
+ .data(uri)
+ .build()
+
+ val result = imageLoader.execute(imageRequest)
+
+ return result.image?.toBitmap()
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/bg_app_widget.xml b/app/src/main/res/drawable/bg_app_widget.xml
new file mode 100644
index 0000000..fc19b48
--- /dev/null
+++ b/app/src/main/res/drawable/bg_app_widget.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2021 The Android Open Source Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+
+ <corners android:radius="28dp" />
+</shape>
diff --git a/app/src/main/res/drawable/bg_app_widget_round_view.xml b/app/src/main/res/drawable/bg_app_widget_round_view.xml
new file mode 100644
index 0000000..60d12a4
--- /dev/null
+++ b/app/src/main/res/drawable/bg_app_widget_round_view.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+
+ <corners android:radius="8dp" />
+</shape>
diff --git a/app/src/main/res/layout/app_widget_now_playing.xml b/app/src/main/res/layout/app_widget_now_playing.xml
new file mode 100644
index 0000000..2e3fc16
--- /dev/null
+++ b/app/src/main/res/layout/app_widget_now_playing.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/linearLayout"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@drawable/bg_app_widget"
+ android:backgroundTint="?attr/colorSurface"
+ android:baselineAligned="false"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:theme="@style/Theme.Material3.DynamicColors.DayNight">
+
+ <ImageView
+ android:id="@+id/thumbnailImageView"
+ android:background="@drawable/bg_app_widget_round_view"
+ android:backgroundTint="?attr/colorSecondaryContainer"
+ android:clipToOutline="true"
+ android:layout_width="60dp"
+ android:layout_height="60dp"
+ android:src="@drawable/ic_music_note"
+ tools:targetApi="s" />
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/titleTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:maxLines="2"
+ android:text="@string/now_playing_widget_dummy_title"
+ android:textColor="?attr/colorOnSurface"
+ android:textStyle="bold" />
+
+ <TextView
+ android:id="@+id/artistNameTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:text="@string/now_playing_widget_dummy_artist"
+ android:textColor="?attr/colorOnSurface" />
+
+ </LinearLayout>
+
+ <FrameLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+
+ <ProgressBar
+ android:id="@+id/bufferingProgressBar"
+ style="@style/Widget.AppCompat.ProgressBar"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center" />
+
+ <ImageButton
+ android:id="@+id/playPauseImageButton"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:layout_gravity="center"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:src="@drawable/ic_play_arrow"
+ android:tint="?attr/colorOnSurface"
+ tools:ignore="UseAppTint" />
+
+ </FrameLayout>
+
+</LinearLayout>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 77b77f9..1a78196 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -129,4 +129,10 @@
<string name="provider_type">Provider type</string>
<string name="provider_type_error">Select a provider type</string>
<string name="missing_provider_arguments">The following arguments are required: %1$s</string>
+
+ <!-- Now playing widget -->
+ <string name="now_playing_widget_description">Now playing</string>
+ <string name="now_playing_widget_dummy_title">Title</string>
+ <string name="now_playing_widget_dummy_artist">Artist</string>
+ <string name="now_playing_widget_dummy_album">Album</string>
</resources>
diff --git a/app/src/main/res/xml/app_widget_now_playing.xml b/app/src/main/res/xml/app_widget_now_playing.xml
new file mode 100644
index 0000000..bed9071
--- /dev/null
+++ b/app/src/main/res/xml/app_widget_now_playing.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:description="@string/now_playing_widget_description"
+ android:initialLayout="@layout/app_widget_now_playing"
+ android:minWidth="200dp"
+ android:minHeight="50dp"
+ android:previewLayout="@layout/app_widget_now_playing"
+ android:resizeMode="horizontal|vertical"
+ android:targetCellWidth="4"
+ android:targetCellHeight="1"
+ android:updatePeriodMillis="0"
+ android:widgetCategory="home_screen"
+ tools:ignore="UnusedAttribute" />