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>
<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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -451,7 +451,7 @@ class PlaybackService :
playbackManager.changePlaying(false)
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
/**
* 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,

View file

@ -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(
context: Context,
views: Map<SizeF, RemoteViews>
) {
check(views.isNotEmpty()) { "Must provide a non-empty map" }
// --- LAYOUTS ---
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.
updateAppWidget(name, RemoteViews(views))
/**
* 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 {
// Otherwise, we try our best to backport the responsive behavior to older versions.
// 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])
}
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,
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 {
/**
* 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"
}
}

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: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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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