appwidget: redocument
Redocument the appwidget (formerly widget) module. This commit also re-architectures the module somewhat to make further extension easier later on.
This commit is contained in:
parent
b38b8a909f
commit
200a3dfeaf
43 changed files with 523 additions and 437 deletions
|
@ -1,4 +1,4 @@
|
|||
<p align="center"><img src="fastlane/metadata/android/en-US/images/icon.png" width="150"></p>
|
||||
d<p align="center"><img src="fastlane/metadata/android/en-US/images/icon.png" width="150"></p>
|
||||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
|
@ -46,9 +46,10 @@ I primarily built Auxio for myself, but you can use it too, I guess.
|
|||
- Snappy UI derived from the latest Material Design guidelines
|
||||
- Opinionated UX that prioritizes ease of use over edge cases
|
||||
- Customizable behavior
|
||||
- Seamless artist system that unifies album artist and artist tags
|
||||
- Advanced media indexer with support for multiple artists, release types,
|
||||
precise/original dates, sort tags, and more
|
||||
- Support for disc numbers, multiple artists, release types,
|
||||
precise/original dates, sort tags, and more
|
||||
- Advanced artist system that unifies artists and album artists
|
||||
into a single entry
|
||||
- SD Card-aware folder management
|
||||
- Reliable playback state persistence
|
||||
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
|
||||
|
|
|
@ -59,13 +59,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setupTheme()
|
||||
|
||||
// Inflate the views after setting up the theme so that the theme attributes are applied.
|
||||
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupEdgeToEdge(binding.root)
|
||||
|
||||
logD("Activity created")
|
||||
}
|
||||
|
||||
|
@ -76,6 +74,7 @@ class MainActivity : AppCompatActivity() {
|
|||
startService(Intent(this, PlaybackService::class.java))
|
||||
|
||||
if (!startIntentAction(intent)) {
|
||||
// No intent action to do, just restore the previously saved state.
|
||||
playbackModel.startAction(InternalPlayer.Action.RestoreState)
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +84,32 @@ class MainActivity : AppCompatActivity() {
|
|||
startIntentAction(intent)
|
||||
}
|
||||
|
||||
private fun setupTheme() {
|
||||
val settings = Settings(this)
|
||||
// Apply the theme configuration.
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
// Apply the color scheme. The black theme requires it's own set of themes since
|
||||
// it's not possible to modify the themes at run-time.
|
||||
if (isNight && settings.useBlackTheme) {
|
||||
logD("Applying black theme [accent ${settings.accent}]")
|
||||
setTheme(settings.accent.blackTheme)
|
||||
} else {
|
||||
logD("Applying normal theme [accent ${settings.accent}]")
|
||||
setTheme(settings.accent.theme)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupEdgeToEdge(contentView: View) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
contentView.setOnApplyWindowInsetsListener { view, insets ->
|
||||
// Automatically inset the view to the left/right, as component support for
|
||||
// these insets are highly lacking.
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
view.updatePadding(left = bars.left, right = bars.right)
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action]
|
||||
* that can be used in the playback system.
|
||||
|
@ -117,30 +142,6 @@ class MainActivity : AppCompatActivity() {
|
|||
return true
|
||||
}
|
||||
|
||||
private fun setupTheme() {
|
||||
val settings = Settings(this)
|
||||
// Set up the current theme.
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
// Set up the color scheme. Note that the black theme has it's own set
|
||||
// of styles since the color schemes cannot be modified at runtime.
|
||||
if (isNight && settings.useBlackTheme) {
|
||||
logD("Applying black theme [accent ${settings.accent}]")
|
||||
setTheme(settings.accent.blackTheme)
|
||||
} else {
|
||||
logD("Applying normal theme [accent ${settings.accent}]")
|
||||
setTheme(settings.accent.theme)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupEdgeToEdge(contentView: View) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
contentView.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
view.updatePadding(left = bars.left, right = bars.right)
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
|
||||
}
|
||||
|
|
|
@ -69,6 +69,8 @@ class MainFragment :
|
|||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
val context = requireActivity()
|
||||
// Override the back pressed callback so we can map back navigation to collapsing
|
||||
|
|
|
@ -75,6 +75,7 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
|
|||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP --
|
||||
binding.detailToolbar.apply {
|
||||
inflateMenu(R.menu.menu_album_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
@ -84,10 +85,8 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
|
|||
binding.detailRecycler.adapter = detailAdapter
|
||||
|
||||
// -- VIEWMODEL SETUP ---
|
||||
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setAlbumUid(args.albumUid)
|
||||
|
||||
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
||||
collectImmediately(detailModel.albumList, detailAdapter::submitList)
|
||||
collectImmediately(
|
||||
|
|
|
@ -75,6 +75,7 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
|
|||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailToolbar.apply {
|
||||
inflateMenu(R.menu.menu_genre_artist_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
@ -84,10 +85,8 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
|
|||
binding.detailRecycler.adapter = detailAdapter
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setArtistUid(args.artistUid)
|
||||
|
||||
collectImmediately(detailModel.currentArtist, ::updateItem)
|
||||
collectImmediately(detailModel.artistList, detailAdapter::submitList)
|
||||
collectImmediately(
|
||||
|
|
|
@ -74,6 +74,7 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
|
|||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailToolbar.apply {
|
||||
inflateMenu(R.menu.menu_genre_artist_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
@ -83,10 +84,8 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
|
|||
binding.detailRecycler.adapter = detailAdapter
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setGenreUid(args.genreUid)
|
||||
|
||||
collectImmediately(detailModel.currentGenre, ::updateItem)
|
||||
collectImmediately(detailModel.genreList, detailAdapter::submitList)
|
||||
collectImmediately(
|
||||
|
|
|
@ -57,14 +57,13 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
}
|
||||
|
||||
private fun updateSong(song: DetailSong?) {
|
||||
val binding = requireBinding()
|
||||
|
||||
if (song == null) {
|
||||
// Song we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
||||
val binding = requireBinding()
|
||||
if (song.properties != null) {
|
||||
// Finished loading Song properties, populate and show the list of Song information.
|
||||
binding.detailLoading.isInvisible = true
|
||||
|
|
|
@ -106,6 +106,7 @@ class HomeFragment :
|
|||
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||
binding.homeToolbar.setOnMenuItemClickListener(this)
|
||||
|
||||
|
@ -152,7 +153,6 @@ class HomeFragment :
|
|||
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
collect(homeModel.shouldRecreate, ::handleRecreate)
|
||||
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
||||
collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab)
|
||||
|
|
|
@ -451,7 +451,7 @@ class PlaybackService :
|
|||
playbackManager.changePlaying(false)
|
||||
stopAndSave()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,215 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.LayoutRes
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* The default widget is displayed whenever there is no music playing. It just shows the message "No
|
||||
* music playing".
|
||||
*/
|
||||
fun createDefaultWidget(context: Context) = createViews(context, R.layout.widget_default)
|
||||
|
||||
/**
|
||||
* The thin widget is a weird outlier widget intended to work well on strange launchers or landscape
|
||||
* grid launchers that allow really thin widget sizing.
|
||||
*/
|
||||
fun createThinWidget(context: Context, state: WidgetComponent.WidgetState) =
|
||||
createViews(context, R.layout.widget_thin)
|
||||
.applyRoundingToBackground(context)
|
||||
.applyMeta(context, state)
|
||||
.applyBasicControls(context, state)
|
||||
|
||||
/**
|
||||
* The small widget is for 2x2 widgets and just shows the cover art and playback controls. This is
|
||||
* generally because a Medium widget is too large for this widget size and a text-only widget is too
|
||||
* small for this widget size.
|
||||
*/
|
||||
fun createSmallWidget(context: Context, state: WidgetComponent.WidgetState) =
|
||||
createViews(context, R.layout.widget_small)
|
||||
.applyRoundingToBar(context)
|
||||
.applyCover(context, state)
|
||||
.applyBasicControls(context, state)
|
||||
|
||||
/**
|
||||
* The medium widget is for 2x3 widgets and shows the cover art, title/artist, and three controls.
|
||||
* This is the default widget configuration.
|
||||
*/
|
||||
fun createMediumWidget(context: Context, state: WidgetComponent.WidgetState) =
|
||||
createViews(context, R.layout.widget_medium)
|
||||
.applyRoundingToBackground(context)
|
||||
.applyMeta(context, state)
|
||||
.applyBasicControls(context, state)
|
||||
|
||||
/** The wide widget is for Nx2 widgets and is like the small widget but with more controls. */
|
||||
fun createWideWidget(context: Context, state: WidgetComponent.WidgetState) =
|
||||
createViews(context, R.layout.widget_wide)
|
||||
.applyRoundingToBar(context)
|
||||
.applyCover(context, state)
|
||||
.applyFullControls(context, state)
|
||||
|
||||
/** The large widget is for 3x4 widgets and shows all metadata and controls. */
|
||||
fun createLargeWidget(context: Context, state: WidgetComponent.WidgetState): RemoteViews =
|
||||
createViews(context, R.layout.widget_large)
|
||||
.applyRoundingToBackground(context)
|
||||
.applyMeta(context, state)
|
||||
.applyFullControls(context, state)
|
||||
|
||||
private fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, layout)
|
||||
views.setOnClickPendingIntent(android.R.id.background, context.newMainPendingIntent())
|
||||
return views
|
||||
}
|
||||
|
||||
private fun RemoteViews.applyRoundingToBackground(context: Context): RemoteViews {
|
||||
if (Settings(context).roundMode && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
setInt(android.R.id.background, "setBackgroundResource", R.drawable.ui_widget_bg_round)
|
||||
} else {
|
||||
setInt(android.R.id.background, "setBackgroundResource", R.drawable.ui_widget_bg)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RemoteViews.applyRoundingToBar(context: Context): RemoteViews {
|
||||
if (Settings(context).roundMode && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
setInt(R.id.widget_controls, "setBackgroundResource", R.drawable.ui_widget_bar_round)
|
||||
} else {
|
||||
setInt(R.id.widget_controls, "setBackgroundResource", R.drawable.ui_widget_bar)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RemoteViews.applyMeta(
|
||||
context: Context,
|
||||
state: WidgetComponent.WidgetState
|
||||
): RemoteViews {
|
||||
applyCover(context, state)
|
||||
|
||||
setTextViewText(R.id.widget_song, state.song.resolveName(context))
|
||||
setTextViewText(R.id.widget_artist, state.song.resolveArtistContents(context))
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RemoteViews.applyCover(
|
||||
context: Context,
|
||||
state: WidgetComponent.WidgetState
|
||||
): RemoteViews {
|
||||
if (state.cover != null) {
|
||||
setImageViewBitmap(R.id.widget_cover, state.cover)
|
||||
setContentDescription(
|
||||
R.id.widget_cover,
|
||||
context.getString(R.string.desc_album_cover, state.song.album.resolveName(context)))
|
||||
} else {
|
||||
setImageViewResource(R.id.widget_cover, R.drawable.ic_remote_default_cover_24)
|
||||
setContentDescription(R.id.widget_cover, context.getString(R.string.desc_no_cover))
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RemoteViews.applyPlayPauseControls(
|
||||
context: Context,
|
||||
state: WidgetComponent.WidgetState
|
||||
): RemoteViews {
|
||||
// Controls are timeline elements, override the layout direction to RTL
|
||||
setInt(R.id.widget_controls, "setLayoutDirection", View.LAYOUT_DIRECTION_LTR)
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_play_pause,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_PLAY_PAUSE))
|
||||
|
||||
// Like the Android 13 media controls, use a circular fab when paused, and a squircle fab
|
||||
// when playing.
|
||||
|
||||
val icon: Int
|
||||
val container: Int
|
||||
|
||||
if (state.isPlaying) {
|
||||
icon = R.drawable.ic_pause_24
|
||||
container = R.drawable.ui_remote_fab_container_playing
|
||||
} else {
|
||||
icon = R.drawable.ic_play_24
|
||||
container = R.drawable.ui_remote_fab_container_paused
|
||||
}
|
||||
|
||||
setImageViewResource(R.id.widget_play_pause, icon)
|
||||
setInt(R.id.widget_play_pause, "setBackgroundResource", container)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RemoteViews.applyBasicControls(
|
||||
context: Context,
|
||||
state: WidgetComponent.WidgetState
|
||||
): RemoteViews {
|
||||
applyPlayPauseControls(context, state)
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_prev, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV))
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_next, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT))
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RemoteViews.applyFullControls(
|
||||
context: Context,
|
||||
state: WidgetComponent.WidgetState
|
||||
): RemoteViews {
|
||||
applyBasicControls(context, state)
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_repeat,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_shuffle,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
|
||||
|
||||
val shuffleRes =
|
||||
when {
|
||||
state.isShuffled -> R.drawable.ic_shuffle_on_24
|
||||
else -> R.drawable.ic_shuffle_off_24
|
||||
}
|
||||
|
||||
val repeatRes =
|
||||
when (state.repeatMode) {
|
||||
RepeatMode.NONE -> R.drawable.ic_repeat_off_24
|
||||
RepeatMode.ALL -> R.drawable.ic_repeat_on_24
|
||||
RepeatMode.TRACK -> R.drawable.ic_repeat_one_24
|
||||
}
|
||||
|
||||
setImageViewResource(R.id.widget_shuffle, shuffleRes)
|
||||
setImageViewResource(R.id.widget_repeat, repeatRes)
|
||||
|
||||
return this
|
||||
}
|
|
@ -36,39 +36,31 @@ import org.oxycblt.auxio.util.getDimenPixels
|
|||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
|
||||
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may
|
||||
* result in memory leaks if [PlaybackStateManager]/[Settings] gets created and bound to without
|
||||
* being released.
|
||||
* A component that manages the state of all of Auxio's widgets.
|
||||
* This is kept separate from the AppWidgetProviders themselves to prevent possible memory
|
||||
* leaks and enable the main functionality to be extended to more widgets in the future.
|
||||
* @param context [Context] required to manage AppWidgetProviders.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class WidgetComponent(private val context: Context) :
|
||||
PlaybackStateManager.Callback, Settings.Callback {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settings = Settings(context, this)
|
||||
private val widget = WidgetProvider()
|
||||
private val widgetProvider = WidgetProvider()
|
||||
private val provider = BitmapProvider(context)
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
|
||||
if (playbackManager.isInitialized) {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Force-update the widget.
|
||||
/**
|
||||
* Update [WidgetProvider] with the current playback state.
|
||||
*/
|
||||
fun update() {
|
||||
// Updating Auxio's widget is unlike the rest of Auxio for a few reasons:
|
||||
// 1. We can't use the typical primitives like ViewModels
|
||||
// 2. The component range is far smaller, so we have to do some odd hacks to get
|
||||
// the same UX.
|
||||
fun updateNowPlaying() {
|
||||
val song = playbackManager.song
|
||||
if (song == null) {
|
||||
logD("No song, resetting widget")
|
||||
widget.update(context, null)
|
||||
widgetProvider.update(context, null)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -83,7 +75,7 @@ class WidgetComponent(private val context: Context) :
|
|||
override fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder {
|
||||
val cornerRadius =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// Android 12, always round the cover with the app widget's inner radius
|
||||
// Android 12, always round the cover with the widget's inner radius
|
||||
context.getDimenPixels(android.R.dimen.system_app_widget_inner_radius)
|
||||
} else if (settings.roundMode) {
|
||||
// < Android 12, but the user still enabled round mode.
|
||||
|
@ -93,8 +85,6 @@ class WidgetComponent(private val context: Context) :
|
|||
0
|
||||
}
|
||||
|
||||
// We resize the image in a such a way that we don't hit the RemoteView size
|
||||
// limit, which is the size of an RGB_8888 bitmap 1.5x the screen size.
|
||||
val metrics = context.resources.displayMetrics
|
||||
val sw = metrics.widthPixels
|
||||
val sh = metrics.heightPixels
|
||||
|
@ -104,55 +94,70 @@ class WidgetComponent(private val context: Context) :
|
|||
// to work around a bug in Android 13 where the bitmaps aren't pooled
|
||||
// properly, massively reducing the memory size we can work with.
|
||||
builder
|
||||
.size(computeSize(sw, sh, 10f))
|
||||
.size(computeWidgetImageSize(sw, sh, 10f))
|
||||
.transformations(
|
||||
SquareFrameTransform.INSTANCE,
|
||||
RoundedCornersTransformation(cornerRadius.toFloat()))
|
||||
} else {
|
||||
// Divide by two to really make sure we aren't hitting the memory limit.
|
||||
builder.size(computeSize(sw, sh, 2f))
|
||||
builder.size(computeWidgetImageSize(sw, sh, 2f))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
val state = WidgetState(song, bitmap, isPlaying, repeatMode, isShuffled)
|
||||
widget.update(context, state)
|
||||
val state = PlaybackState(song, bitmap, isPlaying, repeatMode, isShuffled)
|
||||
widgetProvider.update(context, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun computeSize(sw: Int, sh: Int, modifier: Float) =
|
||||
/**
|
||||
* Get the recommended image size to load for use.
|
||||
* @param sw The current screen width
|
||||
* @param sh The current screen height
|
||||
* @param modifier Modifier to reduce the image size.
|
||||
* @return An image size that is guaranteed not to exceed the widget bitmap memory limit.
|
||||
*/
|
||||
private fun computeWidgetImageSize(sw: Int, sh: Int, modifier: Float) =
|
||||
// Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse
|
||||
// that to obtain the image size.
|
||||
sqrt((6f / 4f / modifier) * sw * sh).toInt()
|
||||
|
||||
/*
|
||||
* Release this instance, removing the callbacks and resetting all widgets
|
||||
/**
|
||||
* Release this instance, preventing any further events from updating the widget instances.
|
||||
*/
|
||||
fun release() {
|
||||
provider.release()
|
||||
settings.release()
|
||||
widget.reset(context)
|
||||
widgetProvider.reset(context)
|
||||
playbackManager.removeCallback(this)
|
||||
}
|
||||
|
||||
// --- CALLBACKS ---
|
||||
|
||||
override fun onIndexMoved(index: Int) = update()
|
||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update()
|
||||
override fun onStateChanged(state: InternalPlayer.State) = update()
|
||||
override fun onShuffledChanged(isShuffled: Boolean) = update()
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) = update()
|
||||
// Hook all the major song-changing updates + the major player state updates
|
||||
// to updating the "Now Playing" widget.
|
||||
override fun onIndexMoved(index: Int) = updateNowPlaying()
|
||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = updateNowPlaying()
|
||||
override fun onStateChanged(state: InternalPlayer.State) = updateNowPlaying()
|
||||
override fun onShuffledChanged(isShuffled: Boolean) = updateNowPlaying()
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) = updateNowPlaying()
|
||||
override fun onSettingChanged(key: String) {
|
||||
if (key == context.getString(R.string.set_key_cover_mode) ||
|
||||
key == context.getString(R.string.set_key_round_mode)) {
|
||||
update()
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* An immutable condensed variant of the current playback state, used so that PlaybackStateManager
|
||||
* does not need to be queried directly.
|
||||
/**
|
||||
* A condensed form of the playback state that is safe to use in AppWidgets.
|
||||
* @param song [PlaybackStateManager.song]
|
||||
* @param cover A pre-loaded album cover [Bitmap] for [song].
|
||||
* @param isPlaying [PlaybackStateManager.playerState]
|
||||
* @param repeatMode [PlaybackStateManager.repeatMode]
|
||||
* @param isShuffled [PlaybackStateManager.isShuffled]
|
||||
*/
|
||||
data class WidgetState(
|
||||
data class PlaybackState(
|
||||
val song: Song,
|
||||
val cover: Bitmap?,
|
||||
val isPlaying: Boolean,
|
||||
|
|
|
@ -25,73 +25,30 @@ import android.content.Intent
|
|||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.SizeF
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.util.isLandscape
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* Auxio's one and only appwidget. This widget follows a more unorthodox approach, effectively
|
||||
* packing what could be considered multiple widgets into a single responsive widget.
|
||||
*
|
||||
* This widget is also able to backport it's responsive behavior to android versions below 12,
|
||||
* albeit with some issues, such as UI jittering and a layout not being picked when the orientation
|
||||
* changes. This is tolerable.
|
||||
*
|
||||
* For more specific details about these sub-widgets, see Forms.kt.
|
||||
*
|
||||
* The [AppWidgetProvider] for the "Now Playing" widget. This widget shows the current
|
||||
* playback state alongside actions to control it.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class WidgetProvider : AppWidgetProvider() {
|
||||
/*
|
||||
* Update the widget based on the playback state.
|
||||
*/
|
||||
fun update(context: Context, state: WidgetComponent.WidgetState?) {
|
||||
if (state == null) {
|
||||
reset(context)
|
||||
return
|
||||
}
|
||||
|
||||
// Map each widget form to the cells where it would look at least okay.
|
||||
val views =
|
||||
mapOf(
|
||||
SizeF(180f, 100f) to createThinWidget(context, state),
|
||||
SizeF(180f, 152f) to createSmallWidget(context, state),
|
||||
SizeF(272f, 152f) to createWideWidget(context, state),
|
||||
SizeF(180f, 272f) to createMediumWidget(context, state),
|
||||
SizeF(272f, 272f) to createLargeWidget(context, state))
|
||||
|
||||
val awm = AppWidgetManager.getInstance(context)
|
||||
|
||||
try {
|
||||
awm.updateAppWidgetCompat(context, views)
|
||||
} catch (e: Exception) {
|
||||
logW("Unable to update widget: $e")
|
||||
awm.updateAppWidget(
|
||||
ComponentName(context, this::class.java), createDefaultWidget(context))
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Revert this widget to its default view
|
||||
*/
|
||||
fun reset(context: Context) {
|
||||
logD("Resetting widget")
|
||||
|
||||
AppWidgetManager.getInstance(context)
|
||||
.updateAppWidget(ComponentName(context, this::class.java), createDefaultWidget(context))
|
||||
}
|
||||
|
||||
// --- OVERRIDES ---
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
reset(context)
|
||||
requestUpdate(context)
|
||||
// Revert to the default layout for now until we get a response from WidgetComponent.
|
||||
// If we don't, then we will stick with the default widget layout.
|
||||
reset(context)
|
||||
}
|
||||
|
||||
override fun onAppWidgetOptionsChanged(
|
||||
|
@ -101,80 +58,322 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
newOptions: Bundle?
|
||||
) {
|
||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
|
||||
|
||||
// Another adaptive layout backport for API 21+: We are unable to immediately update
|
||||
// the layout ourselves when the widget dimensions change, so we need to request
|
||||
// an update from WidgetComponent first.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
logD("Requesting new view from PlaybackService")
|
||||
|
||||
// We can't resize the widget until we can generate the views, so request an update
|
||||
// from PlaybackService.
|
||||
requestUpdate(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the currently shown layout based on the given [WidgetComponent.PlaybackState]
|
||||
* @param context [Context] required to update the widget layout.
|
||||
* @param state [WidgetComponent.PlaybackState] to show, or null if no playback is going
|
||||
* on.
|
||||
*/
|
||||
fun update(context: Context, state: WidgetComponent.PlaybackState?) {
|
||||
if (state == null) {
|
||||
// No state, use the default widget.
|
||||
reset(context)
|
||||
return
|
||||
}
|
||||
|
||||
// Create and configure each possible layout for the widget. These dimensions seem
|
||||
// arbitrary, but they are actually the minimum dimensions required to fit all of
|
||||
// the widget elements, plus some leeway for text sizing.
|
||||
val views =
|
||||
mapOf(
|
||||
SizeF(180f, 100f) to newThinLayout(context, state),
|
||||
SizeF(180f, 152f) to newSmallLayout(context, state),
|
||||
SizeF(272f, 152f) to newWideLayout(context, state),
|
||||
SizeF(180f, 272f) to newMediumLayout(context, state),
|
||||
SizeF(272f, 272f) to newLargeLayout(context, state))
|
||||
|
||||
// Manually update AppWidgetManager with the new views.
|
||||
val awm = AppWidgetManager.getInstance(context)
|
||||
val component = ComponentName(context, this::class.java)
|
||||
try {
|
||||
awm.updateAppWidgetCompat(context, component, views)
|
||||
} catch (e: Exception) {
|
||||
// Layout update failed, gracefully degrade to the default widget.
|
||||
logW("Unable to update widget: $e")
|
||||
reset(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert to the default layout that displays "No music playing".
|
||||
* @param context [Context] required to update the widget layout.
|
||||
*/
|
||||
fun reset(context: Context) {
|
||||
logD("Using default layout")
|
||||
AppWidgetManager.getInstance(context)
|
||||
.updateAppWidget(ComponentName(context, this::class.java), newDefaultLayout(context))
|
||||
}
|
||||
|
||||
// --- INTERNAL METHODS ---
|
||||
|
||||
/**
|
||||
* Request an update from [WidgetComponent].
|
||||
* @param context [Context] required to send update request broadcast.
|
||||
*/
|
||||
private fun requestUpdate(context: Context) {
|
||||
logD("Sending update intent to PlaybackService")
|
||||
val intent = Intent(ACTION_WIDGET_UPDATE).addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun AppWidgetManager.updateAppWidgetCompat(
|
||||
// --- LAYOUTS ---
|
||||
|
||||
/**
|
||||
* Create and configure a [RemoteViews] for [R.layout.widget_default], intended for situations
|
||||
* where no other widget layout is applicable.
|
||||
* @param context [Context] required to create the [RemoteViews].
|
||||
*/
|
||||
private fun newDefaultLayout(context: Context) =
|
||||
newRemoteViews(context, R.layout.widget_default)
|
||||
|
||||
/**
|
||||
* Create and configure a [RemoteViews] for [R.layout.widget_thin], intended for extremely
|
||||
* small grid sizes on phones in landscape mode.
|
||||
* @param context [Context] required to create the [RemoteViews].
|
||||
*/
|
||||
private fun newThinLayout(context: Context, state: WidgetComponent.PlaybackState) =
|
||||
newRemoteViews(context, R.layout.widget_thin)
|
||||
.setupBackground(context)
|
||||
.setupPlaybackState(context, state)
|
||||
.setupTimelineControls(context, state)
|
||||
|
||||
/**
|
||||
* Create and configure a [RemoteViews] for [R.layout.widget_small], intended to be a
|
||||
* modestly-sized default layout for most devices.
|
||||
* @param context [Context] required to create the [RemoteViews].
|
||||
*/
|
||||
private fun newSmallLayout(context: Context, state: WidgetComponent.PlaybackState) =
|
||||
newRemoteViews(context, R.layout.widget_small)
|
||||
.setupBar(context)
|
||||
.setupCover(context, state)
|
||||
.setupTimelineControls(context, state)
|
||||
|
||||
/**
|
||||
* Create and configure a [RemoteViews] for [R.layout.widget_medium], intended to be
|
||||
* a taller widget that shows more information about the currently playing song.
|
||||
* @param context [Context] required to create the [RemoteViews].
|
||||
*/
|
||||
private fun newMediumLayout(context: Context, state: WidgetComponent.PlaybackState) =
|
||||
newRemoteViews(context, R.layout.widget_medium)
|
||||
.setupBackground(context)
|
||||
.setupPlaybackState(context, state)
|
||||
.setupTimelineControls(context, state)
|
||||
|
||||
/**
|
||||
* Create and configure a [RemoteViews] for [R.layout.widget_wide], intended to be
|
||||
* a wider version of [R.layout.widget_small] that shows additional controls.
|
||||
* @param context [Context] required to create the [RemoteViews].
|
||||
*/
|
||||
private fun newWideLayout(context: Context, state: WidgetComponent.PlaybackState) =
|
||||
newRemoteViews(context, R.layout.widget_wide)
|
||||
.setupBar(context)
|
||||
.setupCover(context, state)
|
||||
.setupFullControls(context, state)
|
||||
|
||||
/**
|
||||
* Create and configure a [RemoteViews] for [R.layout.widget_large], intended to be
|
||||
* a wider version of [R.layout.widget_medium] that shows additional controls.
|
||||
* @param context [Context] required to create the [RemoteViews].
|
||||
*/
|
||||
private fun newLargeLayout(context: Context, state: WidgetComponent.PlaybackState) =
|
||||
newRemoteViews(context, R.layout.widget_large)
|
||||
.setupBackground(context)
|
||||
.setupPlaybackState(context, state)
|
||||
.setupFullControls(context, state)
|
||||
|
||||
/**
|
||||
* Set up the control bar in a [RemoteViews] layout that contains one. This is a kind of
|
||||
* "floating" drawable that sits in front of the cover and contains the controls.
|
||||
* @param context [Context] required to set up the view.
|
||||
*/
|
||||
private fun RemoteViews.setupBar(context: Context): RemoteViews {
|
||||
// Below API 31, enable a rounded bar only if round mode is enabled.
|
||||
// On API 31+, the bar should always be round in order to fit in with other widgets.
|
||||
val background = if (Settings(context).roundMode &&
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
R.drawable.ui_widget_bar_round
|
||||
} else {
|
||||
R.drawable.ui_widget_bar_system
|
||||
}
|
||||
setBackgroundResource(R.id.widget_controls, background)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the background in a [RemoteViews] layout that contains one. This is largely
|
||||
* self-explanatory, being a solid-color background that sits behind the cover and controls.
|
||||
* @param context [Context] required to set up the view.
|
||||
*/
|
||||
private fun RemoteViews.setupBackground(context: Context): RemoteViews {
|
||||
// Below API 31, enable a rounded background only if round mode is enabled.
|
||||
// On API 31+, the background should always be round in order to fit in with other
|
||||
// widgets.
|
||||
val background = if (Settings(context).roundMode &&
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
R.drawable.ui_widget_bar_round
|
||||
} else {
|
||||
R.drawable.ui_widget_bar_system
|
||||
}
|
||||
setBackgroundResource(android.R.id.background, background)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the album cover in a [RemoteViews] layout that contains one.
|
||||
* @param context [Context] required to set up the view.
|
||||
* @param state Current [WidgetComponent.PlaybackState] to display.
|
||||
*/
|
||||
private fun RemoteViews.setupCover(
|
||||
context: Context,
|
||||
views: Map<SizeF, RemoteViews>
|
||||
) {
|
||||
check(views.isNotEmpty()) { "Must provide a non-empty map" }
|
||||
|
||||
val name = ComponentName(context, WidgetProvider::class.java)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// Widgets are automatically responsive on Android 12, no need to do anything.
|
||||
updateAppWidget(name, RemoteViews(views))
|
||||
state: WidgetComponent.PlaybackState
|
||||
): RemoteViews {
|
||||
if (state.cover != null) {
|
||||
setImageViewBitmap(R.id.widget_cover, state.cover)
|
||||
setContentDescription(
|
||||
R.id.widget_cover,
|
||||
context.getString(R.string.desc_album_cover, state.song.album.resolveName(context)))
|
||||
} else {
|
||||
// Otherwise, we try our best to backport the responsive behavior to older versions.
|
||||
// This seems to work well enough on most launchers.
|
||||
// We are unable to use the typical placeholder cover with the song item due to
|
||||
// limitations with the corner radius. Instead use a custom-made album icon as the
|
||||
// placeholder.
|
||||
setImageViewResource(R.id.widget_cover, R.drawable.ic_remote_default_cover_24)
|
||||
setContentDescription(R.id.widget_cover, context.getString(R.string.desc_no_cover))
|
||||
}
|
||||
|
||||
// Each widget has independent dimensions, so we iterate through them all
|
||||
// and do this for each.
|
||||
for (id in getAppWidgetIds(name)) {
|
||||
val options = getAppWidgetOptions(id)
|
||||
return this
|
||||
}
|
||||
|
||||
val width: Int
|
||||
val height: Int
|
||||
/**
|
||||
* Set up the album cover, song title, and artist name in a [RemoteViews] layout that
|
||||
* contains them.
|
||||
* @param context [Context] required to set up the view.
|
||||
* @param state Current [WidgetComponent.PlaybackState] to display.
|
||||
*/
|
||||
private fun RemoteViews.setupPlaybackState(
|
||||
context: Context,
|
||||
state: WidgetComponent.PlaybackState
|
||||
): RemoteViews {
|
||||
setupCover(context, state)
|
||||
setTextViewText(R.id.widget_song, state.song.resolveName(context))
|
||||
setTextViewText(R.id.widget_artist, state.song.resolveArtistContents(context))
|
||||
return this
|
||||
}
|
||||
|
||||
// Landscape/Portrait modes use different dimen bounds
|
||||
if (context.isLandscape) {
|
||||
width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH)
|
||||
height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)
|
||||
/**
|
||||
* Set up the play/pause button in a [RemoteViews] layout that contains one.
|
||||
* @param context [Context] required to set up the view.
|
||||
* @param state Current [WidgetComponent.PlaybackState] to display.
|
||||
*/
|
||||
private fun RemoteViews.setupBasicControls(
|
||||
context: Context,
|
||||
state: WidgetComponent.PlaybackState
|
||||
): RemoteViews {
|
||||
// Hook the play/pause button to the play/pause broadcast that will be recognized
|
||||
// by PlaybackService.
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_play_pause,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_PLAY_PAUSE))
|
||||
|
||||
// Set up the play/pause button appearance. Like the Android 13 media controls, use
|
||||
// a circular FAB when paused, and a squircle FAB when playing. This does require us
|
||||
// to disable the ripple animation sadly, as it will glitch when this is used. The
|
||||
// shape change should act as a similar signal.
|
||||
val icon: Int
|
||||
val background: Int
|
||||
if (state.isPlaying) {
|
||||
icon = R.drawable.ic_pause_24
|
||||
background = R.drawable.ui_remote_fab_container_playing
|
||||
} else {
|
||||
width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
|
||||
height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
|
||||
icon = R.drawable.ic_play_24
|
||||
background = R.drawable.ui_remote_fab_container_paused
|
||||
}
|
||||
|
||||
logD("Assuming true widget dimens are ${width}x$height")
|
||||
setImageViewResource(R.id.widget_play_pause, icon)
|
||||
setBackgroundResource(R.id.widget_play_pause, background)
|
||||
|
||||
// Find the layout with the greatest area that fits entirely within
|
||||
// the widget. This is what we will use. Fall back to the smallest layout
|
||||
// otherwise.
|
||||
val candidates = mutableListOf<SizeF>()
|
||||
|
||||
for (size in views.keys) {
|
||||
if (size.width <= width && size.height <= height) {
|
||||
candidates.add(size)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
val layout =
|
||||
candidates.maxByOrNull { it.height * it.width }
|
||||
?: views.minBy { it.key.width * it.key.height }.key
|
||||
/**
|
||||
* Set up the play/pause and skip previous/next button in a [RemoteViews] layout that
|
||||
* contains them.
|
||||
* @param context [Context] required to set up the view.
|
||||
* @param state Current [WidgetComponent.PlaybackState] to display.
|
||||
*/
|
||||
private fun RemoteViews.setupTimelineControls(
|
||||
context: Context,
|
||||
state: WidgetComponent.PlaybackState
|
||||
): RemoteViews {
|
||||
// Timeline controls contain the basic controls, set those up
|
||||
setupBasicControls(context, state)
|
||||
// Timeline elements should always be left-to-right.
|
||||
setLayoutDirection(R.id.widget_controls, View.LAYOUT_DIRECTION_LTR)
|
||||
// Hook the skip buttons to the respective broadcasts that can be recognized
|
||||
// by PlaybackService.
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_prev, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV))
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_next, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT))
|
||||
return this
|
||||
}
|
||||
|
||||
logD("Using widget layout $layout ${views.contains(layout)}")
|
||||
updateAppWidget(id, views[layout])
|
||||
/**
|
||||
* Set up the play/pause, skip previous/next, and repeat/shuffle buttons in a [RemoteViews]
|
||||
* that contains them.
|
||||
* @param context [Context] required to set up the view.
|
||||
* @param state Current [WidgetComponent.PlaybackState] to display.
|
||||
*/
|
||||
private fun RemoteViews.setupFullControls(
|
||||
context: Context,
|
||||
state: WidgetComponent.PlaybackState
|
||||
): RemoteViews {
|
||||
// Full controls contain timeline controls, make are set those.
|
||||
setupTimelineControls(context, state)
|
||||
|
||||
// Hook the repeat/shuffle buttons to the respective broadcasts that can
|
||||
// be recognized by PlaybackService.
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_repeat,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_shuffle,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
|
||||
|
||||
// Set up the repeat/shuffle buttons. When working with RemoteViews, we will
|
||||
// need to hard-code different accent tinting configurations, as stateful drawables
|
||||
// are unsupported.
|
||||
val repeatRes =
|
||||
when (state.repeatMode) {
|
||||
RepeatMode.NONE -> R.drawable.ic_repeat_off_24
|
||||
RepeatMode.ALL -> R.drawable.ic_repeat_on_24
|
||||
RepeatMode.TRACK -> R.drawable.ic_repeat_one_24
|
||||
}
|
||||
setImageViewResource(R.id.widget_repeat, repeatRes)
|
||||
|
||||
val shuffleRes =
|
||||
when {
|
||||
state.isShuffled -> R.drawable.ic_shuffle_on_24
|
||||
else -> R.drawable.ic_shuffle_off_24
|
||||
}
|
||||
setImageViewResource(R.id.widget_shuffle, shuffleRes)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Broadcast when [WidgetProvider] desires to update it's widget with new
|
||||
* information. Responsible background tasks should intercept this and relay
|
||||
* the message to [WidgetComponent].
|
||||
*/
|
||||
const val ACTION_WIDGET_UPDATE = BuildConfig.APPLICATION_ID + ".action.WIDGET_UPDATE"
|
||||
}
|
||||
}
|
||||
|
|
97
app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
Normal file
97
app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
Normal file
|
@ -0,0 +1,97 @@
|
|||
package org.oxycblt.auxio.widgets
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.SizeF
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.LayoutRes
|
||||
import org.oxycblt.auxio.util.isLandscape
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* Create a [RemoteViews] instance with the specified layout and an automatic click handler
|
||||
* to open the Auxio activity.
|
||||
* @param context [Context] required to create [RemoteViews].
|
||||
* @param layoutRes Resource ID of the layout to use. Must be compatible with [RemoteViews].
|
||||
* @return A new [RemoteViews] instance with the specified configuration.
|
||||
*/
|
||||
fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, layoutRes)
|
||||
views.setOnClickPendingIntent(android.R.id.background, context.newMainPendingIntent())
|
||||
return views
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the background resource of a [RemoteViews] View.
|
||||
* @param viewId The ID of the view to update.
|
||||
* @param drawableRes The resource ID of the drawable to set the background to.
|
||||
*/
|
||||
fun RemoteViews.setBackgroundResource(@IdRes viewId: Int, @DrawableRes drawableRes: Int) {
|
||||
setInt(viewId, "setBackgroundResource", drawableRes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the layout direction of a [RemoteViews] view.
|
||||
* @param viewId The ID of the view to update.
|
||||
* @param layoutDirection The layout direction to apply to the view,
|
||||
*/
|
||||
fun RemoteViews.setLayoutDirection(@IdRes viewId: Int, layoutDirection: Int) {
|
||||
setInt(viewId, "setLayoutDirection", layoutDirection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the app widget layouts corresponding to the given [AppWidgetProvider] [ComponentName]
|
||||
* with an adaptive layout, in a version-compatible manner.
|
||||
* @param context [Context] required to backport adaptive layout behavior.
|
||||
* @param component [ComponentName] of the app widget layout to update.
|
||||
* @param views Mapping between different size classes and [RemoteViews] instances.
|
||||
* @see RemoteViews
|
||||
*/
|
||||
fun AppWidgetManager.updateAppWidgetCompat(
|
||||
context: Context,
|
||||
component: ComponentName,
|
||||
views: Map<SizeF, RemoteViews>
|
||||
) {
|
||||
check(views.isNotEmpty()) { "Must provide a non-empty map" }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// Can use adaptive widgets from API 31+.
|
||||
updateAppWidget(component, RemoteViews(views))
|
||||
} else {
|
||||
// Backport adaptive widgets to API 21+.
|
||||
// Each app widget has independent dimensions, so we iterate through them all
|
||||
// and do this for each.
|
||||
for (id in getAppWidgetIds(component)) {
|
||||
val options = getAppWidgetOptions(id)
|
||||
|
||||
// Depending on the device orientation, the size of the app widget will be
|
||||
// composed differently.
|
||||
val width: Int
|
||||
val height: Int
|
||||
if (context.isLandscape) {
|
||||
// Width will be larger in landscape, so use MAX_WIDTH.
|
||||
width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH)
|
||||
height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)
|
||||
} else {
|
||||
// Height will be larger in portrait, so use MAX_HEIGHT.
|
||||
width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
|
||||
height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
|
||||
}
|
||||
logD("Assuming dimens are ${width}x$height")
|
||||
|
||||
// Find the layout with the greatest area that fits entirely within
|
||||
// the app widget. This is what we will use. Fall back to the smallest layout
|
||||
// otherwise.
|
||||
val layout = views.keys.filter { it.width <= width && it.height <= height }
|
||||
.maxByOrNull { it.height * it.width } ?:
|
||||
views.minBy { it.key.width * it.key.height }.key
|
||||
logD("Using layout $layout ${views.contains(layout)}")
|
||||
|
||||
updateAppWidget(id, views[layout])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/ui_widget_bg"
|
||||
android:background="@drawable/ui_widget_bg_system"
|
||||
android:backgroundTint="?attr/colorSurface"
|
||||
android:theme="@style/Theme.Auxio.Widget">
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/ui_widget_bg"
|
||||
android:background="@drawable/ui_widget_bg_system"
|
||||
android:backgroundTint="?attr/colorSurface"
|
||||
android:theme="@style/Theme.Auxio.Widget">
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/ui_widget_bar"
|
||||
android:background="@drawable/ui_widget_bar_system"
|
||||
android:backgroundTint="?attr/colorSurface"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/spacing_mid_medium">
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/ui_widget_bg"
|
||||
android:background="@drawable/ui_widget_bg_system"
|
||||
android:backgroundTint="?attr/colorSurface"
|
||||
android:baselineAligned="false"
|
||||
android:orientation="horizontal"
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/ui_widget_bar"
|
||||
android:background="@drawable/ui_widget_bar_system"
|
||||
android:backgroundTint="?attr/colorSurface"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/spacing_mid_medium">
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">مشغل موسيقى بسيط ومعقول لنظام الاندرويد.</string>
|
||||
<string name="lng_playback">عرض وتحكم بشتغيل الموسيقى</string>
|
||||
<string name="lng_widget">عرض وتحكم بشتغيل الموسيقى</string>
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
<string name="lbl_retry">إعادة المحاولة</string>
|
||||
<string name="lbl_grant">منح</string>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">Jednoduchý, rozumný hudební přehrávač pro Android.</string>
|
||||
<string name="lbl_indexer">Načítání hudby</string>
|
||||
<string name="lng_playback">Zobrazení a ovládání přehrávání hudby</string>
|
||||
<string name="lng_widget">Zobrazení a ovládání přehrávání hudby</string>
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
<string name="lng_indexing">Načítání vaší hudební knihovny…</string>
|
||||
<string name="lbl_retry">Zkusit znovu</string>
|
||||
|
|
|
@ -117,7 +117,7 @@
|
|||
<item quantity="other">%d Alben</item>
|
||||
</plurals>
|
||||
<string name="info_app_desc">Ein einfacher, rationaler Musikplayer für Android.</string>
|
||||
<string name="lng_playback">Musikwiedergabe anzeigen und kontrollieren</string>
|
||||
<string name="lng_widget">Musikwiedergabe anzeigen und kontrollieren</string>
|
||||
<string name="lbl_artist">Künstler</string>
|
||||
<string name="lbl_album">Album</string>
|
||||
<string name="lbl_sort_date">Jahr</string>
|
||||
|
|
|
@ -100,7 +100,7 @@
|
|||
<string name="err_no_dirs">Καθόλου φάκελοι</string>
|
||||
<string name="info_app_desc">Μιά απλή, λογική εφαρμογή αναπαραγωγής μουσικής για Android.</string>
|
||||
<string name="lbl_indexer">Φόρτωση μουσικής</string>
|
||||
<string name="lng_playback">Προβολή και έλεγχος αναπαραγωγής μουσικής</string>
|
||||
<string name="lng_widget">Προβολή και έλεγχος αναπαραγωγής μουσικής</string>
|
||||
<string name="lbl_album">Album</string>
|
||||
<string name="lbl_eps">EP</string>
|
||||
<string name="lbl_ep">EP</string>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<resources>
|
||||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">Un reproductor de música simple y racional para Android.</string>
|
||||
<string name="lng_playback">Ver y controlar la reproducción musical</string>
|
||||
<string name="lng_widget">Ver y controlar la reproducción musical</string>
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
<string name="lbl_retry">Reintentar</string>
|
||||
<string name="lbl_grant">Permitir</string>
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
<string name="lbl_singles">Mga Single</string>
|
||||
<string name="lbl_grant">Bigyan</string>
|
||||
<string name="lbl_genres">Mga Genre</string>
|
||||
<string name="lng_playback">Tignan at ayusin ang pagtugtog ng kanta</string>
|
||||
<string name="lng_widget">Tignan at ayusin ang pagtugtog ng kanta</string>
|
||||
<string name="set_theme">Tema</string>
|
||||
<string name="set_lib_tabs_desc">Ibahin ang pagkakita at ayos ng mga library tab</string>
|
||||
<string name="set_pre_amp_desc">Inilalapat ang pre-amp sa kasalukuyang ayos habang ito\'y tumutugtog</string>
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
<string name="set_lib_tabs">Onglets de la bibliothèque</string>
|
||||
<string name="info_app_desc">Un lecteur de musique simple et rationnel pour Android.</string>
|
||||
<string name="lbl_indexer">Chargement de musique</string>
|
||||
<string name="lng_playback">Afficher et contrôler la lecture de la musique</string>
|
||||
<string name="lng_widget">Afficher et contrôler la lecture de la musique</string>
|
||||
<string name="lng_indexing">Chargement de votre bibliothèque musicale…</string>
|
||||
<string name="lbl_sort_name">Nom</string>
|
||||
<string name="lbl_artist">Artiste</string>
|
||||
|
|
|
@ -171,7 +171,7 @@
|
|||
<string name="info_app_desc">Jednostavan i racionalan izvođač glazbe za Android.</string>
|
||||
<string name="lbl_retry">Pokušaj ponovo</string>
|
||||
<string name="lbl_indexer">Učitavanje glazbe</string>
|
||||
<string name="lng_playback">Pregledaj i upravljaj reprodukcijom glazbe</string>
|
||||
<string name="lng_widget">Pregledaj i upravljaj reprodukcijom glazbe</string>
|
||||
<string name="lbl_grant">Dozvoli</string>
|
||||
<string name="lbl_single_live">Singl uživo</string>
|
||||
<string name="lbl_single_remix">Singl remiks</string>
|
||||
|
|
|
@ -76,7 +76,7 @@
|
|||
<string name="lbl_album">Album</string>
|
||||
<string name="info_app_desc">Pemutar musik yang simpel dan rasional untuk android.</string>
|
||||
<string name="lbl_indexer">Pemuatan Musik</string>
|
||||
<string name="lng_playback">Lihat dan kontrol pemutaran musik</string>
|
||||
<string name="lng_widget">Lihat dan kontrol pemutaran musik</string>
|
||||
<string name="lbl_sort_date">Tahun</string>
|
||||
<string name="lbl_sort_duration">Durasi</string>
|
||||
<string name="lbl_sort_disc">Disk</string>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<resources>
|
||||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">Un semplice, razionale lettore musicale per android.</string>
|
||||
<string name="lng_playback">Vedi e gestisci la riproduzione musicale</string>
|
||||
<string name="lng_widget">Vedi e gestisci la riproduzione musicale</string>
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
<string name="lbl_retry">Riprova</string>
|
||||
<string name="lbl_grant">Permetti</string>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">단순하고, 실용적인 안드로이드용 뮤직 플레이어입니다.</string>
|
||||
<string name="lng_playback">음악 재생 제어 및 상태 확인</string>
|
||||
<string name="lng_widget">음악 재생 제어 및 상태 확인</string>
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
<string name="lbl_retry">재시도</string>
|
||||
<string name="lbl_grant">허가</string>
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
<string name="set_black_mode_desc">Naudokti grynai juodą tamsią temą</string>
|
||||
<string name="info_app_desc">Paprastas, racionalus „Android“ muzikos grotuvas.</string>
|
||||
<string name="lbl_indexer">Muzika kraunama</string>
|
||||
<string name="lng_playback">Peržiūrėti ir valdyti muzikos grojimą</string>
|
||||
<string name="lng_widget">Peržiūrėti ir valdyti muzikos grojimą</string>
|
||||
<string name="lbl_genres">Žanrai</string>
|
||||
<string name="lbl_retry">Bandykite dar kartą</string>
|
||||
<string name="lbl_grant">Suteikti</string>
|
||||
|
|
|
@ -114,7 +114,7 @@
|
|||
<string name="fmt_disc_no">Schijf %d</string>
|
||||
<string name="fmt_bitrate">%d kbps</string>
|
||||
<string name="fmt_db_pos">+%.1f dB</string>
|
||||
<string name="lng_playback">Muziekweergave bekijken en regelen</string>
|
||||
<string name="lng_widget">Muziekweergave bekijken en regelen</string>
|
||||
<string name="fmt_sample_rate">%d Hz</string>
|
||||
<string name="lbl_song_detail">Bekijk eigenschappen</string>
|
||||
<string name="lbl_sort_name">Naam</string>
|
||||
|
|
|
@ -215,7 +215,7 @@
|
|||
<string name="desc_clear_search">Wyczyść zapytanie wyszukiwania</string>
|
||||
<string name="err_did_not_restore">Nie można przywrócić stanu odtwarzania</string>
|
||||
<string name="desc_genre_image">Okładka gatunku %s</string>
|
||||
<string name="lng_playback">Wyświetlanie oraz kontrolowanie odtwarzania muzyki</string>
|
||||
<string name="lng_widget">Wyświetlanie oraz kontrolowanie odtwarzania muzyki</string>
|
||||
<string name="set_pre_amp_with">Regulacja w oparciu o tagi</string>
|
||||
<string name="set_pre_amp_without">Regulacja bez tagów</string>
|
||||
<string name="set_pre_amp_desc">Wzmocnienie dźwięku przez preamplifier jest nakładane na wcześniej ustawione wzmocnienie podczas odtwarzania</string>
|
||||
|
|
|
@ -201,7 +201,7 @@
|
|||
<string name="set_save_state">Salvar lista de reprodução</string>
|
||||
<string name="set_wipe_state">Limpar lista de reprodução</string>
|
||||
<string name="set_restore_state">Restaurar lista de reprodução</string>
|
||||
<string name="lng_playback">Visualize e controle a reprodução de música</string>
|
||||
<string name="lng_widget">Visualize e controle a reprodução de música</string>
|
||||
<string name="set_bar_action">Ação personalizada na barra de reprodução</string>
|
||||
<string name="set_bar_action_repeat">Modo de repetição</string>
|
||||
<string name="set_wipe_desc">Limpa a lista de reprodução salva anteriormente (se houver)</string>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">Простой и рациональный музыкальный проигрыватель.</string>
|
||||
<string name="lng_playback">Настройки воспроизведения</string>
|
||||
<string name="lng_widget">Настройки воспроизведения</string>
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
<string name="lbl_retry">Повторить попытку</string>
|
||||
<string name="lbl_grant">Разрешить</string>
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
<string name="lbl_size">Boyut</string>
|
||||
<string name="lbl_bitrate">Bit hızı</string>
|
||||
<string name="lbl_sample_rate">Örnek hızı</string>
|
||||
<string name="lng_playback">Müzik çalmayı görüntüle ve kontrol et</string>
|
||||
<string name="lng_widget">Müzik çalmayı görüntüle ve kontrol et</string>
|
||||
<string name="info_app_desc">Android için basit, rasyonel bir müzik çalar.</string>
|
||||
<string name="lbl_indexer">Müzik Yükleniyor</string>
|
||||
<string name="lng_indexing">Müzik kitaplığınız yükleniyor…</string>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">一款简洁、克制的 Android 音乐播放器。</string>
|
||||
<string name="lng_playback">查看并控制音乐播放</string>
|
||||
<string name="lng_widget">查看并控制音乐播放</string>
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
<string name="lbl_retry">重试</string>
|
||||
<string name="lbl_grant">授予</string>
|
||||
|
|
|
@ -48,6 +48,6 @@
|
|||
<dimen name="recycler_fab_space_normal">88dp</dimen>
|
||||
<dimen name="recycler_fab_space_large">128dp</dimen>
|
||||
|
||||
<dimen name="widget_width_def">180dp</dimen>
|
||||
<dimen name="widget_height_def">100dp</dimen>
|
||||
<dimen name="widget_def_width">180dp</dimen>
|
||||
<dimen name="widget_def_height">100dp</dimen>
|
||||
</resources>
|
|
@ -147,7 +147,7 @@
|
|||
<!-- Long Namespace | Longer Descriptions -->
|
||||
<eat-comment />
|
||||
|
||||
<string name="lng_playback">View and control music playback</string>
|
||||
<string name="lng_widget">View and control music playback</string>
|
||||
<string name="lng_indexing">Loading your music library…</string>
|
||||
<string name="lng_observing">Monitoring your music library for changes…</string>
|
||||
<string name="lng_queue_added">Added to queue</string>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/lng_playback"
|
||||
android:description="@string/lng_widget"
|
||||
android:initialLayout="@layout/widget_default"
|
||||
android:minWidth="@dimen/widget_width_def"
|
||||
android:minHeight="@dimen/widget_height_def"
|
||||
android:minResizeWidth="@dimen/widget_width_def"
|
||||
android:minResizeHeight="@dimen/widget_height_def"
|
||||
android:minWidth="@dimen/widget_def_width"
|
||||
android:minHeight="@dimen/widget_def_height"
|
||||
android:minResizeWidth="@dimen/widget_def_width"
|
||||
android:minResizeHeight="@dimen/widget_def_height"
|
||||
android:previewImage="@drawable/ui_widget_preview"
|
||||
android:previewLayout="@layout/widget_small"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_default"
|
||||
android:minWidth="@dimen/widget_width_def"
|
||||
android:minHeight="@dimen/widget_height_def"
|
||||
android:minResizeWidth="@dimen/widget_width_def"
|
||||
android:minResizeHeight="@dimen/widget_height_def"
|
||||
android:minWidth="@dimen/widget_def_width"
|
||||
android:minHeight="@dimen/widget_def_height"
|
||||
android:minResizeWidth="@dimen/widget_def_width"
|
||||
android:minResizeHeight="@dimen/widget_def_height"
|
||||
android:previewImage="@drawable/ui_widget_preview"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="0"
|
||||
|
|
Loading…
Reference in a new issue