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 @@ + +