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