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/components/FullBottomSheet.kt b/app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt
index 6b73482706..2b71312ad0 100644
--- a/app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt
+++ b/app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt
@@ -34,16 +34,20 @@ import android.os.Parcelable
import android.text.format.DateFormat
import android.util.AttributeSet
import android.view.AbsSavedState
+import android.view.GestureDetector
import android.view.Gravity
import android.view.KeyEvent
+import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.view.WindowInsets
+import android.view.animation.LinearInterpolator
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.EditText
+import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.SeekBar
@@ -57,6 +61,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.animation.addListener
import androidx.core.animation.doOnEnd
import androidx.core.content.edit
+import kotlin.math.abs
import androidx.core.graphics.Insets
import androidx.core.graphics.TypefaceCompat
import androidx.core.os.BundleCompat
@@ -167,6 +172,8 @@ class FullBottomSheet
private var runnableRunning = false
private var firstTime = false
private var enableQualityInfo = false
+ private var rotateCookieButton = false
+ private var buttonRotationAnimator: ValueAnimator? = null
private val prefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
private var currentFormat: AudioFormatDetector.AudioFormats? = null
@@ -252,6 +259,7 @@ class FullBottomSheet
private val bottomSheetFullTitle: TextView
private val bottomSheetFullSubtitle: TextView
private val bottomSheetFullControllerButton: MaterialButton
+ private val bottomSheetFullControllerButtonBg: ImageView?
private val bottomSheetFullNextButton: MaterialButton
private val bottomSheetFullPreviousButton: MaterialButton
private val bottomSheetFullDuration: TextView
@@ -269,9 +277,13 @@ class FullBottomSheet
private val bottomSheetFullSlider: Slider
private val bottomSheetFullCoverFrame: MaterialCardView
val bottomSheetFullLyricView: LyricsView by lazy { (parent as ViewGroup).findViewById(R.id.lyric_frame)!! }
- private val progressDrawable: SquigglyProgress
+ private lateinit var progressDrawable: SquigglyProgress
private var pqs: PlaylistQueueSheet? = null
+ private var coverTouchStartX = 0f
+ private var coverTouchStartY = 0f
+ private var coverIsHorizontalSwipe = false
+
init {
inflate(context, R.layout.full_player, this)
bottomSheetFullCoverFrame = findViewById(R.id.album_cover_frame)
@@ -280,6 +292,7 @@ class FullBottomSheet
bottomSheetFullSubtitle = findViewById(R.id.full_song_artist)
bottomSheetFullPreviousButton = findViewById(R.id.sheet_previous_song)
bottomSheetFullControllerButton = findViewById(R.id.sheet_mid_button)
+ bottomSheetFullControllerButtonBg = findViewById(R.id.sheet_mid_button_bg)
bottomSheetFullNextButton = findViewById(R.id.sheet_next_song)
bottomSheetFullPosition = findViewById(R.id.position)
bottomSheetFullDuration = findViewById(R.id.duration)
@@ -294,51 +307,103 @@ class FullBottomSheet
bottomSheetPlaylistButton = findViewById(R.id.playlist)
bottomSheetLyricButton = findViewById(R.id.lyrics)
bottomSheetFullQualityDetails = findViewById(R.id.quality_details)
+
+ setupCoverTouchListener()
+ setupSeekBarAndSlider()
+ setupControlButtons()
+ setupCardAndDialogListeners()
+ setupCustomCommandListeners()
+
refreshSettings(null)
prefs.registerOnSharedPreferenceChangeListener(this)
- activity.controllerViewModel.customCommandListeners.addCallback(activity.lifecycle) { _, command, _ ->
- when (command.customAction) {
- GramophonePlaybackService.SERVICE_TIMER_CHANGED -> updateTimer()
- GramophonePlaybackService.SERVICE_GET_LYRICS -> {
- val parsedLyrics = instance?.getLyrics()
- bottomSheetFullLyricView.updateLyrics(parsedLyrics)
- }
+ val colorSecondaryContainer = MaterialColors.getColor(
+ context,
+ com.google.android.material.R.attr.colorSecondaryContainer,
+ -1
+ )
+ val colorSurface = MaterialColors.getColor(
+ context,
+ com.google.android.material.R.attr.colorSurface,
+ -1
+ )
+ val backgroundProcessedColor = ColorUtils.getColor(
+ colorSurface,
+ ColorUtils.ColorType.COLOR_BACKGROUND_ELEVATED,
+ context
+ )
+ val colorContrastFainted = ColorUtils.getColor(
+ colorSecondaryContainer,
+ ColorUtils.ColorType.COLOR_CONTRAST_FAINTED,
+ context
+ )
+ setBackgroundColor(backgroundProcessedColor)
+ bottomSheetFullSlider.trackInactiveTintList = ColorStateList.valueOf(colorContrastFainted)
- GramophonePlaybackService.SERVICE_GET_AUDIO_FORMAT -> {
- val format = instance?.getAudioFormat()
- this.currentFormat = format
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
- !handler.hasCallbacks(formatUpdateRunnable)
- ) {
- // TODO: is 300ms long enough wait for stuff like bitrate? 100ms isn't.
- handler.postDelayed(formatUpdateRunnable, 300)
+ activity.controllerViewModel.addRecreationalPlayerListener(activity.lifecycle, this) {
+ firstTime = true
+ updateTimer()
+ onRepeatModeChanged(instance?.repeatMode ?: Player.REPEAT_MODE_OFF)
+ onShuffleModeEnabledChanged(instance?.shuffleModeEnabled == true)
+ onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE)
+ onMediaItemTransition(
+ instance?.currentMediaItem,
+ Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED
+ )
+ onMediaMetadataChanged(instance?.mediaMetadata ?: MediaMetadata.EMPTY)
+ firstTime = false
+ }
+ }
+
+ private fun setupCoverTouchListener() {
+ bottomSheetFullCover.setOnTouchListener { v, event ->
+ val enabled = prefs.getBoolean("swipe_to_switch_track", true)
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ coverTouchStartX = event.rawX
+ coverTouchStartY = event.rawY
+ coverIsHorizontalSwipe = false
+ parent?.requestDisallowInterceptTouchEvent(true)
+ }
+ MotionEvent.ACTION_MOVE -> {
+ val dx = abs(event.rawX - coverTouchStartX)
+ val dy = abs(event.rawY - coverTouchStartY)
+ if (dx > 20.dpToPx(context) && dx > dy) {
+ coverIsHorizontalSwipe = true
+ parent?.requestDisallowInterceptTouchEvent(true)
}
}
-
- else -> {
- return@addCallback Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
+ MotionEvent.ACTION_UP -> {
+ val dx = event.rawX - coverTouchStartX
+ val dy = event.rawY - coverTouchStartY
+ if (enabled && abs(dx) > abs(dy) && abs(dx) > 50.dpToPx(context)) {
+ if (dx > 0) {
+ instance?.seekToPrevious()
+ } else {
+ instance?.seekToNext()
+ }
+ return@setOnTouchListener true
+ } else if (!coverIsHorizontalSwipe) {
+ v.performClick()
+ }
+ }
+ MotionEvent.ACTION_CANCEL -> {
+ coverIsHorizontalSwipe = false
}
}
- return@addCallback Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
+ true
}
+ }
- val seekBarProgressWavelength =
- context.resources
- .getDimensionPixelSize(R.dimen.media_seekbar_progress_wavelength)
- .toFloat()
- val seekBarProgressAmplitude =
- context.resources
- .getDimensionPixelSize(R.dimen.media_seekbar_progress_amplitude)
- .toFloat()
- val seekBarProgressPhase =
- context.resources
- .getDimensionPixelSize(R.dimen.media_seekbar_progress_phase)
- .toFloat()
- val seekBarProgressStrokeWidth =
- context.resources
- .getDimensionPixelSize(R.dimen.media_seekbar_progress_stroke_width)
- .toFloat()
+ private fun setupSeekBarAndSlider() {
+ val seekBarProgressWavelength = context.resources
+ .getDimensionPixelSize(R.dimen.media_seekbar_progress_wavelength).toFloat()
+ val seekBarProgressAmplitude = context.resources
+ .getDimensionPixelSize(R.dimen.media_seekbar_progress_amplitude).toFloat()
+ val seekBarProgressPhase = context.resources
+ .getDimensionPixelSize(R.dimen.media_seekbar_progress_phase).toFloat()
+ val seekBarProgressStrokeWidth = context.resources
+ .getDimensionPixelSize(R.dimen.media_seekbar_progress_stroke_width).toFloat()
bottomSheetFullSeekBar.progressDrawable = SquigglyProgress().also {
progressDrawable = it
@@ -350,12 +415,88 @@ class FullBottomSheet
it.animate = false
}
+ bottomSheetFullSlider.addOnChangeListener { _, value, isUser ->
+ if (isUser) {
+ val dest = instance?.mediaMetadata?.durationMs
+ if (dest != null) {
+ bottomSheetFullPosition.text =
+ CalculationUtils.convertDurationToTimeStamp(value.toLong())
+ }
+ }
+ }
+ bottomSheetFullSeekBar.setOnSeekBarChangeListener(touchListener)
+ bottomSheetFullSlider.addOnSliderTouchListener(touchListener)
+ }
+
+ private fun setupControlButtons() {
+ bottomSheetFullControllerButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ instance?.playOrPause()
+ }
+ bottomSheetFullPreviousButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ instance?.seekToPrevious()
+ }
+ bottomSheetFullPreviousButton.setOnLongClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.LONG_PRESS)
+ instance?.seekBack()
+ true
+ }
+ bottomSheetFullNextButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ instance?.seekToNext()
+ }
+ bottomSheetFullNextButton.setOnLongClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.LONG_PRESS)
+ instance?.seekForward()
+ true
+ }
+ bottomSheetShuffleButton.addOnCheckedChangeListener { _, isChecked ->
+ instance?.shuffleModeEnabled = isChecked
+ }
+ bottomSheetShuffleButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ }
+ bottomSheetLoopButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ instance?.repeatMode = when (instance?.repeatMode) {
+ Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
+ Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
+ Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_OFF
+ else -> throw IllegalStateException()
+ }
+ }
+ bottomSheetPlaybackSpeedButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ if (instance != null) showPlaybackSpeedDialog()
+ }
+ bottomSheetFavoriteButton.addOnCheckedChangeListener(this)
+ bottomSheetPlaylistButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ if (instance != null) {
+ pqs = PlaylistQueueSheet(wrappedContext ?: context, activity).also { it.show() }
+ }
+ }
+ bottomSheetFullSlideUpButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ minimize?.invoke()
+ }
+ bottomSheetLyricButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ bottomSheetFullLyricView.fadInAnimation(LYRIC_FADE_TRANSITION_SEC)
+ }
+ bottomSheetTimerButton.setOnClickListener {
+ ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ showTimerDialog()
+ }
+ }
+
+ private fun setupCardAndDialogListeners() {
bottomSheetFullCover.setOnClickListener {
activity.startFragment(DetailDialogFragment()) {
putString("Id", instance?.currentMediaItem?.mediaId)
}
}
-
bottomSheetFullTitle.setOnClickListener {
minimize?.invoke()
activity.startFragment(GeneralSubFragment()) {
@@ -363,7 +504,6 @@ class FullBottomSheet
putInt("Item", R.id.album)
}
}
-
if (Flags.FORMAT_INFO_DIALOG) {
bottomSheetFullQualityDetails.setOnClickListener {
MaterialAlertDialogBuilder(wrappedContext ?: context)
@@ -376,7 +516,6 @@ class FullBottomSheet
.show()
}
}
-
bottomSheetFullSubtitle.setOnClickListener {
minimize?.invoke()
activity.startFragment(ArtistSubFragment()) {
@@ -384,213 +523,133 @@ class FullBottomSheet
putInt("Item", R.id.artist)
}
}
+ }
- bottomSheetTimerButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- val t = instance?.getTimer()
- val currentText = if (t?.first != null) context.getString(R.string.timer_expiry,
- DateFormat.getTimeFormat(context).format(System.currentTimeMillis() + t.first!!)
- ) else if (t?.second == true) context.getString(R.string.timer_expiry_end_of_this_song)
- else null
- if (currentText != null) {
- val dialog = MaterialAlertDialogBuilder(wrappedContext ?: context)
- .setTitle(R.string.timer)
- .setView(R.layout.dialog_sleep_timer_active)
- .setNeutralButton(R.string.unset) { _, _ ->
- instance?.setTimer(0, false)
- }
- .setPositiveButton(android.R.string.ok) { _, _ -> }
- .show()
- dialog.findViewById(R.id.textView)!!.text = currentText
- dialog.findViewById(R.id.checkBox)!!.let {
- if (t!!.first == null) {
- it.visibility = GONE
- } else {
- it.isChecked = t.second
- it.setOnCheckedChangeListener { _, value ->
- val newTime = instance?.getTimer()
- instance?.setTimer(newTime?.first ?: 0, value)
- }
- }
+ private fun setupCustomCommandListeners() {
+ activity.controllerViewModel.customCommandListeners.addCallback(activity.lifecycle) { _, command, _ ->
+ when (command.customAction) {
+ GramophonePlaybackService.SERVICE_TIMER_CHANGED -> updateTimer()
+ GramophonePlaybackService.SERVICE_GET_LYRICS -> {
+ val parsedLyrics = instance?.getLyrics()
+ bottomSheetFullLyricView.updateLyrics(parsedLyrics)
}
- } else {
- val dialog = MaterialAlertDialogBuilder(wrappedContext ?: context)
- .setTitle(R.string.timer)
- .setView(R.layout.dialog_sleep_timer)
- .setNegativeButton(android.R.string.cancel) { _, _ -> }
- .show()
- val lv = dialog.findViewById(R.id.listView)!!
- val checkbox = dialog.findViewById(R.id.checkBox)!!
- checkbox.isChecked = prefs.getBooleanStrict("lastTimerEos", false)
- val minutes = listOf(0, 1, 3, 5, 10, 15, 20, 30, 45, 60, 90)
- val items = minutes.map {
- if (it > 0)
- context.resources.getQuantityString(
- R.plurals.minutes, it,
- it
- )
- else
- context.resources.getString(R.string.timer_end_of_this_song)
- } + context.resources.getString(R.string.other)
- lv.adapter = ArrayAdapter(
- context, android.R.layout.simple_list_item_1,
- items
- )
- lv.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
- dialog.dismiss()
- if (position == minutes.size) {
- lateinit var et: EditText
- lateinit var cb: CheckBox
- // TODO find out why wrapped context does not work
- val dialog2 = MaterialAlertDialogBuilder(context)
- .setTitle(R.string.timer)
- .setView(R.layout.dialog_sleep_timer_custom)
- .setPositiveButton(android.R.string.ok) { _, _ ->
- try {
- instance?.setTimer((NumberFormat.getInstance().parse(
- et.editableText.toString())!!.toFloat() * 60f *
- 1000f).toInt(), cb.isChecked)
- } catch (_: ParseException) {
- // race condition with button enable
- }
- }
- .setNegativeButton(android.R.string.cancel) { _, _ -> }
- .show()
- val b = dialog2.getButton(DialogInterface.BUTTON_POSITIVE)
- b.isEnabled = false
- et = dialog2.findViewById(R.id.editText)!!
- et.addTextChangedListener {
- b.isEnabled = try {
- NumberFormat.getInstance().parse(it.toString())!!.toFloat()
- true
- } catch (_: ParseException) {
- false
- }
- }
- cb = dialog2.findViewById(R.id.checkBox)!!
- cb.isChecked = checkbox.isChecked
- } else {
- val duration = minutes[position] * 60 * 1000
- val eos = duration == 0 || checkbox.isChecked
- instance?.setTimer(duration, eos)
+ GramophonePlaybackService.SERVICE_GET_AUDIO_FORMAT -> {
+ val format = instance?.getAudioFormat()
+ this.currentFormat = format
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
+ !handler.hasCallbacks(formatUpdateRunnable)
+ ) {
+ handler.postDelayed(formatUpdateRunnable, 300)
}
}
+ else -> {
+ return@addCallback Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
+ }
}
+ return@addCallback Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
+ }
- bottomSheetLoopButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- instance?.repeatMode = when (instance?.repeatMode) {
- Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
- Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
- Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_OFF
- else -> throw IllegalStateException()
- }
+ private fun showTimerDialog() {
+ val t = instance?.getTimer()
+ if (t?.first != null || t?.second == true) {
+ showActiveTimerDialog(t)
+ } else {
+ showInactiveTimerDialog()
}
+ }
- bottomSheetPlaybackSpeedButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- if (instance != null)
- showPlaybackSpeedDialog()
+ private fun showActiveTimerDialog(t: Pair) {
+ val currentText = if (t.first != null) {
+ context.getString(
+ R.string.timer_expiry,
+ DateFormat.getTimeFormat(context).format(System.currentTimeMillis() + t.first!!)
+ )
+ } else {
+ context.getString(R.string.timer_expiry_end_of_this_song)
}
- bottomSheetFavoriteButton.addOnCheckedChangeListener(this)
-
- bottomSheetPlaylistButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- if (instance != null)
- pqs = PlaylistQueueSheet(wrappedContext ?: context, activity).also { it.show() }
- }
- bottomSheetFullControllerButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- instance?.playOrPause()
- }
- bottomSheetFullPreviousButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- instance?.seekToPrevious()
- }
- bottomSheetFullPreviousButton.setOnLongClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.LONG_PRESS)
- instance?.seekBack()
- true
- }
- bottomSheetFullNextButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- instance?.seekToNext()
- }
- bottomSheetFullNextButton.setOnLongClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.LONG_PRESS)
- instance?.seekForward()
- true
- }
- bottomSheetShuffleButton.addOnCheckedChangeListener { _, isChecked ->
- instance?.shuffleModeEnabled = isChecked
- }
+ val dialog = MaterialAlertDialogBuilder(wrappedContext ?: context)
+ .setTitle(R.string.timer)
+ .setView(R.layout.dialog_sleep_timer_active)
+ .setNeutralButton(R.string.unset) { _, _ ->
+ instance?.setTimer(0, false)
+ }
+ .setPositiveButton(android.R.string.ok) { _, _ -> }
+ .show()
- bottomSheetFullSlider.addOnChangeListener { _, value, isUser ->
- if (isUser) {
- val dest = instance?.mediaMetadata?.durationMs
- if (dest != null) {
- bottomSheetFullPosition.text =
- CalculationUtils.convertDurationToTimeStamp((value).toLong())
+ dialog.findViewById(R.id.textView)!!.text = currentText
+ dialog.findViewById(R.id.checkBox)!!.let {
+ if (t.first == null) {
+ it.visibility = GONE
+ } else {
+ it.isChecked = t.second
+ it.setOnCheckedChangeListener { _, value ->
+ val newTime = instance?.getTimer()
+ instance?.setTimer(newTime?.first ?: 0, value)
}
}
}
+ }
- bottomSheetFullSeekBar.setOnSeekBarChangeListener(touchListener)
- bottomSheetFullSlider.addOnSliderTouchListener(touchListener)
-
- bottomSheetFullSlideUpButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- minimize?.invoke()
- }
-
- bottomSheetLyricButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
- bottomSheetFullLyricView.fadInAnimation(LYRIC_FADE_TRANSITION_SEC)
- }
+ private fun showInactiveTimerDialog() {
+ val dialog = MaterialAlertDialogBuilder(wrappedContext ?: context)
+ .setTitle(R.string.timer)
+ .setView(R.layout.dialog_sleep_timer)
+ .setNegativeButton(android.R.string.cancel) { _, _ -> }
+ .show()
- bottomSheetShuffleButton.setOnClickListener {
- ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
+ val lv = dialog.findViewById(R.id.listView)!!
+ val checkbox = dialog.findViewById(R.id.checkBox)!!
+ checkbox.isChecked = prefs.getBooleanStrict("lastTimerEos", false)
+ val minutes = listOf(0, 1, 3, 5, 10, 15, 20, 30, 45, 60, 90)
+ val items = minutes.map {
+ if (it > 0) context.resources.getQuantityString(R.plurals.minutes, it, it)
+ else context.resources.getString(R.string.timer_end_of_this_song)
+ } + context.resources.getString(R.string.other)
+
+ lv.adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, items)
+ lv.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
+ dialog.dismiss()
+ if (position == minutes.size) {
+ showCustomTimerDialog(checkbox)
+ } else {
+ val duration = minutes[position] * 60 * 1000
+ val eos = duration == 0 || checkbox.isChecked
+ instance?.setTimer(duration, eos)
+ }
}
+ }
- val colorSecondaryContainer =
- MaterialColors.getColor(
- context,
- com.google.android.material.R.attr.colorSecondaryContainer,
- -1
- )
- val colorSurface = MaterialColors.getColor(
- context,
- com.google.android.material.R.attr.colorSurface,
- -1
- )
- val backgroundProcessedColor = ColorUtils.getColor(
- colorSurface,
- ColorUtils.ColorType.COLOR_BACKGROUND_ELEVATED,
- context
- )
- val colorContrastFainted = ColorUtils.getColor(
- colorSecondaryContainer,
- ColorUtils.ColorType.COLOR_CONTRAST_FAINTED,
- context
- )
- setBackgroundColor(backgroundProcessedColor)
- bottomSheetFullSlider.trackInactiveTintList = ColorStateList.valueOf(colorContrastFainted)
+ private fun showCustomTimerDialog(baseCheckbox: CheckBox) {
+ lateinit var et: EditText
+ lateinit var cb: CheckBox
+ val customDialog = MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.timer)
+ .setView(R.layout.dialog_sleep_timer_custom)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ try {
+ val parsed = NumberFormat.getInstance().parse(et.editableText.toString())!!.toFloat()
+ instance?.setTimer((parsed * 60f * 1000f).toInt(), cb.isChecked)
+ } catch (_: ParseException) {
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ -> }
+ .show()
- activity.controllerViewModel.addRecreationalPlayerListener(activity.lifecycle, this) {
- firstTime = true
- updateTimer()
- onRepeatModeChanged(instance?.repeatMode ?: Player.REPEAT_MODE_OFF)
- onShuffleModeEnabledChanged(instance?.shuffleModeEnabled == true)
- onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE)
- onMediaItemTransition(
- instance?.currentMediaItem,
- Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED
- )
- onMediaMetadataChanged(instance?.mediaMetadata ?: MediaMetadata.EMPTY)
- firstTime = false
+ val posButton = customDialog.getButton(DialogInterface.BUTTON_POSITIVE)
+ posButton.isEnabled = false
+ et = customDialog.findViewById(R.id.editText)!!
+ et.addTextChangedListener {
+ posButton.isEnabled = try {
+ NumberFormat.getInstance().parse(it.toString())!!.toFloat()
+ true
+ } catch (_: ParseException) {
+ false
+ }
}
+ cb = customDialog.findViewById(R.id.checkBox)!!
+ cb.isChecked = baseCheckbox.isChecked
}
override fun onAttachedToWindow() {
@@ -625,6 +684,11 @@ class FullBottomSheet
bottomSheetFullSeekBar.progressTintList = ColorStateList.valueOf(colorPrimary)
}
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ stopButtonRotation(false)
+ }
+
override fun onSaveInstanceState(): Parcelable {
return Bundle().apply {
putParcelable("Super", super.onSaveInstanceState())
@@ -670,38 +734,44 @@ class FullBottomSheet
}
private fun refreshSettings(key: String?) {
+ refreshProgressBarSetting(key)
+ refreshQualityInfoSetting(key)
+ refreshTitleAppearanceSetting(key)
+ refreshCoverAppearanceSetting(key)
+ }
+
+ private fun refreshProgressBarSetting(key: String?) {
if (key == null || key == "default_progress_bar") {
- if (prefs.getBooleanStrict("default_progress_bar", false)) {
- bottomSheetFullSlider.visibility = VISIBLE
- bottomSheetFullSeekBar.visibility = GONE
- } else {
- bottomSheetFullSlider.visibility = GONE
- bottomSheetFullSeekBar.visibility = VISIBLE
- }
+ val isDefault = prefs.getBooleanStrict("default_progress_bar", false)
+ bottomSheetFullSlider.visibility = if (isDefault) VISIBLE else GONE
+ bottomSheetFullSeekBar.visibility = if (isDefault) GONE else VISIBLE
}
+ }
+
+ private fun refreshQualityInfoSetting(key: String?) {
if (key == null || key == "audio_quality_info") {
enableQualityInfo = prefs.getBooleanStrict("audio_quality_info", false)
updateQualityIndicators(
- if (enableQualityInfo)
- AudioFormatDetector.detectAudioFormat(currentFormat) else null
+ if (enableQualityInfo) AudioFormatDetector.detectAudioFormat(currentFormat) else null
)
}
+ }
+
+ private fun refreshTitleAppearanceSetting(key: String?) {
if (key == null || key == "centered_title") {
- if (prefs.getBooleanStrict("centered_title", false)) {
- bottomSheetFullTitle.gravity = Gravity.CENTER
- bottomSheetFullSubtitle.gravity = Gravity.CENTER
- } else {
- bottomSheetFullTitle.gravity = Gravity.CENTER_HORIZONTAL or Gravity.START
- bottomSheetFullSubtitle.gravity = Gravity.CENTER_HORIZONTAL or Gravity.START
- }
+ val isCentered = prefs.getBooleanStrict("centered_title", false)
+ val gravity = if (isCentered) Gravity.CENTER else (Gravity.CENTER_HORIZONTAL or Gravity.START)
+ bottomSheetFullTitle.gravity = gravity
+ bottomSheetFullSubtitle.gravity = gravity
}
if (key == null || key == "bold_title") {
- if (prefs.getBooleanStrict("bold_title", true)) {
- bottomSheetFullTitle.typeface = TypefaceCompat.create(context, null, 600, false)
- } else {
- bottomSheetFullTitle.typeface = TypefaceCompat.create(context, null, 400, false)
- }
+ val isBold = prefs.getBooleanStrict("bold_title", true)
+ val weight = if (isBold) 600 else 400
+ bottomSheetFullTitle.typeface = TypefaceCompat.create(context, null, weight, false)
}
+ }
+
+ private fun refreshCoverAppearanceSetting(key: String?) {
if (key == null || key == "album_round_corner") {
bottomSheetFullCoverFrame.radius = prefs.getIntStrict(
"album_round_corner",
@@ -711,6 +781,43 @@ class FullBottomSheet
if (key == null || key == "cookie_cover") {
bottomSheetFullCover.setClip(prefs.getBooleanStrict("cookie_cover", false))
}
+ if (key == null || key == "rotate_cookie_button") {
+ rotateCookieButton = prefs.getBooleanStrict("rotate_cookie_button", false)
+ if (!rotateCookieButton) {
+ stopButtonRotation(true)
+ } else if (instance?.isPlaying == true) {
+ startButtonRotation()
+ }
+ }
+ }
+
+ private fun startButtonRotation() {
+ val bg = bottomSheetFullControllerButtonBg ?: return
+ if (!rotateCookieButton || instance?.isPlaying != true) return
+ if (buttonRotationAnimator == null) {
+ buttonRotationAnimator = ValueAnimator.ofFloat(
+ bg.rotation,
+ bg.rotation + 360f
+ ).apply {
+ duration = 36000L
+ repeatCount = ValueAnimator.INFINITE
+ interpolator = LinearInterpolator()
+ addUpdateListener { animator ->
+ bg.rotation = animator.animatedValue as Float
+ }
+ start()
+ }
+ } else if (buttonRotationAnimator?.isRunning != true) {
+ buttonRotationAnimator?.start()
+ }
+ }
+
+ private fun stopButtonRotation(reset: Boolean = false) {
+ buttonRotationAnimator?.cancel()
+ buttonRotationAnimator = null
+ if (reset) {
+ bottomSheetFullControllerButtonBg?.rotation = 0f
+ }
}
private fun showPlaybackSpeedDialog() {
@@ -1043,370 +1150,237 @@ class FullBottomSheet
}
}
- private suspend fun applyColorScheme(animate: Boolean = true) {
- val ctx = wrappedContext ?: context
-
- val colorSurface = MaterialColors.getColor(
- ctx,
- com.google.android.material.R.attr.colorSurface,
- -1
- )
-
- val colorOnSurface = MaterialColors.getColor(
- ctx,
- com.google.android.material.R.attr.colorOnSurface,
- -1
- )
-
- val colorOnSurfaceVariant = MaterialColors.getColor(
- ctx,
- com.google.android.material.R.attr.colorOnSurfaceVariant,
- -1
- )
-
- val colorPrimary =
- MaterialColors.getColor(
- ctx,
- androidx.appcompat.R.attr.colorPrimary,
- -1
- )
-
- val colorSecondary =
- MaterialColors.getColor(
- ctx,
- com.google.android.material.R.attr.colorSecondary,
- -1
- )
-
- val colorSecondaryContainer =
- MaterialColors.getColor(
- ctx,
- com.google.android.material.R.attr.colorSecondaryContainer,
- -1
- )
-
- val colorOnSecondaryContainer =
- MaterialColors.getColor(
- ctx,
- com.google.android.material.R.attr.colorOnSecondaryContainer,
- -1
- )
-
- val selectorBackground =
- AppCompatResources.getColorStateList(
- ctx,
- R.color.sl_check_button
- )
-
- val selectorFavBackground =
- AppCompatResources.getColorStateList(
- ctx,
- R.color.sl_fav_button
- )
-
- val backgroundProcessedColor = ColorUtils.getColor(
- colorSurface,
- ColorUtils.ColorType.COLOR_BACKGROUND_ELEVATED,
- ctx
- )
-
- val colorContrastFainted = ColorUtils.getColor(
- colorSecondaryContainer,
- ColorUtils.ColorType.COLOR_CONTRAST_FAINTED,
- ctx
- )
-
+ private data class FullPlayerColorScheme(
+ val surface: Int,
+ val onSurface: Int,
+ val onSurfaceVariant: Int,
+ val primary: Int,
+ val secondary: Int,
+ val secondaryContainer: Int,
+ val onSecondaryContainer: Int,
+ val selectorBackground: ColorStateList,
+ val selectorFavBackground: ColorStateList,
+ val backgroundProcessed: Int,
+ val contrastFainted: Int,
+ val lyricsText: Int,
+ val lyricsHighlightTl: Int
+ )
+
+ private fun resolveThemeColors(ctx: Context): FullPlayerColorScheme {
+ val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, -1)
+ val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, -1)
+ val colorOnSurfaceVariant = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, -1)
+ val colorPrimary = MaterialColors.getColor(ctx, androidx.appcompat.R.attr.colorPrimary, -1)
+ val colorSecondary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSecondary, -1)
+ val colorSecondaryContainer = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSecondaryContainer, -1)
+ val colorOnSecondaryContainer = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSecondaryContainer, -1)
+ val selectorBackground = AppCompatResources.getColorStateList(ctx, R.color.sl_check_button)
+ val selectorFavBackground = AppCompatResources.getColorStateList(ctx, R.color.sl_fav_button)
+ val backgroundProcessedColor = ColorUtils.getColor(colorSurface, ColorUtils.ColorType.COLOR_BACKGROUND_ELEVATED, ctx)
+ val colorContrastFainted = ColorUtils.getColor(colorSecondaryContainer, ColorUtils.ColorType.COLOR_CONTRAST_FAINTED, ctx)
val lyricsTextColor = androidx.core.graphics.ColorUtils.compositeColors(
androidx.core.graphics.ColorUtils.setAlphaComponent(colorPrimary, 77),
backgroundProcessedColor
)
-
val lyricsHighlightTlColor = androidx.core.graphics.ColorUtils.compositeColors(
androidx.core.graphics.ColorUtils.setAlphaComponent(colorPrimary, 200),
backgroundProcessedColor
)
+ return FullPlayerColorScheme(
+ surface = colorSurface,
+ onSurface = colorOnSurface,
+ onSurfaceVariant = colorOnSurfaceVariant,
+ primary = colorPrimary,
+ secondary = colorSecondary,
+ secondaryContainer = colorSecondaryContainer,
+ onSecondaryContainer = colorOnSecondaryContainer,
+ selectorBackground = selectorBackground,
+ selectorFavBackground = selectorFavBackground,
+ backgroundProcessed = backgroundProcessedColor,
+ contrastFainted = colorContrastFainted,
+ lyricsText = lyricsTextColor,
+ lyricsHighlightTl = lyricsHighlightTlColor
+ )
+ }
- if (animate) {
-
- val surfaceTransition = ValueAnimator.ofArgb(
- (background as ColorDrawable).color,
- backgroundProcessedColor
- )
-
- val primaryTransition = ValueAnimator.ofArgb(
- bottomSheetFullTitle.textColors.defaultColor,
- colorPrimary
- )
-
- val secondaryContainerTransition = ValueAnimator.ofArgb(
- bottomSheetFullControllerButton.backgroundTintList!!.defaultColor,
- colorSecondaryContainer
- )
-
- val onSecondaryContainerTransition = ValueAnimator.ofArgb(
- bottomSheetFullControllerButton.iconTint.defaultColor,
- colorOnSecondaryContainer
- )
-
- val colorContrastFaintedTransition = ValueAnimator.ofArgb(
- bottomSheetFullSlider.trackInactiveTintList.defaultColor,
- colorContrastFainted
- )
-
- val colorOnSurfaceTransition = ValueAnimator.ofArgb(
- bottomSheetLyricButton.iconTint.defaultColor,
- colorOnSurface
- )
-
- val lyricTextColorTransition = ValueAnimator.ofArgb(
- bottomSheetFullLyricView.defaultTextColor,
- lyricsTextColor
- )
-
- val lyricHighlightTlColorTransition = ValueAnimator.ofArgb(
- bottomSheetFullLyricView.highlightTlTextColor,
- lyricsHighlightTlColor
- )
-
- val loopTransition = ValueAnimator.ofArgb(
- bottomSheetLoopButton.iconTint.getColorForState(
- bottomSheetLoopButton.drawableState, Color.RED
- ),
- selectorBackground.getColorForState(
- bottomSheetLoopButton.drawableState, Color.RED
- )
- )
-
- val shuffleTransition = ValueAnimator.ofArgb(
- bottomSheetShuffleButton.iconTint.getColorForState(
- bottomSheetShuffleButton.drawableState, Color.RED
- ),
- selectorBackground.getColorForState(
- bottomSheetShuffleButton.drawableState, Color.RED
- )
- )
-
- val favoriteTransition = ValueAnimator.ofArgb(
- bottomSheetFavoriteButton.iconTint.getColorForState(
- bottomSheetFavoriteButton.drawableState, Color.RED
- ),
- selectorFavBackground.getColorForState(
- bottomSheetFavoriteButton.drawableState, Color.RED
- )
- )
-
- surfaceTransition.apply {
- addUpdateListener { animation ->
- setBackgroundColor(
- animation.animatedValue as Int
- )
- bottomSheetFullLyricView.setBackgroundColor(
- animation.animatedValue as Int
- )
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
- }
-
- primaryTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetFullSlider.thumbTintList =
- ColorStateList.valueOf(progressColor)
- bottomSheetFullSlider.trackActiveTintList =
- ColorStateList.valueOf(progressColor)
- bottomSheetFullSeekBar.progressTintList =
- ColorStateList.valueOf(progressColor)
- bottomSheetFullSeekBar.thumbTintList =
- ColorStateList.valueOf(progressColor)
- bottomSheetFullLyricView.updateHighlightColor(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ private fun buildColorAnimators(ctx: Context, colors: FullPlayerColorScheme): List {
+ val surfaceTransition = ValueAnimator.ofArgb(
+ (background as ColorDrawable).color, colors.backgroundProcessed
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ val color = it.animatedValue as Int
+ setBackgroundColor(color)
+ bottomSheetFullLyricView.setBackgroundColor(color)
}
+ }
- secondaryContainerTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetFullControllerButton.backgroundTintList =
- ColorStateList.valueOf(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val primaryTransition = ValueAnimator.ofArgb(
+ bottomSheetFullTitle.textColors.defaultColor, colors.primary
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ val color = it.animatedValue as Int
+ bottomSheetFullSlider.thumbTintList = ColorStateList.valueOf(color)
+ bottomSheetFullSlider.trackActiveTintList = ColorStateList.valueOf(color)
+ bottomSheetFullSeekBar.progressTintList = ColorStateList.valueOf(color)
+ bottomSheetFullSeekBar.thumbTintList = ColorStateList.valueOf(color)
+ bottomSheetFullLyricView.updateHighlightColor(color)
}
+ }
- onSecondaryContainerTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetFullControllerButton.iconTint =
- ColorStateList.valueOf(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val secondaryContainerTransition = ValueAnimator.ofArgb(
+ bottomSheetFullControllerButtonBg?.imageTintList?.defaultColor
+ ?: MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSecondaryContainer, -1),
+ colors.secondaryContainer
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetFullControllerButtonBg?.imageTintList = ColorStateList.valueOf(it.animatedValue as Int)
}
+ }
- colorContrastFaintedTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetFullSlider.trackInactiveTintList =
- ColorStateList.valueOf(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val onSecondaryContainerTransition = ValueAnimator.ofArgb(
+ bottomSheetFullControllerButton.iconTint.defaultColor, colors.onSecondaryContainer
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetFullControllerButton.iconTint = ColorStateList.valueOf(it.animatedValue as Int)
}
+ }
- colorOnSurfaceTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetTimerButton.iconTint =
- ColorStateList.valueOf(progressColor)
- bottomSheetPlaybackSpeedButton.iconTint =
- ColorStateList.valueOf(progressColor)
- bottomSheetPlaylistButton.iconTint =
- ColorStateList.valueOf(progressColor)
- bottomSheetLyricButton.iconTint =
- ColorStateList.valueOf(progressColor)
- bottomSheetFullNextButton.iconTint =
- ColorStateList.valueOf(progressColor)
- bottomSheetFullPreviousButton.iconTint =
- ColorStateList.valueOf(progressColor)
- bottomSheetFullSlideUpButton.iconTint =
- ColorStateList.valueOf(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val contrastFaintedTransition = ValueAnimator.ofArgb(
+ bottomSheetFullSlider.trackInactiveTintList.defaultColor, colors.contrastFainted
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetFullSlider.trackInactiveTintList = ColorStateList.valueOf(it.animatedValue as Int)
}
+ }
- lyricTextColorTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetFullLyricView.updateTextColor(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val onSurfaceTransition = ValueAnimator.ofArgb(
+ bottomSheetLyricButton.iconTint.defaultColor, colors.onSurface
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ val color = ColorStateList.valueOf(it.animatedValue as Int)
+ bottomSheetTimerButton.iconTint = color
+ bottomSheetPlaybackSpeedButton.iconTint = color
+ bottomSheetPlaylistButton.iconTint = color
+ bottomSheetLyricButton.iconTint = color
+ bottomSheetFullNextButton.iconTint = color
+ bottomSheetFullPreviousButton.iconTint = color
+ bottomSheetFullSlideUpButton.iconTint = color
}
+ }
- lyricHighlightTlColorTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetFullLyricView.updateHighlightTlColor(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val lyricTextTransition = ValueAnimator.ofArgb(
+ bottomSheetFullLyricView.defaultTextColor, colors.lyricsText
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetFullLyricView.updateTextColor(it.animatedValue as Int)
}
+ }
- loopTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetLoopButton.iconTint = ColorStateList.valueOf(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val lyricHighlightTlTransition = ValueAnimator.ofArgb(
+ bottomSheetFullLyricView.highlightTlTextColor, colors.lyricsHighlightTl
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetFullLyricView.updateHighlightTlColor(it.animatedValue as Int)
}
+ }
- shuffleTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetShuffleButton.iconTint = ColorStateList.valueOf(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val loopTransition = ValueAnimator.ofArgb(
+ bottomSheetLoopButton.iconTint.getColorForState(bottomSheetLoopButton.drawableState, Color.RED),
+ colors.selectorBackground.getColorForState(bottomSheetLoopButton.drawableState, Color.RED)
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetLoopButton.iconTint = ColorStateList.valueOf(it.animatedValue as Int)
}
+ }
- favoriteTransition.apply {
- addUpdateListener { animation ->
- val progressColor = animation.animatedValue as Int
- bottomSheetFavoriteButton.iconTint = ColorStateList.valueOf(progressColor)
- }
- duration = BACKGROUND_COLOR_TRANSITION_SEC
+ val shuffleTransition = ValueAnimator.ofArgb(
+ bottomSheetShuffleButton.iconTint.getColorForState(bottomSheetShuffleButton.drawableState, Color.RED),
+ colors.selectorBackground.getColorForState(bottomSheetShuffleButton.drawableState, Color.RED)
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetShuffleButton.iconTint = ColorStateList.valueOf(it.animatedValue as Int)
}
+ }
- withContext(Dispatchers.Main) {
- surfaceTransition.start()
- primaryTransition.start()
- secondaryContainerTransition.start()
- onSecondaryContainerTransition.start()
- colorContrastFaintedTransition.start()
- colorOnSurfaceTransition.start()
- lyricTextColorTransition.start()
- lyricHighlightTlColorTransition.start()
- loopTransition.start()
- shuffleTransition.start()
- favoriteTransition.start()
-
- // Note: Animator.addListener isn't thread-safe on all Android versions, ensure we
- // stay on the main thread to avoid crashes.
- surfaceTransition.awaitEnd()
- primaryTransition.awaitEnd()
- secondaryContainerTransition.awaitEnd()
- onSecondaryContainerTransition.awaitEnd()
- colorContrastFaintedTransition.awaitEnd()
- colorOnSurfaceTransition.awaitEnd()
- lyricTextColorTransition.awaitEnd()
- lyricHighlightTlColorTransition.awaitEnd()
- loopTransition.awaitEnd()
- shuffleTransition.awaitEnd()
- favoriteTransition.awaitEnd()
+ val favoriteTransition = ValueAnimator.ofArgb(
+ bottomSheetFavoriteButton.iconTint.getColorForState(bottomSheetFavoriteButton.drawableState, Color.RED),
+ colors.selectorFavBackground.getColorForState(bottomSheetFavoriteButton.drawableState, Color.RED)
+ ).apply {
+ duration = BACKGROUND_COLOR_TRANSITION_SEC
+ addUpdateListener {
+ bottomSheetFavoriteButton.iconTint = ColorStateList.valueOf(it.animatedValue as Int)
}
}
- currentJob = null
+ return listOf(
+ surfaceTransition, primaryTransition, secondaryContainerTransition,
+ onSecondaryContainerTransition, contrastFaintedTransition, onSurfaceTransition,
+ lyricTextTransition, lyricHighlightTlTransition, loopTransition,
+ shuffleTransition, favoriteTransition
+ )
+ }
+
+ private fun applyStaticColors(colors: FullPlayerColorScheme) {
postOnAnimation {
- setBackgroundColor(backgroundProcessedColor)
- bottomSheetFullLyricView.setBackgroundColor(backgroundProcessedColor)
- bottomSheetFullTitle.setTextColor(
- colorPrimary
- )
- bottomSheetFullSubtitle.setTextColor(
- colorSecondary
- )
- bottomSheetFullControllerButton.backgroundTintList =
- ColorStateList.valueOf(colorSecondaryContainer)
- bottomSheetFullControllerButton.iconTint =
- ColorStateList.valueOf(colorOnSecondaryContainer)
-
- bottomSheetFullSlider.thumbTintList =
- ColorStateList.valueOf(colorPrimary)
- bottomSheetFullSlider.trackActiveTintList =
- ColorStateList.valueOf(colorPrimary)
- bottomSheetFullSeekBar.progressTintList =
- ColorStateList.valueOf(colorPrimary)
- bottomSheetFullSeekBar.thumbTintList =
- ColorStateList.valueOf(colorPrimary)
- bottomSheetFullSlider.trackInactiveTintList =
- ColorStateList.valueOf(colorContrastFainted)
+ setBackgroundColor(colors.backgroundProcessed)
+ bottomSheetFullLyricView.setBackgroundColor(colors.backgroundProcessed)
+ bottomSheetFullTitle.setTextColor(colors.primary)
+ bottomSheetFullSubtitle.setTextColor(colors.secondary)
+ bottomSheetFullControllerButtonBg?.imageTintList = ColorStateList.valueOf(colors.secondaryContainer)
+ bottomSheetFullControllerButton.iconTint = ColorStateList.valueOf(colors.onSecondaryContainer)
+
+ bottomSheetFullSlider.thumbTintList = ColorStateList.valueOf(colors.primary)
+ bottomSheetFullSlider.trackActiveTintList = ColorStateList.valueOf(colors.primary)
+ bottomSheetFullSeekBar.progressTintList = ColorStateList.valueOf(colors.primary)
+ bottomSheetFullSeekBar.thumbTintList = ColorStateList.valueOf(colors.primary)
+ bottomSheetFullSlider.trackInactiveTintList = ColorStateList.valueOf(colors.contrastFainted)
TextViewCompat.setCompoundDrawableTintList(
- bottomSheetFullQualityDetails,
- ColorStateList.valueOf(colorOnSurfaceVariant)
- )
- bottomSheetFullQualityDetails.setTextColor(
- colorOnSurfaceVariant
- )
- bottomSheetFullLyricView.updateTextColor(
- lyricsTextColor,
- colorPrimary,
- lyricsHighlightTlColor,
+ bottomSheetFullQualityDetails, ColorStateList.valueOf(colors.onSurfaceVariant)
)
+ bottomSheetFullQualityDetails.setTextColor(colors.onSurfaceVariant)
+ bottomSheetFullLyricView.updateTextColor(colors.lyricsText, colors.primary, colors.lyricsHighlightTl)
+
+ bottomSheetTimerButton.iconTint = ColorStateList.valueOf(colors.onSurface)
+ bottomSheetPlaybackSpeedButton.iconTint = ColorStateList.valueOf(colors.onSurface)
+ bottomSheetPlaylistButton.iconTint = ColorStateList.valueOf(colors.onSurface)
+ bottomSheetShuffleButton.iconTint = colors.selectorBackground
+ bottomSheetLoopButton.iconTint = colors.selectorBackground
+ bottomSheetLyricButton.iconTint = ColorStateList.valueOf(colors.onSurface)
+ bottomSheetFavoriteButton.iconTint = colors.selectorFavBackground
+
+ bottomSheetFullNextButton.iconTint = ColorStateList.valueOf(colors.onSurface)
+ bottomSheetFullPreviousButton.iconTint = ColorStateList.valueOf(colors.onSurface)
+ bottomSheetFullSlideUpButton.iconTint = ColorStateList.valueOf(colors.onSurface)
+
+ bottomSheetFullPosition.setTextColor(colors.onSurfaceVariant)
+ bottomSheetFullDuration.setTextColor(colors.onSurfaceVariant)
+ }
+ }
- bottomSheetTimerButton.iconTint =
- ColorStateList.valueOf(colorOnSurface)
- bottomSheetPlaybackSpeedButton.iconTint =
- ColorStateList.valueOf(colorOnSurface)
- bottomSheetPlaylistButton.iconTint =
- ColorStateList.valueOf(colorOnSurface)
- bottomSheetShuffleButton.iconTint =
- selectorBackground
- bottomSheetLoopButton.iconTint =
- selectorBackground
- bottomSheetLyricButton.iconTint =
- ColorStateList.valueOf(colorOnSurface)
- bottomSheetFavoriteButton.iconTint =
- selectorFavBackground
-
- bottomSheetFullNextButton.iconTint =
- ColorStateList.valueOf(colorOnSurface)
- bottomSheetFullPreviousButton.iconTint =
- ColorStateList.valueOf(colorOnSurface)
- bottomSheetFullSlideUpButton.iconTint =
- ColorStateList.valueOf(colorOnSurface)
-
- bottomSheetFullPosition.setTextColor(
- colorOnSurfaceVariant
- )
- bottomSheetFullDuration.setTextColor(
- colorOnSurfaceVariant
- )
+ private suspend fun applyColorScheme(animate: Boolean = true) {
+ val ctx = wrappedContext ?: context
+ val colors = resolveThemeColors(ctx)
+
+ if (animate) {
+ val animators = buildColorAnimators(ctx, colors)
+ withContext(Dispatchers.Main) {
+ animators.forEach { it.start() }
+ animators.forEach { it.awaitEnd() }
+ }
}
+
+ currentJob = null
+ applyStaticColors(colors)
}
private suspend fun ValueAnimator.awaitEnd() {
@@ -1531,43 +1505,57 @@ class FullBottomSheet
override fun onPlaybackStateChanged(playbackState: Int) {
if (instance?.isPlaying == true) {
- if (bottomSheetFullControllerButton.getTag(R.id.play_next) as Int? != 1) {
- bottomSheetFullControllerButton.icon =
- AppCompatResources.getDrawable(
- wrappedContext ?: context,
- R.drawable.play_anim
- )
- bottomSheetFullControllerButton.background =
- AppCompatResources.getDrawable(context, R.drawable.bg_play_anim)
- bottomSheetFullControllerButton.icon.startAnimation()
- bottomSheetFullControllerButton.background.startAnimation()
- bottomSheetFullControllerButton.setTag(R.id.play_next, 1)
- }
- if (!isUserTracking) {
- progressDrawable.animate = true
- }
- if (!runnableRunning) {
- runnableRunning = true
- handler.postDelayed(positionRunnable, SLIDER_UPDATE_INTERVAL)
- }
- bottomSheetFullCover.startRotation()
+ updatePlayingStateUI()
} else if (playbackState != Player.STATE_BUFFERING) {
- if (bottomSheetFullControllerButton.getTag(R.id.play_next) as Int? != 2) {
- bottomSheetFullControllerButton.icon =
- AppCompatResources.getDrawable(
- wrappedContext ?: context,
- R.drawable.pause_anim
- )
- bottomSheetFullControllerButton.background =
- AppCompatResources.getDrawable(context, R.drawable.bg_pause_anim)
- bottomSheetFullControllerButton.icon.startAnimation()
- bottomSheetFullControllerButton.background.startAnimation()
- bottomSheetFullControllerButton.setTag(R.id.play_next, 2)
- bottomSheetFullCover.stopRotation()
- }
- if (!isUserTracking) {
- progressDrawable.animate = false
- }
+ updatePausedStateUI()
+ }
+ }
+
+ private fun updatePlayingStateUI() {
+ if (bottomSheetFullControllerButton.getTag(R.id.play_next) as Int? != 1) {
+ bottomSheetFullControllerButton.icon =
+ AppCompatResources.getDrawable(
+ wrappedContext ?: context,
+ R.drawable.play_anim
+ )
+ bottomSheetFullControllerButtonBg?.setImageDrawable(
+ AppCompatResources.getDrawable(context, R.drawable.bg_play_anim)
+ )
+ bottomSheetFullControllerButton.icon.startAnimation()
+ bottomSheetFullControllerButtonBg?.drawable?.startAnimation()
+ bottomSheetFullControllerButton.setTag(R.id.play_next, 1)
+ }
+ if (!isUserTracking) {
+ progressDrawable.animate = true
+ }
+ if (!runnableRunning) {
+ runnableRunning = true
+ handler.postDelayed(positionRunnable, SLIDER_UPDATE_INTERVAL)
+ }
+ bottomSheetFullCover.startRotation()
+ if (rotateCookieButton) {
+ startButtonRotation()
+ }
+ }
+
+ private fun updatePausedStateUI() {
+ if (bottomSheetFullControllerButton.getTag(R.id.play_next) as Int? != 2) {
+ bottomSheetFullControllerButton.icon =
+ AppCompatResources.getDrawable(
+ wrappedContext ?: context,
+ R.drawable.pause_anim
+ )
+ bottomSheetFullControllerButtonBg?.setImageDrawable(
+ AppCompatResources.getDrawable(context, R.drawable.bg_pause_anim)
+ )
+ bottomSheetFullControllerButton.icon.startAnimation()
+ bottomSheetFullControllerButtonBg?.drawable?.startAnimation()
+ bottomSheetFullControllerButton.setTag(R.id.play_next, 2)
+ bottomSheetFullCover.stopRotation()
+ stopButtonRotation()
+ }
+ if (!isUserTracking) {
+ progressDrawable.animate = false
}
}
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-w600dp-land/full_player.xml b/app/src/main/res/layout-w600dp-land/full_player.xml
index bd831d0458..2649746a28 100644
--- a/app/src/main/res/layout-w600dp-land/full_player.xml
+++ b/app/src/main/res/layout-w600dp-land/full_player.xml
@@ -200,7 +200,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="28dp"
- app:layout_constraintBottom_toTopOf="@id/sheet_mid_button"
+ app:layout_constraintBottom_toTopOf="@id/sheet_mid_button_frame"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/slider"
@@ -253,29 +253,43 @@
-
+ app:layout_constraintTop_toBottomOf="@id/duration_frame">
+
+
+
+
+
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/layout/full_player.xml b/app/src/main/res/layout/full_player.xml
index 0fa6a1c4a3..2e5ae1899e 100644
--- a/app/src/main/res/layout/full_player.xml
+++ b/app/src/main/res/layout/full_player.xml
@@ -207,7 +207,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="36dp"
- app:layout_constraintBottom_toTopOf="@id/sheet_mid_button"
+ app:layout_constraintBottom_toTopOf="@id/sheet_mid_button_frame"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/slider"
@@ -261,29 +261,43 @@
-
+ app:layout_constraintTop_toBottomOf="@id/duration_frame">
+
+
+
+
+
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..f619bb57c5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -398,10 +398,17 @@
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
Directly switch to previous track when pressing previous button instead of seeking to start
+ Rotate play button
+ Subtly rotate the cookie-style play/pause button while playing
+ Swipe to switch track
+ Swipe left or right on the album cover to switch tracks
Delete
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 @@
+
+
diff --git a/app/src/main/res/xml/settings_player.xml b/app/src/main/res/xml/settings_player.xml
index f83d70ca24..33e2679833 100644
--- a/app/src/main/res/xml/settings_player.xml
+++ b/app/src/main/res/xml/settings_player.xml
@@ -85,6 +85,24 @@
android:title="@string/settings_cookie_info"
android:widgetLayout="@layout/preference_switch_widget" />
+
+
+
+