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" />