diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 08fb8bec99..1a81c23215 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -276,6 +276,20 @@
android:resource="@xml/lyric_widget" />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt
index e1175fa8f2..9341a932cc 100644
--- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt
+++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt
@@ -137,6 +137,7 @@ import org.akanework.gramophone.logic.utils.exoplayer.GramophoneExtractorsFactor
import org.akanework.gramophone.logic.utils.exoplayer.GramophoneMediaSourceFactory
import org.akanework.gramophone.logic.utils.exoplayer.GramophoneRenderFactory
import org.akanework.gramophone.ui.AudioPreviewActivity
+import org.akanework.gramophone.ui.CardWidgetProvider
import org.akanework.gramophone.ui.LyricWidgetProvider
import org.akanework.gramophone.ui.MainActivity
import org.akanework.gramophone.ui.fragments.compose.MqState.Companion.CLIENT_QB_REFRESH_ALL
@@ -808,6 +809,12 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis
return onSetRating(session, controller, mediaItemId, rating)
}
+ fun toggleCurrentItemFavorite() {
+ val currentMediaItem = controller?.currentMediaItem ?: return
+ val isHeart = (currentMediaItem.mediaMetadata.userRating as? HeartRating)?.isHeart == true
+ controller?.setRating(HeartRating(!isHeart))
+ }
+
// When destroying, we should release server side player
// alongside with the mediaSession.
override fun onDestroy() {
@@ -833,6 +840,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis
mediaSession = null
broadcastAudioSessionClose()
LyricWidgetProvider.update(this)
+ CardWidgetProvider.update(this)
internalPlaybackThread.quitSafely()
super.onDestroy()
Log.i(TAG, "-onDestroy()")
@@ -1617,6 +1625,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis
}
}
+ CardWidgetProvider.update(this)
lastPlayedManager.save()
}
@@ -1632,10 +1641,12 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
refreshMediaButtonCustomLayout()
+ CardWidgetProvider.update(this)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
scheduleSendingLyrics(false)
+ CardWidgetProvider.update(this)
lastPlayedManager.save()
}
@@ -1677,6 +1688,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis
}
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
refreshMediaButtonCustomLayout()
+ CardWidgetProvider.update(this)
if (needsMissingOnDestroyCallWorkarounds()) {
handler.post { lastPlayedManager.save() }
}
@@ -1684,6 +1696,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis
override fun onRepeatModeChanged(repeatMode: Int) {
refreshMediaButtonCustomLayout()
+ CardWidgetProvider.update(this)
if (needsMissingOnDestroyCallWorkarounds()) {
handler.post { lastPlayedManager.save() }
}
diff --git a/app/src/main/java/org/akanework/gramophone/ui/CardWidgetProvider.kt b/app/src/main/java/org/akanework/gramophone/ui/CardWidgetProvider.kt
new file mode 100644
index 0000000000..af93cb6f87
--- /dev/null
+++ b/app/src/main/java/org/akanework/gramophone/ui/CardWidgetProvider.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2026 Akane Foundation
+ *
+ * Gramophone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Gramophone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.akanework.gramophone.ui
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.KeyEvent
+import androidx.core.graphics.drawable.toBitmap
+import androidx.media3.common.HeartRating
+import coil3.BitmapImage
+import coil3.asDrawable
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import coil3.request.allowHardware
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.akanework.gramophone.logic.GramophonePlaybackService
+import org.akanework.gramophone.ui.widget.CardWidgetActions
+import org.akanework.gramophone.ui.widget.CardWidgetPlaybackState
+import org.akanework.gramophone.ui.widget.CardWidgetViewsBuilder
+
+class CardWidgetProvider : AppWidgetProvider() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ val action = intent.action ?: return
+ handleWidgetAction(context, action)
+ }
+
+ private fun handleWidgetAction(context: Context, action: String) {
+ val service = GramophonePlaybackService.instanceForWidgetAndLyricsOnly
+ val player = service?.endedWorkaroundPlayer
+ when (action) {
+ ACTION_PREVIOUS -> {
+ if (player != null) player.seekToPrevious()
+ else sendMediaButtonFallback(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
+ }
+ ACTION_PLAY_PAUSE -> {
+ if (player != null) {
+ if (player.isPlaying) player.pause() else player.play()
+ } else {
+ sendMediaButtonFallback(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
+ }
+ }
+ ACTION_NEXT -> {
+ if (player != null) player.seekToNext()
+ else sendMediaButtonFallback(context, KeyEvent.KEYCODE_MEDIA_NEXT)
+ }
+ ACTION_SHUFFLE -> {
+ if (player != null) {
+ player.shuffleModeEnabled = !player.shuffleModeEnabled
+ update(context)
+ }
+ }
+ ACTION_FAVORITE -> service?.toggleCurrentItemFavorite()
+ }
+ }
+
+ override fun onAppWidgetOptionsChanged(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ newOptions: Bundle?
+ ) {
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
+ onUpdate(context, appWidgetManager, intArrayOf(appWidgetId))
+ }
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ val state = buildCurrentPlaybackState()
+ val actions = buildWidgetActions(context)
+
+ for (appWidgetId in appWidgetIds) {
+ val initialViews = CardWidgetViewsBuilder.buildResponsiveRemoteViews(
+ context, appWidgetManager, appWidgetId, state, actions
+ )
+ appWidgetManager.updateAppWidget(appWidgetId, initialViews)
+
+ if (state.artworkUri != null && (state.artworkUri != cachedArtworkUri || cachedArtworkBitmap == null)) {
+ loadArtworkAndRefresh(context, appWidgetManager, appWidgetId, state, actions)
+ }
+ }
+ }
+
+ private fun buildCurrentPlaybackState(): CardWidgetPlaybackState {
+ val service = GramophonePlaybackService.instanceForWidgetAndLyricsOnly
+ val player = service?.endedWorkaroundPlayer
+ val mediaItem = player?.currentMediaItem
+ val artworkUri = mediaItem?.mediaMetadata?.artworkUri
+ val cachedBitmap = if (artworkUri != null && artworkUri == cachedArtworkUri) cachedArtworkBitmap else null
+
+ return CardWidgetPlaybackState(
+ title = mediaItem?.mediaMetadata?.title?.toString().orEmpty(),
+ artist = mediaItem?.mediaMetadata?.artist?.toString().orEmpty(),
+ isPlaying = player?.isPlaying == true,
+ isFavorite = (mediaItem?.mediaMetadata?.userRating as? HeartRating)?.isHeart == true,
+ isShuffle = player?.shuffleModeEnabled == true,
+ artworkUri = artworkUri,
+ artworkBitmap = cachedBitmap
+ )
+ }
+
+ private fun buildWidgetActions(context: Context): CardWidgetActions {
+ val openAppPi = PendingIntent.getActivity(
+ context,
+ 0,
+ Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ return CardWidgetActions(
+ openAppPi = openAppPi,
+ prevPi = buildActionPendingIntent(context, ACTION_PREVIOUS, 101),
+ playPausePi = buildActionPendingIntent(context, ACTION_PLAY_PAUSE, 102),
+ nextPi = buildActionPendingIntent(context, ACTION_NEXT, 103),
+ shufflePi = buildActionPendingIntent(context, ACTION_SHUFFLE, 104),
+ favoritePi = buildActionPendingIntent(context, ACTION_FAVORITE, 105)
+ )
+ }
+
+ private fun loadArtworkAndRefresh(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ state: CardWidgetPlaybackState,
+ actions: CardWidgetActions
+ ) {
+ val uri = state.artworkUri ?: return
+ CoroutineScope(Dispatchers.IO).launch {
+ val request = ImageRequest.Builder(context)
+ .data(uri)
+ .size(256, 256)
+ .allowHardware(false)
+ .build()
+ val result = context.imageLoader.execute(request)
+ val bitmap: Bitmap? = (result.image as? BitmapImage)?.bitmap
+ ?: result.image?.asDrawable(context.resources)?.toBitmap()
+
+ withContext(Dispatchers.Main) {
+ cachedArtworkUri = uri
+ cachedArtworkBitmap = bitmap
+ val updatedState = state.copy(artworkBitmap = bitmap)
+ val views = CardWidgetViewsBuilder.buildResponsiveRemoteViews(
+ context, appWidgetManager, appWidgetId, updatedState, actions
+ )
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+ }
+ }
+
+ private fun buildActionPendingIntent(
+ context: Context,
+ action: String,
+ requestCode: Int
+ ): PendingIntent {
+ val intent = Intent(context, CardWidgetProvider::class.java).apply {
+ this.action = action
+ }
+ return PendingIntent.getBroadcast(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ private fun sendMediaButtonFallback(context: Context, keyCode: Int) {
+ val serviceIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply {
+ setClass(context, GramophonePlaybackService::class.java)
+ putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
+ }
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(serviceIntent)
+ } else {
+ context.startService(serviceIntent)
+ }
+ } catch (e: Exception) {
+ Log.w("CardWidgetProvider", "Failed to start service for media button", e)
+ }
+ }
+
+ companion object {
+ const val ACTION_PREVIOUS = "org.akanework.gramophone.ACTION_CARD_WIDGET_PREVIOUS"
+ const val ACTION_PLAY_PAUSE = "org.akanework.gramophone.ACTION_CARD_WIDGET_PLAY_PAUSE"
+ const val ACTION_NEXT = "org.akanework.gramophone.ACTION_CARD_WIDGET_NEXT"
+ const val ACTION_SHUFFLE = "org.akanework.gramophone.ACTION_CARD_WIDGET_SHUFFLE"
+ const val ACTION_FAVORITE = "org.akanework.gramophone.ACTION_CARD_WIDGET_FAVORITE"
+
+ private var cachedArtworkUri: Uri? = null
+ private var cachedArtworkBitmap: Bitmap? = null
+
+ fun hasWidget(context: Context): Boolean {
+ val awm = AppWidgetManager.getInstance(context) ?: return false
+ return awm.getAppWidgetIds(ComponentName(context, CardWidgetProvider::class.java)).isNotEmpty()
+ }
+
+ fun update(context: Context) {
+ val awm = AppWidgetManager.getInstance(context)
+ if (awm != null) {
+ val ids = awm.getAppWidgetIds(ComponentName(context, CardWidgetProvider::class.java))
+ if (ids.isNotEmpty()) {
+ CardWidgetProvider().onUpdate(context, awm, ids)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetBitmapUtils.kt b/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetBitmapUtils.kt
new file mode 100644
index 0000000000..8737b98b92
--- /dev/null
+++ b/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetBitmapUtils.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2026 Akane Foundation
+ *
+ * Gramophone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Gramophone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.akanework.gramophone.ui.widget
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
+import android.graphics.RectF
+
+object CardWidgetBitmapUtils {
+
+ fun getRoundedBitmap(src: Bitmap, cornerRadiusPx: Float): Bitmap {
+ return try {
+ val width = src.width.coerceAtLeast(1)
+ val height = src.height.coerceAtLeast(1)
+ val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(output)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ val rect = RectF(0f, 0f, width.toFloat(), height.toFloat())
+ canvas.drawRoundRect(rect, cornerRadiusPx, cornerRadiusPx, paint)
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
+ canvas.drawBitmap(src, 0f, 0f, paint)
+ output
+ } catch (_: Throwable) {
+ src
+ }
+ }
+
+ fun getCircularBitmap(src: Bitmap): Bitmap {
+ return try {
+ val size = minOf(src.width, src.height).coerceAtLeast(1)
+ val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(output)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ val radius = size / 2f
+ canvas.drawCircle(radius, radius, radius, paint)
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
+ val left = (size - src.width) / 2f
+ val top = (size - src.height) / 2f
+ canvas.drawBitmap(src, left, top, paint)
+ output
+ } catch (_: Throwable) {
+ src
+ }
+ }
+}
diff --git a/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetState.kt b/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetState.kt
new file mode 100644
index 0000000000..638b931df8
--- /dev/null
+++ b/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetState.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2026 Akane Foundation
+ *
+ * Gramophone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Gramophone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.akanework.gramophone.ui.widget
+
+import android.app.PendingIntent
+import android.graphics.Bitmap
+import android.net.Uri
+
+data class CardWidgetPlaybackState(
+ val title: String = "",
+ val artist: String = "",
+ val isPlaying: Boolean = false,
+ val isFavorite: Boolean = false,
+ val isShuffle: Boolean = false,
+ val artworkUri: Uri? = null,
+ val artworkBitmap: Bitmap? = null
+)
+
+data class CardWidgetActions(
+ val openAppPi: PendingIntent,
+ val favoritePi: PendingIntent,
+ val prevPi: PendingIntent,
+ val playPausePi: PendingIntent,
+ val nextPi: PendingIntent,
+ val shufflePi: PendingIntent
+)
diff --git a/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetViewsBuilder.kt b/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetViewsBuilder.kt
new file mode 100644
index 0000000000..61505e3e05
--- /dev/null
+++ b/app/src/main/java/org/akanework/gramophone/ui/widget/CardWidgetViewsBuilder.kt
@@ -0,0 +1,288 @@
+/*
+ * Copyright (C) 2026 Akane Foundation
+ *
+ * Gramophone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Gramophone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.akanework.gramophone.ui.widget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.graphics.Bitmap
+import android.os.Build
+import android.util.SizeF
+import android.view.Gravity
+import android.view.View
+import android.widget.RemoteViews
+import androidx.preference.PreferenceManager
+import org.akanework.gramophone.R
+import org.akanework.gramophone.logic.dpToPx
+import kotlin.math.abs
+
+object CardWidgetViewsBuilder {
+
+ fun buildResponsiveRemoteViews(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ state: CardWidgetPlaybackState,
+ actions: CardWidgetActions
+ ): RemoteViews {
+ val pillSingle = buildPillViews(context, state, actions, showCover = false)
+ val pill = buildPillViews(context, state, actions, showCover = true)
+ val circle = buildCircleViews(context, state, actions)
+ val card = buildCardViews(context, state, actions, showPrevious = true, showNext = true)
+ val cardNarrow = buildCardViews(context, state, actions, showPrevious = false, showNext = true)
+ val medium = buildMediumViews(context, state, actions, showMoreButtons = false)
+ val mediumWide = buildMediumViews(context, state, actions, showMoreButtons = true)
+ val large = buildLargeViews(context, state, actions, showMoreButtons = false)
+ val largeWide = buildLargeViews(context, state, actions, showMoreButtons = true)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val viewMapping = mapOf(
+ SizeF(40f, 40f) to pillSingle,
+ SizeF(100f, 40f) to pill,
+ SizeF(190f, 40f) to card,
+ SizeF(90f, 90f) to circle,
+ SizeF(180f, 80f) to medium,
+ SizeF(280f, 80f) to mediumWide,
+ SizeF(180f, 170f) to large,
+ SizeF(280f, 170f) to largeWide
+ )
+ return RemoteViews(viewMapping)
+ } else {
+ return selectPreSLayout(appWidgetManager, appWidgetId, pillSingle, pill, card, circle, medium, mediumWide, large, largeWide)
+ }
+ }
+
+ private fun selectPreSLayout(
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ pillSingle: RemoteViews,
+ pill: RemoteViews,
+ card: RemoteViews,
+ circle: RemoteViews,
+ medium: RemoteViews,
+ mediumWide: RemoteViews,
+ large: RemoteViews,
+ largeWide: RemoteViews
+ ): RemoteViews {
+ val options = try {
+ appWidgetManager.getAppWidgetOptions(appWidgetId)
+ } catch (_: Exception) {
+ null
+ }
+ val minWidth = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0) ?: 0
+ val minHeight = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0) ?: 0
+
+ return when {
+ minHeight < 75 && minWidth in 1..99 -> pillSingle
+ minHeight < 75 && minWidth in 100..189 -> pill
+ minHeight < 75 && minWidth >= 190 -> card
+ minWidth in 75..220 && minHeight in 75..220 && abs(minWidth - minHeight) < 50 -> circle
+ minHeight in 75..165 && minWidth >= 190 -> if (minWidth >= 280) mediumWide else medium
+ minHeight >= 165 && minWidth >= 190 -> if (minWidth >= 280) largeWide else large
+ minWidth < 190 && minHeight < 75 -> pill
+ minWidth < 190 -> circle
+ else -> card
+ }
+ }
+
+ fun buildPillViews(
+ context: Context,
+ state: CardWidgetPlaybackState,
+ actions: CardWidgetActions,
+ showCover: Boolean = true
+ ): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.card_widget_pill).apply {
+ applyPlayPauseControl(this, context, state.isPlaying, actions.playPausePi)
+ setOnClickPendingIntent(R.id.widget_card_root, actions.openAppPi)
+ setOnClickPendingIntent(R.id.widget_cover, actions.openAppPi)
+
+ if (showCover) {
+ setViewVisibility(R.id.widget_cover, View.VISIBLE)
+ applyArtwork(this, state.artworkBitmap, R.id.widget_cover, isCircular = true)
+ } else {
+ setViewVisibility(R.id.widget_cover, View.GONE)
+ }
+ }
+ }
+
+ fun buildCircleViews(
+ context: Context,
+ state: CardWidgetPlaybackState,
+ actions: CardWidgetActions
+ ): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.card_widget_circle).apply {
+ applyPlayPauseControl(this, context, state.isPlaying, actions.playPausePi)
+ setOnClickPendingIntent(R.id.widget_card_root, actions.openAppPi)
+ setOnClickPendingIntent(R.id.widget_cover, actions.openAppPi)
+ applyArtwork(this, state.artworkBitmap, R.id.widget_cover, isCircular = true)
+ }
+ }
+
+ fun buildCardViews(
+ context: Context,
+ state: CardWidgetPlaybackState,
+ actions: CardWidgetActions,
+ showPrevious: Boolean = true,
+ showNext: Boolean = true
+ ): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.card_widget).apply {
+ setTextViewText(R.id.widget_title, state.title)
+ setTextViewText(R.id.widget_artist, state.artist)
+ applyPlayPauseControl(this, context, state.isPlaying, actions.playPausePi)
+ setOnClickPendingIntent(R.id.widget_card_root, actions.openAppPi)
+ setOnClickPendingIntent(R.id.widget_cover, actions.openAppPi)
+
+ applyNavControls(this, showPrevious, actions.prevPi, showNext, actions.nextPi)
+ applyArtwork(this, state.artworkBitmap, R.id.widget_cover, cornerRadiusPx = 12.dpToPx(context).toFloat())
+ }
+ }
+
+ fun buildMediumViews(
+ context: Context,
+ state: CardWidgetPlaybackState,
+ actions: CardWidgetActions,
+ showMoreButtons: Boolean,
+ showPrevious: Boolean = true,
+ showNext: Boolean = true
+ ): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.card_widget_medium).apply {
+ setTextViewText(R.id.widget_title, state.title)
+ setTextViewText(R.id.widget_artist, state.artist)
+ applyPlayPauseControl(this, context, state.isPlaying, actions.playPausePi)
+ setOnClickPendingIntent(R.id.widget_card_root, actions.openAppPi)
+ setOnClickPendingIntent(R.id.widget_cover, actions.openAppPi)
+
+ applyMoreButtons(this, showMoreButtons, state.isFavorite, actions.favoritePi, state.isShuffle, actions.shufflePi)
+ applyNavControls(this, showPrevious, actions.prevPi, showNext, actions.nextPi)
+ applyArtwork(this, state.artworkBitmap, R.id.widget_cover, cornerRadiusPx = 12.dpToPx(context).toFloat())
+ }
+ }
+
+ fun buildLargeViews(
+ context: Context,
+ state: CardWidgetPlaybackState,
+ actions: CardWidgetActions,
+ showMoreButtons: Boolean,
+ showPrevious: Boolean = true,
+ showNext: Boolean = true
+ ): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.card_widget_large).apply {
+ setTextViewText(R.id.widget_title, state.title)
+ setTextViewText(R.id.widget_artist, state.artist)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ val isCentered = prefs.getBoolean("centered_title", false)
+ val gravity = if (isCentered) Gravity.CENTER else (Gravity.START or Gravity.CENTER_VERTICAL)
+ setInt(R.id.widget_title, "setGravity", gravity)
+ setInt(R.id.widget_artist, "setGravity", gravity)
+ }
+
+ applyPlayPauseControl(this, context, state.isPlaying, actions.playPausePi)
+ setOnClickPendingIntent(R.id.widget_card_root, actions.openAppPi)
+ setOnClickPendingIntent(R.id.widget_cover, actions.openAppPi)
+
+ applyMoreButtons(this, showMoreButtons, state.isFavorite, actions.favoritePi, state.isShuffle, actions.shufflePi)
+ applyNavControls(this, showPrevious, actions.prevPi, showNext, actions.nextPi)
+ applyArtwork(this, state.artworkBitmap, R.id.widget_cover, cornerRadiusPx = 12.dpToPx(context).toFloat())
+ }
+ }
+
+ private fun applyPlayPauseControl(
+ views: RemoteViews,
+ context: Context,
+ isPlaying: Boolean,
+ playPausePi: PendingIntent
+ ) {
+ views.setImageViewResource(
+ R.id.widget_play_pause,
+ if (isPlaying) R.drawable.ic_pause_filled else R.drawable.ic_play_arrow_filled
+ )
+ views.setContentDescription(
+ R.id.widget_play_pause,
+ context.getString(if (isPlaying) R.string.pause else R.string.play)
+ )
+ views.setOnClickPendingIntent(R.id.widget_play_pause, playPausePi)
+ }
+
+ private fun applyArtwork(
+ views: RemoteViews,
+ bitmap: Bitmap?,
+ viewId: Int,
+ cornerRadiusPx: Float = 0f,
+ isCircular: Boolean = false
+ ) {
+ if (bitmap != null) {
+ val processed = if (isCircular) {
+ CardWidgetBitmapUtils.getCircularBitmap(bitmap)
+ } else {
+ CardWidgetBitmapUtils.getRoundedBitmap(bitmap, cornerRadiusPx)
+ }
+ views.setImageViewBitmap(viewId, processed)
+ } else {
+ views.setImageViewResource(viewId, R.drawable.ic_default_cover)
+ }
+ }
+
+ private fun applyNavControls(
+ views: RemoteViews,
+ showPrevious: Boolean,
+ prevPi: PendingIntent,
+ showNext: Boolean,
+ nextPi: PendingIntent
+ ) {
+ if (showPrevious) {
+ views.setViewVisibility(R.id.widget_previous, View.VISIBLE)
+ views.setOnClickPendingIntent(R.id.widget_previous, prevPi)
+ } else {
+ views.setViewVisibility(R.id.widget_previous, View.GONE)
+ }
+
+ if (showNext) {
+ views.setViewVisibility(R.id.widget_next, View.VISIBLE)
+ views.setOnClickPendingIntent(R.id.widget_next, nextPi)
+ } else {
+ views.setViewVisibility(R.id.widget_next, View.GONE)
+ }
+ }
+
+ private fun applyMoreButtons(
+ views: RemoteViews,
+ showMore: Boolean,
+ isFavorite: Boolean,
+ favoritePi: PendingIntent,
+ isShuffle: Boolean,
+ shufflePi: PendingIntent
+ ) {
+ if (showMore) {
+ views.setViewVisibility(R.id.widget_favorite, View.VISIBLE)
+ views.setViewVisibility(R.id.widget_shuffle, View.VISIBLE)
+ views.setImageViewResource(
+ R.id.widget_favorite,
+ if (isFavorite) R.drawable.ic_favorite_filled else R.drawable.ic_favorite
+ )
+ views.setInt(R.id.widget_favorite, "setImageAlpha", if (isFavorite) 255 else 180)
+ views.setImageViewResource(R.id.widget_shuffle, R.drawable.ic_shuffle)
+ views.setInt(R.id.widget_shuffle, "setImageAlpha", if (isShuffle) 255 else 90)
+ views.setOnClickPendingIntent(R.id.widget_favorite, favoritePi)
+ views.setOnClickPendingIntent(R.id.widget_shuffle, shufflePi)
+ } else {
+ views.setViewVisibility(R.id.widget_favorite, View.GONE)
+ views.setViewVisibility(R.id.widget_shuffle, View.GONE)
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/widget_card_background.xml b/app/src/main/res/drawable/widget_card_background.xml
new file mode 100644
index 0000000000..68da970751
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_control_background.xml b/app/src/main/res/drawable/widget_card_control_background.xml
new file mode 100644
index 0000000000..654943911a
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_control_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_cover_background.xml b/app/src/main/res/drawable/widget_card_cover_background.xml
new file mode 100644
index 0000000000..6979f97dc7
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_cover_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_play_background.xml b/app/src/main/res/drawable/widget_card_play_background.xml
new file mode 100644
index 0000000000..4d6a435b15
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_play_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_circle_background.xml b/app/src/main/res/drawable/widget_circle_background.xml
new file mode 100644
index 0000000000..afb448a3fb
--- /dev/null
+++ b/app/src/main/res/drawable/widget_circle_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_pill_background.xml b/app/src/main/res/drawable/widget_pill_background.xml
new file mode 100644
index 0000000000..f1f7023b8c
--- /dev/null
+++ b/app/src/main/res/drawable/widget_pill_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_rounded_square_play_background.xml b/app/src/main/res/drawable/widget_rounded_square_play_background.xml
new file mode 100644
index 0000000000..875558ae12
--- /dev/null
+++ b/app/src/main/res/drawable/widget_rounded_square_play_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/card_widget.xml b/app/src/main/res/layout/card_widget.xml
new file mode 100644
index 0000000000..aef4ec9d71
--- /dev/null
+++ b/app/src/main/res/layout/card_widget.xml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/card_widget_circle.xml b/app/src/main/res/layout/card_widget_circle.xml
new file mode 100644
index 0000000000..d41fba11fa
--- /dev/null
+++ b/app/src/main/res/layout/card_widget_circle.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/card_widget_large.xml b/app/src/main/res/layout/card_widget_large.xml
new file mode 100644
index 0000000000..b3f936bdb2
--- /dev/null
+++ b/app/src/main/res/layout/card_widget_large.xml
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/card_widget_medium.xml b/app/src/main/res/layout/card_widget_medium.xml
new file mode 100644
index 0000000000..8f6650e507
--- /dev/null
+++ b/app/src/main/res/layout/card_widget_medium.xml
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/card_widget_pill.xml b/app/src/main/res/layout/card_widget_pill.xml
new file mode 100644
index 0000000000..902cf37bc0
--- /dev/null
+++ b/app/src/main/res/layout/card_widget_pill.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml
new file mode 100644
index 0000000000..1952c5dbc2
--- /dev/null
+++ b/app/src/main/res/values-night-v31/colors.xml
@@ -0,0 +1,11 @@
+
+
+ @android:color/system_neutral1_800
+ @android:color/system_neutral2_800
+ @android:color/system_neutral2_700
+ @android:color/system_accent1_200
+ @android:color/system_accent1_800
+ @android:color/system_neutral1_100
+ @android:color/system_neutral2_200
+ @android:color/system_neutral2_700
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 89484b5558..0d5bdb6c9c 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -46,4 +46,12 @@
#272A2F
#32353A
@color/md_theme_onSurfaceVariant
+ @color/md_theme_surfaceContainer
+ @color/md_theme_surfaceContainerHigh
+ @color/md_theme_surfaceContainerHighest
+ @color/md_theme_primary
+ @color/md_theme_onPrimary
+ @color/md_theme_onSurface
+ @color/md_theme_onSurfaceVariant
+ @color/md_theme_surfaceContainerHighest
diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml
new file mode 100644
index 0000000000..8501150023
--- /dev/null
+++ b/app/src/main/res/values-v31/colors.xml
@@ -0,0 +1,11 @@
+
+
+ @android:color/system_neutral1_100
+ @android:color/system_neutral2_100
+ @android:color/system_neutral2_200
+ @android:color/system_accent1_600
+ @android:color/system_accent1_0
+ @android:color/system_neutral1_900
+ @android:color/system_neutral2_700
+ @android:color/system_neutral2_200
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 9922260080..4dc8441518 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -157,6 +157,9 @@
启用魅族状态栏歌词(仅适用于部分设备)
启用新歌词界面
显示当前播放媒体歌词的小部件
+ 卡片小组件
+ 提供音乐播放控制的桌面小组件
+ 暂停
滚动到专辑
滚动到歌曲
关闭
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 7d46a52bd8..e51a4429d1 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -46,4 +46,12 @@
#E7E8EE
#E1E2E8
#363644
+ @color/md_theme_surfaceContainer
+ @color/md_theme_surfaceContainerHigh
+ @color/md_theme_surfaceContainerHighest
+ @color/md_theme_primary
+ @color/md_theme_onPrimary
+ @color/md_theme_onSurface
+ @color/md_theme_onSurfaceVariant
+ @color/md_theme_surfaceContainerHighest
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7c3d235619..6fcb19d853 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -398,6 +398,9 @@
Display lyrics as the notification title and artist - title as subtitle
Widget that shows lyrics of currently playing media
+ Card widget
+ Widget that provides music playback controls
+ Pause
Always skip to previous track
diff --git a/app/src/main/res/xml/card_widget.xml b/app/src/main/res/xml/card_widget.xml
new file mode 100644
index 0000000000..610b688665
--- /dev/null
+++ b/app/src/main/res/xml/card_widget.xml
@@ -0,0 +1,11 @@
+
+