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:
Alexander Capehart 2022-12-23 21:37:48 -07:00
parent b38b8a909f
commit 200a3dfeaf
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
43 changed files with 523 additions and 437 deletions

View file

@ -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> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <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 - Snappy UI derived from the latest Material Design guidelines
- Opinionated UX that prioritizes ease of use over edge cases - Opinionated UX that prioritizes ease of use over edge cases
- Customizable behavior - Customizable behavior
- Seamless artist system that unifies album artist and artist tags - Support for disc numbers, multiple artists, release types,
- Advanced media indexer with support for multiple artists, release types, precise/original dates, sort tags, and more
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 - SD Card-aware folder management
- Reliable playback state persistence - Reliable playback state persistence
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files) - Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)

View file

@ -59,13 +59,11 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setupTheme() setupTheme()
// Inflate the views after setting up the theme so that the theme attributes are applied.
val binding = ActivityMainBinding.inflate(layoutInflater) val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setupEdgeToEdge(binding.root) setupEdgeToEdge(binding.root)
logD("Activity created") logD("Activity created")
} }
@ -76,6 +74,7 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, PlaybackService::class.java)) startService(Intent(this, PlaybackService::class.java))
if (!startIntentAction(intent)) { if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state.
playbackModel.startAction(InternalPlayer.Action.RestoreState) playbackModel.startAction(InternalPlayer.Action.RestoreState)
} }
} }
@ -85,6 +84,32 @@ class MainActivity : AppCompatActivity() {
startIntentAction(intent) 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] * Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action]
* that can be used in the playback system. * that can be used in the playback system.
@ -117,30 +142,6 @@ class MainActivity : AppCompatActivity() {
return true 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 { companion object {
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED" private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
} }

View file

@ -69,6 +69,8 @@ class MainFragment :
override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --- // --- UI SETUP ---
val context = requireActivity() val context = requireActivity()
// Override the back pressed callback so we can map back navigation to collapsing // Override the back pressed callback so we can map back navigation to collapsing

View file

@ -75,6 +75,7 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
binding.detailToolbar.apply { binding.detailToolbar.apply {
inflateMenu(R.menu.menu_album_detail) inflateMenu(R.menu.menu_album_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -84,10 +85,8 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = detailAdapter
// -- VIEWMODEL SETUP --- // -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbumUid(args.albumUid) detailModel.setAlbumUid(args.albumUid)
collectImmediately(detailModel.currentAlbum, ::updateAlbum) collectImmediately(detailModel.currentAlbum, ::updateAlbum)
collectImmediately(detailModel.albumList, detailAdapter::submitList) collectImmediately(detailModel.albumList, detailAdapter::submitList)
collectImmediately( collectImmediately(

View file

@ -75,6 +75,7 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply { binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail) inflateMenu(R.menu.menu_genre_artist_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -84,10 +85,8 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = detailAdapter
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtistUid(args.artistUid) detailModel.setArtistUid(args.artistUid)
collectImmediately(detailModel.currentArtist, ::updateItem) collectImmediately(detailModel.currentArtist, ::updateItem)
collectImmediately(detailModel.artistList, detailAdapter::submitList) collectImmediately(detailModel.artistList, detailAdapter::submitList)
collectImmediately( collectImmediately(

View file

@ -74,6 +74,7 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply { binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail) inflateMenu(R.menu.menu_genre_artist_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -83,10 +84,8 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = detailAdapter
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenreUid(args.genreUid) detailModel.setGenreUid(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updateItem) collectImmediately(detailModel.currentGenre, ::updateItem)
collectImmediately(detailModel.genreList, detailAdapter::submitList) collectImmediately(detailModel.genreList, detailAdapter::submitList)
collectImmediately( collectImmediately(

View file

@ -57,14 +57,13 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
} }
private fun updateSong(song: DetailSong?) { private fun updateSong(song: DetailSong?) {
val binding = requireBinding()
if (song == null) { if (song == null) {
// Song we were showing no longer exists. // Song we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
val binding = requireBinding()
if (song.properties != null) { if (song.properties != null) {
// Finished loading Song properties, populate and show the list of Song information. // Finished loading Song properties, populate and show the list of Song information.
binding.detailLoading.isInvisible = true binding.detailLoading.isInvisible = true

View file

@ -106,6 +106,7 @@ class HomeFragment :
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(this) binding.homeToolbar.setOnMenuItemClickListener(this)
@ -152,7 +153,6 @@ class HomeFragment :
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() } binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.shouldRecreate, ::handleRecreate) collect(homeModel.shouldRecreate, ::handleRecreate)
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab) collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab)

View file

@ -451,7 +451,7 @@ class PlaybackService :
playbackManager.changePlaying(false) playbackManager.changePlaying(false)
stopAndSave() stopAndSave()
} }
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update() WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.updateNowPlaying()
} }
} }

View file

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

View file

@ -36,39 +36,31 @@ import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the * A component that manages the state of all of Auxio's widgets.
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may * This is kept separate from the AppWidgetProviders themselves to prevent possible memory
* result in memory leaks if [PlaybackStateManager]/[Settings] gets created and bound to without * leaks and enable the main functionality to be extended to more widgets in the future.
* being released. * @param context [Context] required to manage AppWidgetProviders.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class WidgetComponent(private val context: Context) : class WidgetComponent(private val context: Context) :
PlaybackStateManager.Callback, Settings.Callback { PlaybackStateManager.Callback, Settings.Callback {
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context, this) private val settings = Settings(context, this)
private val widget = WidgetProvider() private val widgetProvider = WidgetProvider()
private val provider = BitmapProvider(context) private val provider = BitmapProvider(context)
init { init {
playbackManager.addCallback(this) playbackManager.addCallback(this)
if (playbackManager.isInitialized) {
update()
}
} }
/* /**
* Force-update the widget. * Update [WidgetProvider] with the current playback state.
*/ */
fun update() { fun updateNowPlaying() {
// 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.
val song = playbackManager.song val song = playbackManager.song
if (song == null) { if (song == null) {
logD("No song, resetting widget") logD("No song, resetting widget")
widget.update(context, null) widgetProvider.update(context, null)
return return
} }
@ -83,7 +75,7 @@ class WidgetComponent(private val context: Context) :
override fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder { override fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder {
val cornerRadius = val cornerRadius =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 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) context.getDimenPixels(android.R.dimen.system_app_widget_inner_radius)
} else if (settings.roundMode) { } else if (settings.roundMode) {
// < Android 12, but the user still enabled round mode. // < Android 12, but the user still enabled round mode.
@ -93,8 +85,6 @@ class WidgetComponent(private val context: Context) :
0 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 metrics = context.resources.displayMetrics
val sw = metrics.widthPixels val sw = metrics.widthPixels
val sh = metrics.heightPixels 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 // to work around a bug in Android 13 where the bitmaps aren't pooled
// properly, massively reducing the memory size we can work with. // properly, massively reducing the memory size we can work with.
builder builder
.size(computeSize(sw, sh, 10f)) .size(computeWidgetImageSize(sw, sh, 10f))
.transformations( .transformations(
SquareFrameTransform.INSTANCE, SquareFrameTransform.INSTANCE,
RoundedCornersTransformation(cornerRadius.toFloat())) RoundedCornersTransformation(cornerRadius.toFloat()))
} else { } else {
// Divide by two to really make sure we aren't hitting the memory limit. // 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?) { override fun onCompleted(bitmap: Bitmap?) {
val state = WidgetState(song, bitmap, isPlaying, repeatMode, isShuffled) val state = PlaybackState(song, bitmap, isPlaying, repeatMode, isShuffled)
widget.update(context, state) 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() 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() { fun release() {
provider.release() provider.release()
settings.release() settings.release()
widget.reset(context) widgetProvider.reset(context)
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
} }
// --- CALLBACKS --- // --- CALLBACKS ---
override fun onIndexMoved(index: Int) = update() // Hook all the major song-changing updates + the major player state updates
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update() // to updating the "Now Playing" widget.
override fun onStateChanged(state: InternalPlayer.State) = update() override fun onIndexMoved(index: Int) = updateNowPlaying()
override fun onShuffledChanged(isShuffled: Boolean) = update() override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = updateNowPlaying()
override fun onRepeatChanged(repeatMode: RepeatMode) = update() override fun onStateChanged(state: InternalPlayer.State) = updateNowPlaying()
override fun onShuffledChanged(isShuffled: Boolean) = updateNowPlaying()
override fun onRepeatChanged(repeatMode: RepeatMode) = updateNowPlaying()
override fun onSettingChanged(key: String) { override fun onSettingChanged(key: String) {
if (key == context.getString(R.string.set_key_cover_mode) || if (key == context.getString(R.string.set_key_cover_mode) ||
key == context.getString(R.string.set_key_round_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 * A condensed form of the playback state that is safe to use in AppWidgets.
* does not need to be queried directly. * @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 song: Song,
val cover: Bitmap?, val cover: Bitmap?,
val isPlaying: Boolean, val isPlaying: Boolean,

View file

@ -25,73 +25,30 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.SizeF import android.util.SizeF
import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logW 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 * The [AppWidgetProvider] for the "Now Playing" widget. This widget shows the current
* packing what could be considered multiple widgets into a single responsive widget. * playback state alongside actions to control it.
*
* 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.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class WidgetProvider : AppWidgetProvider() { 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( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray appWidgetIds: IntArray
) { ) {
reset(context)
requestUpdate(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( override fun onAppWidgetOptionsChanged(
@ -101,80 +58,322 @@ class WidgetProvider : AppWidgetProvider() {
newOptions: Bundle? newOptions: Bundle?
) { ) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) 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) { 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) 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 --- // --- INTERNAL METHODS ---
/**
* Request an update from [WidgetComponent].
* @param context [Context] required to send update request broadcast.
*/
private fun requestUpdate(context: Context) { private fun requestUpdate(context: Context) {
logD("Sending update intent to PlaybackService") logD("Sending update intent to PlaybackService")
val intent = Intent(ACTION_WIDGET_UPDATE).addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY) val intent = Intent(ACTION_WIDGET_UPDATE).addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
private fun AppWidgetManager.updateAppWidgetCompat( // --- LAYOUTS ---
context: Context,
views: Map<SizeF, RemoteViews>
) {
check(views.isNotEmpty()) { "Must provide a non-empty map" }
val name = ComponentName(context, WidgetProvider::class.java) /**
* 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)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { /**
// Widgets are automatically responsive on Android 12, no need to do anything. * Create and configure a [RemoteViews] for [R.layout.widget_thin], intended for extremely
updateAppWidget(name, RemoteViews(views)) * 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 { } else {
// Otherwise, we try our best to backport the responsive behavior to older versions. R.drawable.ui_widget_bar_system
// This seems to work well enough on most launchers.
// 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)
val width: Int
val height: Int
// 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)
} else {
width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
}
logD("Assuming true widget dimens are ${width}x$height")
// 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)
}
}
val layout =
candidates.maxByOrNull { it.height * it.width }
?: views.minBy { it.key.width * it.key.height }.key
logD("Using widget layout $layout ${views.contains(layout)}")
updateAppWidget(id, views[layout])
}
} }
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,
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 {
// 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))
}
return this
}
/**
* 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
}
/**
* 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 {
icon = R.drawable.ic_play_24
background = R.drawable.ui_remote_fab_container_paused
}
setImageViewResource(R.id.widget_play_pause, icon)
setBackgroundResource(R.id.widget_play_pause, background)
return this
}
/**
* 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
}
/**
* 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 { 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" const val ACTION_WIDGET_UPDATE = BuildConfig.APPLICATION_ID + ".action.WIDGET_UPDATE"
} }
} }

View 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])
}
}
}

View file

@ -4,7 +4,7 @@
android:id="@android:id/background" android:id="@android:id/background"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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:backgroundTint="?attr/colorSurface"
android:theme="@style/Theme.Auxio.Widget"> android:theme="@style/Theme.Auxio.Widget">

View file

@ -4,7 +4,7 @@
android:id="@android:id/background" android:id="@android:id/background"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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:backgroundTint="?attr/colorSurface"
android:theme="@style/Theme.Auxio.Widget"> android:theme="@style/Theme.Auxio.Widget">

View file

@ -61,7 +61,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentBottom="true" android:layout_alignParentBottom="true"
android:layout_gravity="center" android:layout_gravity="center"
android:background="@drawable/ui_widget_bar" android:background="@drawable/ui_widget_bar_system"
android:backgroundTint="?attr/colorSurface" android:backgroundTint="?attr/colorSurface"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="@dimen/spacing_mid_medium"> android:padding="@dimen/spacing_mid_medium">

View file

@ -4,7 +4,7 @@
android:id="@android:id/background" android:id="@android:id/background"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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:backgroundTint="?attr/colorSurface"
android:baselineAligned="false" android:baselineAligned="false"
android:orientation="horizontal" android:orientation="horizontal"

View file

@ -48,7 +48,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentBottom="true" android:layout_alignParentBottom="true"
android:layout_gravity="center" android:layout_gravity="center"
android:background="@drawable/ui_widget_bar" android:background="@drawable/ui_widget_bar_system"
android:backgroundTint="?attr/colorSurface" android:backgroundTint="?attr/colorSurface"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="@dimen/spacing_mid_medium"> android:padding="@dimen/spacing_mid_medium">

View file

@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">مشغل موسيقى بسيط ومعقول لنظام الاندرويد.</string> <string name="info_app_desc">مشغل موسيقى بسيط ومعقول لنظام الاندرويد.</string>
<string name="lng_playback">عرض وتحكم بشتغيل الموسيقى</string> <string name="lng_widget">عرض وتحكم بشتغيل الموسيقى</string>
<!-- Label Namespace | Static Labels --> <!-- Label Namespace | Static Labels -->
<string name="lbl_retry">إعادة المحاولة</string> <string name="lbl_retry">إعادة المحاولة</string>
<string name="lbl_grant">منح</string> <string name="lbl_grant">منح</string>

View file

@ -3,7 +3,7 @@
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">Jednoduchý, rozumný hudební přehrávač pro Android.</string> <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="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 --> <!-- Label Namespace | Static Labels -->
<string name="lng_indexing">Načítání vaší hudební knihovny…</string> <string name="lng_indexing">Načítání vaší hudební knihovny…</string>
<string name="lbl_retry">Zkusit znovu</string> <string name="lbl_retry">Zkusit znovu</string>

View file

@ -117,7 +117,7 @@
<item quantity="other">%d Alben</item> <item quantity="other">%d Alben</item>
</plurals> </plurals>
<string name="info_app_desc">Ein einfacher, rationaler Musikplayer für Android.</string> <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_artist">Künstler</string>
<string name="lbl_album">Album</string> <string name="lbl_album">Album</string>
<string name="lbl_sort_date">Jahr</string> <string name="lbl_sort_date">Jahr</string>

View file

@ -100,7 +100,7 @@
<string name="err_no_dirs">Καθόλου φάκελοι</string> <string name="err_no_dirs">Καθόλου φάκελοι</string>
<string name="info_app_desc">Μιά απλή, λογική εφαρμογή αναπαραγωγής μουσικής για Android.</string> <string name="info_app_desc">Μιά απλή, λογική εφαρμογή αναπαραγωγής μουσικής για Android.</string>
<string name="lbl_indexer">Φόρτωση μουσικής</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_album">Album</string>
<string name="lbl_eps">EP</string> <string name="lbl_eps">EP</string>
<string name="lbl_ep">EP</string> <string name="lbl_ep">EP</string>

View file

@ -2,7 +2,7 @@
<resources> <resources>
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">Un reproductor de música simple y racional para Android.</string> <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 --> <!-- Label Namespace | Static Labels -->
<string name="lbl_retry">Reintentar</string> <string name="lbl_retry">Reintentar</string>
<string name="lbl_grant">Permitir</string> <string name="lbl_grant">Permitir</string>

View file

@ -94,7 +94,7 @@
<string name="lbl_singles">Mga Single</string> <string name="lbl_singles">Mga Single</string>
<string name="lbl_grant">Bigyan</string> <string name="lbl_grant">Bigyan</string>
<string name="lbl_genres">Mga Genre</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_theme">Tema</string>
<string name="set_lib_tabs_desc">Ibahin ang pagkakita at ayos ng mga library tab</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> <string name="set_pre_amp_desc">Inilalapat ang pre-amp sa kasalukuyang ayos habang ito\'y tumutugtog</string>

View file

@ -94,7 +94,7 @@
<string name="set_lib_tabs">Onglets de la bibliothèque</string> <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="info_app_desc">Un lecteur de musique simple et rationnel pour Android.</string>
<string name="lbl_indexer">Chargement de musique</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="lng_indexing">Chargement de votre bibliothèque musicale…</string>
<string name="lbl_sort_name">Nom</string> <string name="lbl_sort_name">Nom</string>
<string name="lbl_artist">Artiste</string> <string name="lbl_artist">Artiste</string>

View file

@ -171,7 +171,7 @@
<string name="info_app_desc">Jednostavan i racionalan izvođač glazbe za Android.</string> <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_retry">Pokušaj ponovo</string>
<string name="lbl_indexer">Učitavanje glazbe</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_grant">Dozvoli</string>
<string name="lbl_single_live">Singl uživo</string> <string name="lbl_single_live">Singl uživo</string>
<string name="lbl_single_remix">Singl remiks</string> <string name="lbl_single_remix">Singl remiks</string>

View file

@ -76,7 +76,7 @@
<string name="lbl_album">Album</string> <string name="lbl_album">Album</string>
<string name="info_app_desc">Pemutar musik yang simpel dan rasional untuk android.</string> <string name="info_app_desc">Pemutar musik yang simpel dan rasional untuk android.</string>
<string name="lbl_indexer">Pemuatan Musik</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_date">Tahun</string>
<string name="lbl_sort_duration">Durasi</string> <string name="lbl_sort_duration">Durasi</string>
<string name="lbl_sort_disc">Disk</string> <string name="lbl_sort_disc">Disk</string>

View file

@ -2,7 +2,7 @@
<resources> <resources>
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">Un semplice, razionale lettore musicale per android.</string> <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 --> <!-- Label Namespace | Static Labels -->
<string name="lbl_retry">Riprova</string> <string name="lbl_retry">Riprova</string>
<string name="lbl_grant">Permetti</string> <string name="lbl_grant">Permetti</string>

View file

@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">단순하고, 실용적인 안드로이드용 뮤직 플레이어입니다.</string> <string name="info_app_desc">단순하고, 실용적인 안드로이드용 뮤직 플레이어입니다.</string>
<string name="lng_playback">음악 재생 제어 및 상태 확인</string> <string name="lng_widget">음악 재생 제어 및 상태 확인</string>
<!-- Label Namespace | Static Labels --> <!-- Label Namespace | Static Labels -->
<string name="lbl_retry">재시도</string> <string name="lbl_retry">재시도</string>
<string name="lbl_grant">허가</string> <string name="lbl_grant">허가</string>

View file

@ -49,7 +49,7 @@
<string name="set_black_mode_desc">Naudokti grynai juodą tamsią temą</string> <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="info_app_desc">Paprastas, racionalus „Android“ muzikos grotuvas.</string>
<string name="lbl_indexer">Muzika kraunama</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_genres">Žanrai</string>
<string name="lbl_retry">Bandykite dar kartą</string> <string name="lbl_retry">Bandykite dar kartą</string>
<string name="lbl_grant">Suteikti</string> <string name="lbl_grant">Suteikti</string>

View file

@ -114,7 +114,7 @@
<string name="fmt_disc_no">Schijf %d</string> <string name="fmt_disc_no">Schijf %d</string>
<string name="fmt_bitrate">%d kbps</string> <string name="fmt_bitrate">%d kbps</string>
<string name="fmt_db_pos">+%.1f dB</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="fmt_sample_rate">%d Hz</string>
<string name="lbl_song_detail">Bekijk eigenschappen</string> <string name="lbl_song_detail">Bekijk eigenschappen</string>
<string name="lbl_sort_name">Naam</string> <string name="lbl_sort_name">Naam</string>

View file

@ -215,7 +215,7 @@
<string name="desc_clear_search">Wyczyść zapytanie wyszukiwania</string> <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="err_did_not_restore">Nie można przywrócić stanu odtwarzania</string>
<string name="desc_genre_image">Okładka gatunku %s</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_with">Regulacja w oparciu o tagi</string>
<string name="set_pre_amp_without">Regulacja bez tagów</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> <string name="set_pre_amp_desc">Wzmocnienie dźwięku przez preamplifier jest nakładane na wcześniej ustawione wzmocnienie podczas odtwarzania</string>

View file

@ -201,7 +201,7 @@
<string name="set_save_state">Salvar lista de reprodução</string> <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_wipe_state">Limpar lista de reprodução</string>
<string name="set_restore_state">Restaurar 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">Ação personalizada na barra de reprodução</string>
<string name="set_bar_action_repeat">Modo de repetiçã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> <string name="set_wipe_desc">Limpa a lista de reprodução salva anteriormente (se houver)</string>

View file

@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">Простой и рациональный музыкальный проигрыватель.</string> <string name="info_app_desc">Простой и рациональный музыкальный проигрыватель.</string>
<string name="lng_playback">Настройки воспроизведения</string> <string name="lng_widget">Настройки воспроизведения</string>
<!-- Label Namespace | Static Labels --> <!-- Label Namespace | Static Labels -->
<string name="lbl_retry">Повторить попытку</string> <string name="lbl_retry">Повторить попытку</string>
<string name="lbl_grant">Разрешить</string> <string name="lbl_grant">Разрешить</string>

View file

@ -71,7 +71,7 @@
<string name="lbl_size">Boyut</string> <string name="lbl_size">Boyut</string>
<string name="lbl_bitrate">Bit hızı</string> <string name="lbl_bitrate">Bit hızı</string>
<string name="lbl_sample_rate">Örnek 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="info_app_desc">Android için basit, rasyonel bir müzik çalar.</string>
<string name="lbl_indexer">Müzik Yükleniyor</string> <string name="lbl_indexer">Müzik Yükleniyor</string>
<string name="lng_indexing">Müzik kitaplığınız yükleniyor…</string> <string name="lng_indexing">Müzik kitaplığınız yükleniyor…</string>

View file

@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">一款简洁、克制的 Android 音乐播放器。</string> <string name="info_app_desc">一款简洁、克制的 Android 音乐播放器。</string>
<string name="lng_playback">查看并控制音乐播放</string> <string name="lng_widget">查看并控制音乐播放</string>
<!-- Label Namespace | Static Labels --> <!-- Label Namespace | Static Labels -->
<string name="lbl_retry">重试</string> <string name="lbl_retry">重试</string>
<string name="lbl_grant">授予</string> <string name="lbl_grant">授予</string>

View file

@ -48,6 +48,6 @@
<dimen name="recycler_fab_space_normal">88dp</dimen> <dimen name="recycler_fab_space_normal">88dp</dimen>
<dimen name="recycler_fab_space_large">128dp</dimen> <dimen name="recycler_fab_space_large">128dp</dimen>
<dimen name="widget_width_def">180dp</dimen> <dimen name="widget_def_width">180dp</dimen>
<dimen name="widget_height_def">100dp</dimen> <dimen name="widget_def_height">100dp</dimen>
</resources> </resources>

View file

@ -147,7 +147,7 @@
<!-- Long Namespace | Longer Descriptions --> <!-- Long Namespace | Longer Descriptions -->
<eat-comment /> <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_indexing">Loading your music library…</string>
<string name="lng_observing">Monitoring your music library for changes…</string> <string name="lng_observing">Monitoring your music library for changes…</string>
<string name="lng_queue_added">Added to queue</string> <string name="lng_queue_added">Added to queue</string>

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <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:initialLayout="@layout/widget_default"
android:minWidth="@dimen/widget_width_def" android:minWidth="@dimen/widget_def_width"
android:minHeight="@dimen/widget_height_def" android:minHeight="@dimen/widget_def_height"
android:minResizeWidth="@dimen/widget_width_def" android:minResizeWidth="@dimen/widget_def_width"
android:minResizeHeight="@dimen/widget_height_def" android:minResizeHeight="@dimen/widget_def_height"
android:previewImage="@drawable/ui_widget_preview" android:previewImage="@drawable/ui_widget_preview"
android:previewLayout="@layout/widget_small" android:previewLayout="@layout/widget_small"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_default" android:initialLayout="@layout/widget_default"
android:minWidth="@dimen/widget_width_def" android:minWidth="@dimen/widget_def_width"
android:minHeight="@dimen/widget_height_def" android:minHeight="@dimen/widget_def_height"
android:minResizeWidth="@dimen/widget_width_def" android:minResizeWidth="@dimen/widget_def_width"
android:minResizeHeight="@dimen/widget_height_def" android:minResizeHeight="@dimen/widget_def_height"
android:previewImage="@drawable/ui_widget_preview" android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0" android:updatePeriodMillis="0"