all: move all fragments to ViewBindingFragment

Move all fragment instances to the new ViewBindingFragment paradigm,
which should help immensely with reducing memory leaks from list
bindings and to really alleviate the overloaded onCreate functions.
This commit is contained in:
OxygenCobalt 2022-03-23 09:18:08 -06:00
parent 8f38ed6ee5
commit 95057ec357
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
46 changed files with 875 additions and 1034 deletions

View file

@ -28,14 +28,7 @@ import org.oxycblt.auxio.coil.GenreImageFetcher
import org.oxycblt.auxio.coil.MusicKeyer
import org.oxycblt.auxio.settings.SettingsManager
/**
* TODO: Plan for a general UI rework
* ```
* - Refactor fragment class
* - Remove databinding and dedup layouts
* - Rework RecyclerView management and item dragging
* ```
*/
/** TODO: Rework RecyclerView management and item dragging */
@Suppress("UNUSED")
class AuxioApp : Application(), ImageLoaderFactory {
override fun onCreate() {

View file

@ -17,6 +17,7 @@
package org.oxycblt.auxio
/** A table containing all unique integer codes that Auxio uses. */
object IntegerTable {
/** SongViewHolder */
const val ITEM_TYPE_SONG = 0xA000
@ -49,7 +50,7 @@ object IntegerTable {
/** QueueSongViewHolder */
const val ITEM_TYPE_QUEUE_SONG = 0xA00D
/** "Music playback" Notification channel */
/** "Music playback" Notification code */
const val NOTIFICATION_CODE = 0xA0A0
/** Intent request code */
const val REQUEST_CODE = 0xA0C0

View file

@ -22,18 +22,18 @@ import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import com.google.android.material.snackbar.Snackbar
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logW
/**
@ -43,31 +43,25 @@ import org.oxycblt.auxio.util.logW
*
* TODO: Add a new view with a stack trace whenever the music loading process fails.
*/
class MainFragment : Fragment() {
class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private var callback: Callback? = null
private var callback: DynamicBackPressedCallback? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentMainBinding.inflate(inflater)
override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
// --- UI SETUP ---
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reloadMusic(requireContext())
}
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
requireActivity()
.onBackPressedDispatcher
.addCallback(viewLifecycleOwner, Callback(binding).also { callback = it })
.addCallback(viewLifecycleOwner, DynamicBackPressedCallback().also { callback = it })
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Auxio's layout completely breaks down when it's window is resized too small,
@ -87,10 +81,33 @@ class MainFragment : Fragment() {
// Initialize music loading. Do it here so that it shows on every fragment that this
// one contains.
// TODO: Move this to a service [automatic rescanning]
musicModel.loadMusic(requireContext())
// Handle the music loader response.
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
handleLoaderResponse(response, permLauncher)
}
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
}
override fun onResume() {
super.onResume()
callback?.isEnabled = true
}
override fun onPause() {
super.onPause()
callback?.isEnabled = false
}
private fun handleLoaderResponse(
response: MusicStore.Response?,
permLauncher: ActivityResultLauncher<String>
) {
val binding = requireBinding()
// Handle the loader response.
when (response) {
// Ok, start restoring playback now
@ -125,11 +142,12 @@ class MainFragment : Fragment() {
snackbar.show()
}
else -> {}
null -> {}
}
}
playbackModel.song.observe(viewLifecycleOwner) { song ->
private fun updateSong(song: Song?) {
val binding = requireBinding()
if (song != null) {
binding.bottomSheetLayout.show()
} else {
@ -137,27 +155,13 @@ class MainFragment : Fragment() {
}
}
logD("Fragment Created")
return binding.root
}
override fun onResume() {
super.onResume()
callback?.isEnabled = true
}
override fun onPause() {
super.onPause()
callback?.isEnabled = false
}
/**
* A back press callback that handles how to respond to backwards navigation in the detail
* fragments and the playback panel.
*/
inner class Callback(private val binding: FragmentMainBinding) : OnBackPressedCallback(false) {
inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val binding = requireBinding()
if (!binding.bottomSheetLayout.collapse()) {
val navController = binding.exploreNavHost.findNavController()

View file

@ -19,54 +19,23 @@ package org.oxycblt.auxio.accent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogAccentBinding
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.LifecycleDialog
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD
/**
* Dialog responsible for showing the list of accents to select.
* @author OxygenCobalt
*/
class AccentCustomizeDialog : LifecycleDialog() {
class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>() {
private val settingsManager = SettingsManager.getInstance()
private var pendingAccent = settingsManager.accent
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DialogAccentBinding.inflate(inflater)
savedInstanceState?.getInt(KEY_PENDING_ACCENT)?.let { index ->
pendingAccent = Accent(index)
}
// --- UI SETUP ---
binding.accentRecycler.apply {
adapter =
AccentAdapter(pendingAccent) { accent ->
logD("Switching selected accent to $accent")
pendingAccent = accent
}
}
logD("Dialog created")
return binding.root
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index)
}
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.set_accent)
@ -85,6 +54,25 @@ class AccentCustomizeDialog : LifecycleDialog() {
builder.setNegativeButton(android.R.string.cancel, null)
}
override fun onBindingCreated(binding: DialogAccentBinding, savedInstanceState: Bundle?) {
savedInstanceState?.getInt(KEY_PENDING_ACCENT)?.let { index ->
pendingAccent = Accent(index)
}
// --- UI SETUP ---
binding.accentRecycler.adapter =
AccentAdapter(pendingAccent) { accent ->
logD("Switching selected accent to $accent")
pendingAccent = accent
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index)
}
companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.ACCENT_PICKER"
const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"

View file

@ -58,7 +58,7 @@ abstract class BaseFetcher : Fetcher {
/**
* Fetch the artwork of an [album]. This call respects user configuration and has proper
* redundancy in the case that an API fails to load.
* redundancy in the case that metadata fails to load.
*/
protected suspend fun fetchArt(context: Context, album: Album): InputStream? {
if (!settingsManager.showCovers) {
@ -77,14 +77,6 @@ abstract class BaseFetcher : Fetcher {
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? {
val uri = data.albumCoverUri
// Eliminate any chance that this blocking call might mess up the cancellation process
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
}
private suspend fun fetchQualityCovers(context: Context, album: Album): InputStream? {
// Loading quality covers basically means to parse the file metadata ourselves
// and then extract the cover.
@ -207,6 +199,14 @@ abstract class BaseFetcher : Fetcher {
return stream
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? {
val uri = data.albumCoverUri
// Eliminate any chance that this blocking call might mess up the loading process
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
}
/**
* Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph

View file

@ -39,14 +39,14 @@ class SquareFrameTransform : Transformation {
val x = (input.width - dstSize) / 2
val y = (input.height - dstSize) / 2
val wantedWidth = size.width.pxOrElse { dstSize }
val wantedHeight = size.height.pxOrElse { dstSize }
val desiredWidth = size.width.pxOrElse { dstSize }
val desiredHeight = size.height.pxOrElse { dstSize }
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
if (dstSize != wantedWidth || dstSize != wantedHeight) {
if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Desired size differs from the cropped size, resize the bitmap.
return Bitmap.createScaledBitmap(dst, wantedWidth, wantedHeight, true)
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
}
return dst

View file

@ -19,9 +19,6 @@ package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@ -33,6 +30,7 @@ import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
@ -49,14 +47,9 @@ import org.oxycblt.auxio.util.showToast
class AlbumDetailFragment : DetailFragment() {
private val args: AlbumDetailFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
detailModel.setAlbum(args.albumId)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setAlbumId(args.albumId)
val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter =
AlbumDetailAdapter(
playbackModel,
@ -64,11 +57,7 @@ class AlbumDetailFragment : DetailFragment() {
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ALBUM) })
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curAlbum.value!!, binding, R.menu.menu_album_detail) { itemId ->
setupToolbar(detailModel.curAlbum.value!!, R.menu.menu_album_detail) { itemId ->
when (itemId) {
R.id.action_play_next -> {
playbackModel.playNext(detailModel.curAlbum.value!!)
@ -84,16 +73,14 @@ class AlbumDetailFragment : DetailFragment() {
}
}
setupRecycler(binding, detailAdapter) { pos ->
setupRecycler(detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Album
}
updateQueueActions(playbackModel.song.value, binding)
// -- VIEWMODEL SETUP ---
// -- DETAILVIEWMODEL SETUP ---
detailModel.albumData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) }
detailModel.albumData.observe(viewLifecycleOwner, detailAdapter::submitList)
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
@ -102,13 +89,22 @@ class AlbumDetailFragment : DetailFragment() {
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
handleNavigation(item, detailAdapter)
}
updateSong(playbackModel.song.value, detailAdapter)
playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) }
}
private fun handleNavigation(item: Music?, adapter: AlbumDetailAdapter) {
val binding = requireBinding()
when (item) {
// Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise.
is Song -> {
if (detailModel.curAlbum.value!!.id == item.album.id) {
logD("Navigating to a song in this album")
scrollToItem(item.id, binding, detailAdapter)
scrollToItem(item.id, adapter)
detailModel.finishNavToItem()
} else {
logD("Navigating to another album")
@ -142,44 +138,13 @@ class AlbumDetailFragment : DetailFragment() {
}
}
// --- PLAYBACKVIEWMODEL SETUP ---
playbackModel.song.observe(viewLifecycleOwner) { song ->
updateQueueActions(song, binding)
if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM &&
playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id) {
detailAdapter.highlightSong(song, binding.detailRecycler)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler)
}
}
logD("Fragment created")
return binding.root
}
/** Updates the queue actions when */
private fun updateQueueActions(song: Song?, binding: FragmentDetailBinding) {
for (item in binding.detailToolbar.menu.children) {
if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) {
item.isEnabled = song != null
}
}
}
/** Scroll to an song using its [id]. */
private fun scrollToItem(
id: Long,
binding: FragmentDetailBinding,
adapter: AlbumDetailAdapter
) {
private fun scrollToItem(id: Long, adapter: AlbumDetailAdapter) {
// Calculate where the item for the currently played song is
val pos = adapter.currentList.indexOfFirst { it.id == id && it is Song }
if (pos != -1) {
val binding = requireBinding()
binding.detailRecycler.post {
// Make sure to increment the position to make up for the detail header
binding.detailRecycler.layoutManager?.startSmoothScroll(
@ -193,6 +158,25 @@ class AlbumDetailFragment : DetailFragment() {
}
}
/** Updates the queue actions when a song is present or not */
private fun updateSong(song: Song?, adapter: AlbumDetailAdapter) {
val binding = requireBinding()
for (item in binding.detailToolbar.menu.children) {
if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) {
item.isEnabled = song != null
}
}
if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM &&
playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id) {
adapter.highlightSong(song, binding.detailRecycler)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
adapter.highlightSong(null, binding.detailRecycler)
}
}
/**
* [LinearSmoothScroller] subclass that centers the item on the screen instead of snapping to
* the top or bottom.

View file

@ -18,9 +18,6 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
@ -30,6 +27,8 @@ import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
@ -44,21 +43,15 @@ import org.oxycblt.auxio.util.logW
class ArtistDetailFragment : DetailFragment() {
private val args: ArtistDetailFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
detailModel.setArtist(args.artistId)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setArtistId(args.artistId)
val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter =
ArtistDetailAdapter(
playbackModel,
doOnClick = { data ->
if (!detailModel.isNavigating) {
detailModel.setNavigating(true)
findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id))
}
@ -66,12 +59,8 @@ class ArtistDetailFragment : DetailFragment() {
doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) })
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curArtist.value!!, binding)
setupRecycler(binding, detailAdapter) { pos ->
setupToolbar(detailModel.currentArtist.value!!)
setupRecycler(detailAdapter) { pos ->
// If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Artist
@ -79,9 +68,7 @@ class ArtistDetailFragment : DetailFragment() {
// --- VIEWMODEL SETUP ---
detailModel.artistData.observe(viewLifecycleOwner) { data ->
detailAdapter.submitList(data)
}
detailModel.artistData.observe(viewLifecycleOwner, detailAdapter::submitList)
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
@ -89,10 +76,23 @@ class ArtistDetailFragment : DetailFragment() {
}
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation)
// Highlight songs if they are being played
playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) }
// Highlight albums if they are being played
playbackModel.parent.observe(viewLifecycleOwner) { parent ->
updateParent(parent, detailAdapter)
}
}
private fun handleNavigation(item: Music?) {
val binding = requireBinding()
when (item) {
is Artist -> {
if (item.id == detailModel.curArtist.value?.id) {
if (item.id == detailModel.currentArtist.value?.id) {
logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0)
detailModel.finishNavToItem()
@ -117,28 +117,23 @@ class ArtistDetailFragment : DetailFragment() {
}
}
// Highlight albums if they are being played
playbackModel.parent.observe(viewLifecycleOwner) { parent ->
if (parent is Album?) {
detailAdapter.highlightAlbum(parent, binding.detailRecycler)
} else {
detailAdapter.highlightAlbum(null, binding.detailRecycler)
}
}
// Highlight songs if they are being played
playbackModel.song.observe(viewLifecycleOwner) { song ->
private fun updateSong(song: Song?, adapter: ArtistDetailAdapter) {
val binding = requireBinding()
if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST &&
playbackModel.parent.value?.id == detailModel.curArtist.value?.id) {
detailAdapter.highlightSong(song, binding.detailRecycler)
playbackModel.parent.value?.id == detailModel.currentArtist.value?.id) {
adapter.highlightSong(song, binding.detailRecycler)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler)
adapter.highlightSong(null, binding.detailRecycler)
}
}
logD("Fragment created")
return binding.root
private fun updateParent(parent: MusicParent?, adapter: ArtistDetailAdapter) {
val binding = requireBinding()
if (parent is Album?) {
adapter.highlightAlbum(parent, binding.detailRecycler)
} else {
adapter.highlightAlbum(null, binding.detailRecycler)
}
}
}

View file

@ -17,6 +17,7 @@
package org.oxycblt.auxio.detail
import android.view.LayoutInflater
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.children
@ -28,6 +29,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD
@ -35,10 +37,13 @@ import org.oxycblt.auxio.util.logD
* A Base [Fragment] implementing the base features shared across all detail fragments.
* @author OxygenCobalt
*/
abstract class DetailFragment : Fragment() {
abstract class DetailFragment : ViewBindingFragment<FragmentDetailBinding>() {
protected val detailModel: DetailViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateBinding(inflater: LayoutInflater): FragmentDetailBinding =
FragmentDetailBinding.inflate(inflater)
override fun onResume() {
super.onResume()
detailModel.setNavigating(false)
@ -58,11 +63,10 @@ abstract class DetailFragment : Fragment() {
*/
protected fun setupToolbar(
data: MusicParent,
binding: FragmentDetailBinding,
@MenuRes menuId: Int = -1,
onMenuClick: ((itemId: Int) -> Boolean)? = null
) {
binding.detailToolbar.apply {
requireBinding().detailToolbar.apply {
title = data.resolvedName
if (menuId != -1) {
@ -79,11 +83,10 @@ abstract class DetailFragment : Fragment() {
/** Shortcut method for recyclerview setup */
protected fun setupRecycler(
binding: FragmentDetailBinding,
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
gridLookup: (Int) -> Boolean
) {
binding.detailRecycler.apply {
requireBinding().detailRecycler.apply {
adapter = detailAdapter
setHasFixedSize(true)
applySpans(gridLookup)

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode
@ -43,39 +44,37 @@ import org.oxycblt.auxio.util.logD
* @author OxygenCobalt
*/
class DetailViewModel : ViewModel() {
// --- CURRENT VALUES ---
private val mCurGenre = MutableLiveData<Genre?>()
val curGenre: LiveData<Genre?>
get() = mCurGenre
private val mGenreData = MutableLiveData(listOf<Item>())
val genreData: LiveData<List<Item>> = mGenreData
private val mCurArtist = MutableLiveData<Artist?>()
val curArtist: LiveData<Artist?>
get() = mCurArtist
private val mArtistData = MutableLiveData(listOf<Item>())
val artistData: LiveData<List<Item>> = mArtistData
private val mCurAlbum = MutableLiveData<Album?>()
private val mCurrentAlbum = MutableLiveData<Album?>()
val curAlbum: LiveData<Album?>
get() = mCurAlbum
get() = mCurrentAlbum
private val mAlbumData = MutableLiveData(listOf<Item>())
val albumData: LiveData<List<Item>>
get() = mAlbumData
private val mCurrentArtist = MutableLiveData<Artist?>()
val currentArtist: LiveData<Artist?>
get() = mCurrentArtist
private val mArtistData = MutableLiveData(listOf<Item>())
val artistData: LiveData<List<Item>> = mArtistData
private val mCurrentGenre = MutableLiveData<Genre?>()
val currentGenre: LiveData<Genre?>
get() = mCurrentGenre
private val mGenreData = MutableLiveData(listOf<Item>())
val genreData: LiveData<List<Item>> = mGenreData
data class MenuConfig(val anchor: View, val sortMode: Sort)
private val mShowMenu = MutableLiveData<MenuConfig?>(null)
val showMenu: LiveData<MenuConfig?> = mShowMenu
private val mNavToItem = MutableLiveData<Item?>()
private val mNavToItem = MutableLiveData<Music?>()
/** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */
val navToItem: LiveData<Item?>
val navToItem: LiveData<Music?>
get() = mNavToItem
var isNavigating = false
@ -84,25 +83,25 @@ class DetailViewModel : ViewModel() {
private var currentMenuContext: DisplayMode? = null
private val settingsManager = SettingsManager.getInstance()
fun setGenre(id: Long) {
if (mCurGenre.value?.id == id) return
fun setAlbumId(id: Long) {
if (mCurrentAlbum.value?.id == id) return
val musicStore = MusicStore.requireInstance()
mCurGenre.value = musicStore.genres.find { it.id == id }
refreshGenreData()
mCurrentAlbum.value = musicStore.albums.find { it.id == id }
refreshAlbumData()
}
fun setArtist(id: Long) {
if (mCurArtist.value?.id == id) return
fun setArtistId(id: Long) {
if (mCurrentArtist.value?.id == id) return
val musicStore = MusicStore.requireInstance()
mCurArtist.value = musicStore.artists.find { it.id == id }
mCurrentArtist.value = musicStore.artists.find { it.id == id }
refreshArtistData()
}
fun setAlbum(id: Long) {
if (mCurAlbum.value?.id == id) return
fun setGenreId(id: Long) {
if (mCurrentGenre.value?.id == id) return
val musicStore = MusicStore.requireInstance()
mCurAlbum.value = musicStore.albums.find { it.id == id }
refreshAlbumData()
mCurrentGenre.value = musicStore.genres.find { it.id == id }
refreshGenreData()
}
/** Mark that the menu process is done with the new [Sort]. Pass null if there was no change. */
@ -132,7 +131,7 @@ class DetailViewModel : ViewModel() {
}
/** Navigate to an item, whether a song/album/artist */
fun navToItem(item: Item) {
fun navToItem(item: Music) {
mNavToItem.value = item
}
@ -148,7 +147,7 @@ class DetailViewModel : ViewModel() {
private fun refreshGenreData() {
logD("Refreshing genre data")
val genre = requireNotNull(curGenre.value)
val genre = requireNotNull(currentGenre.value)
val data = mutableListOf<Item>(genre)
data.add(
@ -162,14 +161,14 @@ class DetailViewModel : ViewModel() {
mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort)
}))
data.addAll(settingsManager.detailGenreSort.genre(curGenre.value!!))
data.addAll(settingsManager.detailGenreSort.genre(currentGenre.value!!))
mGenreData.value = data
}
private fun refreshArtistData() {
logD("Refreshing artist data")
val artist = requireNotNull(curArtist.value)
val artist = requireNotNull(currentArtist.value)
val data = mutableListOf<Item>(artist)
data.add(Header(id = -2, string = R.string.lbl_albums))

View file

@ -18,9 +18,6 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.FragmentDetailBinding
@ -30,6 +27,7 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
@ -44,35 +42,37 @@ import org.oxycblt.auxio.util.logW
class GenreDetailFragment : DetailFragment() {
private val args: GenreDetailFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
detailModel.setGenre(args.genreId)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setGenreId(args.genreId)
val binding = FragmentDetailBinding.inflate(inflater)
val detailAdapter =
GenreDetailAdapter(
playbackModel,
doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) })
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curGenre.value!!, binding)
setupRecycler(binding, detailAdapter) { pos ->
setupToolbar(detailModel.currentGenre.value!!)
setupRecycler(detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Genre
}
// --- DETAILVIEWMODEL SETUP ---
// --- VIEWMODEL SETUP ---
detailModel.genreData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) }
detailModel.genreData.observe(viewLifecycleOwner, detailAdapter::submitList)
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation)
playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) }
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
showMenu(config)
}
}
}
private fun handleNavigation(item: Music?) {
when (item) {
// All items will launch new detail fragments.
is Artist -> {
@ -82,8 +82,7 @@ class GenreDetailFragment : DetailFragment() {
}
is Album -> {
logD("Navigating to another album")
findNavController()
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id))
findNavController().navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id))
}
is Song -> {
logD("Navigating to another song")
@ -95,26 +94,14 @@ class GenreDetailFragment : DetailFragment() {
}
}
// --- PLAYBACKVIEWMODEL SETUP ---
playbackModel.song.observe(viewLifecycleOwner) { song ->
private fun updateSong(song: Song?, adapter: GenreDetailAdapter) {
val binding = requireBinding()
if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE &&
playbackModel.parent.value?.id == detailModel.curGenre.value!!.id) {
detailAdapter.highlightSong(song, binding.detailRecycler)
playbackModel.parent.value?.id == detailModel.currentGenre.value!!.id) {
adapter.highlightSong(song, binding.detailRecycler)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler)
adapter.highlightSong(null, binding.detailRecycler)
}
}
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
showMenu(config)
}
}
logD("Fragment created")
return binding.root
}
}

View file

@ -20,8 +20,6 @@ package org.oxycblt.auxio.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.core.view.iterator
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@ -41,11 +39,13 @@ import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow
@ -59,26 +59,58 @@ import org.oxycblt.auxio.util.logTraceOrThrow
*
* TODO: Add duration and song count sorts
*/
class HomeFragment : Fragment() {
class HomeFragment : ViewBindingFragment<FragmentHomeBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeBinding.inflate(inflater)
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
val sortItem: MenuItem
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
binding.homeToolbar.apply {
sortItem = menu.findItem(R.id.submenu_sorting)
setOnMenuItemClickListener { item ->
onMenuClick(item)
true
}
}
binding.homePager.apply {
adapter = HomePagerAdapter()
// We know that there will only be a fixed amount of tabs, so we manually set this
// limit to that. This also prevents the appbar lift state from being confused during
// page transitions.
offscreenPageLimit = homeModel.tabs.size
reduceSensitivity(3)
registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) =
homeModel.updateCurrentTab(position)
})
TabLayoutMediator(binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel))
.attach()
}
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP ---
homeModel.fastScrolling.observe(viewLifecycleOwner, ::updateFastScrolling)
homeModel.currentTab.observe(viewLifecycleOwner) { tab -> updateCurrentTab(sortItem, tab) }
homeModel.recreateTabs.observe(viewLifecycleOwner, ::handleRecreateTabs)
musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse)
detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation)
}
private fun onMenuClick(item: MenuItem) {
when (item.itemId) {
R.id.action_search -> {
logD("Navigating to search")
@ -101,161 +133,65 @@ class HomeFragment : Fragment() {
R.id.submenu_sorting -> {}
R.id.option_sort_asc -> {
item.isChecked = !item.isChecked
val new =
homeModel.updateCurrentSort(
requireNotNull(
homeModel
.getSortForDisplay(homeModel.curTab.value!!)
.ascending(item.isChecked)
homeModel.updateCurrentSort(new)
.getSortForDisplay(homeModel.currentTab.value!!)
.ascending(item.isChecked)))
}
// Sorting option was selected, mark it as selected and update the mode
else -> {
item.isChecked = true
val new =
homeModel.updateCurrentSort(
requireNotNull(
homeModel
.getSortForDisplay(homeModel.curTab.value!!)
.assignId(item.itemId)
homeModel.updateCurrentSort(requireNotNull(new))
.getSortForDisplay(homeModel.currentTab.value!!)
.assignId(item.itemId)))
}
}
}
true
}
private fun updateFastScrolling(isFastScrolling: Boolean) {
val binding = requireBinding()
sortItem = menu.findItem(R.id.submenu_sorting)
}
binding.homePager.apply {
adapter = HomePagerAdapter()
// By default, ViewPager2's sensitivity is high enough to result in vertical
// scroll events being registered as horizontal scroll events. Reflect into the
// internal recyclerview and change the touch slope so that touch actions will
// act more as a scroll than as a swipe.
// Derived from:
// https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
try {
val recycler =
ViewPager2::class.java.getDeclaredField("mRecyclerView").run {
isAccessible = true
get(binding.homePager)
}
RecyclerView::class.java.getDeclaredField("mTouchSlop").apply {
isAccessible = true
val slop = get(recycler) as Int
set(recycler, slop * 3) // 3x seems to be the best fit here
}
} catch (e: Exception) {
logE("Unable to reduce ViewPager sensitivity (likely an internal code change)")
e.logTraceOrThrow()
}
// We know that there will only be a fixed amount of tabs, so we manually set this
// limit to that. This also prevents the appbar lift state from being confused during
// page transitions.
offscreenPageLimit = homeModel.tabs.size
registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) =
homeModel.updateCurrentTab(position)
})
TabLayoutMediator(binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel))
.attach()
}
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP ---
// There is no way a fast scrolling event can continue across a re-create. Reset it.
homeModel.updateFastScrolling(false)
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
// Handle the loader response.
when (response) {
is MusicStore.Response.Ok -> binding.homeFab.show()
// While loading or during an error, make sure we keep the shuffle fab hidden so
// that any kind of playback is impossible. PlaybackStateManager also relies on this
// invariant, so please don't change it.
else -> binding.homeFab.hide()
}
}
homeModel.fastScrolling.observe(viewLifecycleOwner) { scrolling ->
// Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response.
if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) {
return@observe
return
}
if (scrolling) {
if (isFastScrolling) {
binding.homeFab.hide()
} else {
binding.homeFab.show()
}
}
homeModel.recreateTabs.observe(viewLifecycleOwner) { recreate ->
// notifyDataSetChanged is not practical for recreating here since it will cache
// the previous fragments. Just instantiate a whole new adapter.
if (recreate) {
binding.homePager.currentItem = 0
binding.homePager.adapter = HomePagerAdapter()
homeModel.finishRecreateTabs()
}
}
homeModel.curTab.observe(viewLifecycleOwner) { t ->
val tab = requireNotNull(t)
private fun updateCurrentTab(sortItem: MenuItem, tab: DisplayMode) {
// Make sure that we update the scrolling view and allowed menu items whenever
// the tab changes.
val binding = requireBinding()
when (tab) {
DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab)
DisplayMode.SHOW_ALBUMS ->
DisplayMode.SHOW_SONGS -> {
updateSortMenu(sortItem, tab)
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list
}
DisplayMode.SHOW_ALBUMS -> {
updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_album }
DisplayMode.SHOW_ARTISTS ->
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list
}
DisplayMode.SHOW_ARTISTS -> {
updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc }
DisplayMode.SHOW_GENRES ->
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_artist_list
}
DisplayMode.SHOW_GENRES -> {
updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc }
}
binding.homeAppbar.liftOnScrollTargetViewId = tab.viewId
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
// The AppBarLayout gets confused when we navigate too fast, wait for it to draw
// before we navigate.
// This is only here just in case a collapsing toolbar is re-added.
binding.homeAppbar.post {
when (item) {
is Song ->
findNavController()
.navigate(HomeFragmentDirections.actionShowAlbum(item.album.id))
is Album ->
findNavController()
.navigate(HomeFragmentDirections.actionShowAlbum(item.id))
is Artist ->
findNavController()
.navigate(HomeFragmentDirections.actionShowArtist(item.id))
is Genre ->
findNavController()
.navigate(HomeFragmentDirections.actionShowGenre(item.id))
else -> {}
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_genre_list
}
}
}
logD("Fragment Created")
return binding.root
}
private fun updateSortMenu(
item: MenuItem,
displayMode: DisplayMode,
@ -276,13 +212,71 @@ class HomeFragment : Fragment() {
}
}
private val DisplayMode.viewId: Int
get() =
when (this) {
DisplayMode.SHOW_SONGS -> R.id.home_song_list
DisplayMode.SHOW_ALBUMS -> R.id.home_album_list
DisplayMode.SHOW_ARTISTS -> R.id.home_artist_list
DisplayMode.SHOW_GENRES -> R.id.home_genre_list
private fun handleRecreateTabs(recreate: Boolean) {
if (recreate) {
requireBinding().homePager.recreate()
homeModel.finishRecreateTabs()
}
}
private fun handleLoaderResponse(response: MusicStore.Response?) {
val binding = requireBinding()
when (response) {
is MusicStore.Response.Ok -> binding.homeFab.show()
// While loading or during an error, make sure we keep the shuffle fab hidden so
// that any kind of playback is impossible. PlaybackStateManager also relies on this
// invariant, so please don't change it.
else -> binding.homeFab.hide()
}
}
private fun handleNavigation(item: Music?) {
// Note: You will want to add a post call to this if you want to re-introduce a collapsing
// toolbar.
when (item) {
is Song ->
findNavController().navigate(HomeFragmentDirections.actionShowAlbum(item.album.id))
is Album ->
findNavController().navigate(HomeFragmentDirections.actionShowAlbum(item.id))
is Artist ->
findNavController().navigate(HomeFragmentDirections.actionShowArtist(item.id))
is Genre ->
findNavController().navigate(HomeFragmentDirections.actionShowGenre(item.id))
else -> {}
}
}
/**
* By default, ViewPager2's sensitivity is high enough to result in vertical scroll events being
* registered as horizontal scroll events. Reflect into the internal recyclerview and change the
* touch slope so that touch actions will act more as a scroll than as a swipe. Derived from:
* https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
*/
private fun ViewPager2.reduceSensitivity(by: Int) {
try {
val recycler =
ViewPager2::class.java.getDeclaredField("mRecyclerView").run {
isAccessible = true
get(this@reduceSensitivity)
}
RecyclerView::class.java.getDeclaredField("mTouchSlop").apply {
isAccessible = true
val slop = get(recycler) as Int
set(recycler, slop * by)
}
} catch (e: Exception) {
logE("Unable to reduce ViewPager sensitivity (likely an internal code change)")
e.logTraceOrThrow()
}
}
/** Forces the view to recreate all fragments contained within it. */
private fun ViewPager2.recreate() {
currentItem = 0
adapter = HomePagerAdapter()
}
private inner class HomePagerAdapter :

View file

@ -63,8 +63,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
private val visibleTabs: List<DisplayMode>
get() = settingsManager.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
private val mCurTab = MutableLiveData(tabs[0])
val curTab: LiveData<DisplayMode> = mCurTab
private val mCurrentTab = MutableLiveData(tabs[0])
val currentTab: LiveData<DisplayMode> = mCurrentTab
/**
* Marker to recreate all library tabs, usually initiated by a settings change. When this flag
@ -91,7 +91,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
/** Update the current tab based off of the new ViewPager position. */
fun updateCurrentTab(pos: Int) {
logD("Updating current tab to ${tabs[pos]}")
mCurTab.value = tabs[pos]
mCurrentTab.value = tabs[pos]
}
fun finishRecreateTabs() {
@ -109,8 +109,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
/** Update the currently displayed item's [Sort]. */
fun updateCurrentSort(sort: Sort) {
logD("Updating ${mCurTab.value} sort to $sort")
when (mCurTab.value) {
logD("Updating ${mCurrentTab.value} sort to $sort")
when (mCurrentTab.value) {
DisplayMode.SHOW_SONGS -> {
settingsManager.libSongSort = sort
mSongs.value = sort.songs(mSongs.value!!)

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
@ -37,27 +36,15 @@ import org.oxycblt.auxio.ui.sliceArticle
* @author
*/
class AlbumListFragment : HomeListFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter =
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter =
AlbumAdapter(
doOnClick = { album ->
findNavController().navigate(HomeFragmentDirections.actionShowAlbum(album.id))
},
::newMenu)
setupRecycler(R.id.home_album_list, binding, adapter, homeModel.albums)
return binding.root
setupRecycler(R.id.home_album_list, homeAdapter, homeModel.albums)
}
override val listPopupProvider: (Int) -> String

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
@ -35,27 +34,15 @@ import org.oxycblt.auxio.ui.sliceArticle
* @author
*/
class ArtistListFragment : HomeListFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter =
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter =
ArtistAdapter(
doOnClick = { artist ->
findNavController().navigate(HomeFragmentDirections.actionShowArtist(artist.id))
},
::newMenu)
setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists)
return binding.root
setupRecycler(R.id.home_artist_list, homeAdapter, homeModel.artists)
}
override val listPopupProvider: (Int) -> String

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
@ -35,27 +34,15 @@ import org.oxycblt.auxio.ui.sliceArticle
* @author
*/
class GenreListFragment : HomeListFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter =
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter =
GenreAdapter(
doOnClick = { Genre ->
findNavController().navigate(HomeFragmentDirections.actionShowGenre(Genre.id))
},
::newMenu)
setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres)
return binding.root
setupRecycler(R.id.home_genre_list, homeAdapter, homeModel.genres)
}
override val listPopupProvider: (Int) -> String

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.home.list
import android.annotation.SuppressLint
import android.view.LayoutInflater
import androidx.annotation.IdRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@ -27,36 +28,43 @@ import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.applySpans
/**
* A Base [Fragment] implementing the base features shared across all list fragments in the home UI.
* @author OxygenCobalt
*/
abstract class HomeListFragment : Fragment() {
protected val homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
abstract class HomeListFragment : ViewBindingFragment<FragmentHomeListBinding>() {
/** The popup provider to use for the fast scroller view. */
abstract val listPopupProvider: (Int) -> String
protected val homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler(
@IdRes uniqueId: Int,
binding: FragmentHomeListBinding,
homeAdapter: HomeAdapter<T, VH>,
homeData: LiveData<List<T>>,
) {
binding.homeRecycler.apply {
requireBinding().homeRecycler.apply {
id = uniqueId
adapter = homeAdapter
setHasFixedSize(true)
applySpans()
popupProvider = listPopupProvider
onDragListener = { dragging -> homeModel.updateFastScrolling(dragging) }
onDragListener = homeModel::updateFastScrolling
}
homeData.observe(viewLifecycleOwner) { data -> homeAdapter.updateData(data) }
homeData.observe(viewLifecycleOwner, homeAdapter::updateData)
}
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
homeModel.updateFastScrolling(false)
}
abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> :

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.oxycblt.auxio.R
@ -35,22 +34,9 @@ import org.oxycblt.auxio.ui.sliceArticle
* @author
*/
class SongListFragment : HomeListFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter = SongsAdapter(doOnClick = { song -> playbackModel.playSong(song) }, ::newMenu)
setupRecycler(R.id.home_song_list, binding, adapter, homeModel.songs)
return binding.root
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter = SongsAdapter(doOnClick = playbackModel::playSong, ::newMenu)
setupRecycler(R.id.home_song_list, homeAdapter, homeModel.songs)
}
override val listPopupProvider: (Int) -> String

View file

@ -19,15 +19,13 @@ package org.oxycblt.auxio.home.tabs
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.LifecycleDialog
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD
/**
@ -35,17 +33,25 @@ import org.oxycblt.auxio.util.logD
* serializes it's state instead of
* @author OxygenCobalt
*/
class TabCustomizeDialog : LifecycleDialog() {
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>() {
private val settingsManager = SettingsManager.getInstance()
private var pendingTabs = settingsManager.libTabs
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DialogTabsBinding.inflate(inflater)
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.set_lib_tabs)
builder.setPositiveButton(android.R.string.ok) { _, _ ->
logD("Committing tab changes")
settingsManager.libTabs = pendingTabs
}
// Negative button just dismisses, no need for a listener.
builder.setNegativeButton(android.R.string.cancel, null)
}
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
if (savedInstanceState != null) {
// Restore any pending tab configurations
val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
@ -57,11 +63,22 @@ class TabCustomizeDialog : LifecycleDialog() {
// Set up adapter & drag callback
val callback = TabDragCallback { pendingTabs }
val helper = ItemTouchHelper(callback)
val tabAdapter =
TabAdapter(
helper,
getTabs = { pendingTabs },
onTabSwitch = { tab ->
val tabAdapter = TabAdapter(helper, getTabs = { pendingTabs }, onTabSwitch = ::moveTabs)
callback.addTabAdapter(tabAdapter)
binding.tabRecycler.apply {
adapter = tabAdapter
helper.attachToRecyclerView(this)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs))
}
private fun moveTabs(tab: Tab) {
// Don't find the specific tab [Which might be outdated due to the nature
// of how ViewHolders are bound], but instead simply look for the mode in
// the list of pending tabs and update that instead.
@ -76,35 +93,8 @@ class TabCustomizeDialog : LifecycleDialog() {
}
}
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)
.isEnabled = pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty()
})
callback.addTabAdapter(tabAdapter)
binding.tabRecycler.apply {
adapter = tabAdapter
helper.attachToRecyclerView(this)
}
return binding.root
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs))
}
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.set_lib_tabs)
builder.setPositiveButton(android.R.string.ok) { _, _ ->
logD("Committing tab changes")
settingsManager.libTabs = pendingTabs
}
// Negative button just dismisses, no need for a listener.
builder.setNegativeButton(android.R.string.cancel, null)
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty()
}
companion object {

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
@ -33,7 +31,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogExcludedBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.LifecycleDialog
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
@ -42,27 +40,29 @@ import org.oxycblt.auxio.util.showToast
* Dialog that manages the currently excluded directories.
* @author OxygenCobalt
*/
class ExcludedDialog : LifecycleDialog() {
class ExcludedDialog : ViewBindingDialogFragment<DialogExcludedBinding>() {
private val excludedModel: ExcludedViewModel by viewModels {
ExcludedViewModel.Factory(requireContext())
}
private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DialogExcludedBinding.inflate(inflater)
override fun onCreateBinding(inflater: LayoutInflater) = DialogExcludedBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.set_excluded)
// Don't set the click listener here, we do some custom black magic in onCreateView instead.
builder.setNeutralButton(R.string.lbl_add, null)
builder.setPositiveButton(R.string.lbl_save, null)
builder.setNegativeButton(android.R.string.cancel, null)
}
override fun onBindingCreated(binding: DialogExcludedBinding, savedInstanceState: Bundle?) {
val adapter = ExcludedEntryAdapter { path -> excludedModel.removePath(path) }
val launcher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
// --- UI SETUP ---
binding.excludedRecycler.adapter = adapter
// Now that the dialog exists, we get the view manually when the dialog is shown
@ -90,23 +90,14 @@ class ExcludedDialog : LifecycleDialog() {
// --- VIEWMODEL SETUP ---
excludedModel.paths.observe(viewLifecycleOwner) { paths ->
adapter.submitList(paths)
binding.excludedEmpty.isVisible = paths.isEmpty()
}
excludedModel.paths.observe(viewLifecycleOwner) { paths -> updatePaths(paths, adapter) }
logD("Dialog created")
return binding.root
}
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.set_excluded)
// Don't set the click listener here, we do some custom black magic in onCreateView instead.
builder.setNeutralButton(R.string.lbl_add, null)
builder.setPositiveButton(R.string.lbl_save, null)
builder.setNegativeButton(android.R.string.cancel, null)
private fun updatePaths(paths: MutableList<String>, adapter: ExcludedEntryAdapter) {
adapter.submitList(paths)
requireBinding().excludedEmpty.isVisible = paths.isEmpty()
}
private fun addDocTreePath(uri: Uri?) {
@ -147,17 +138,16 @@ class ExcludedDialog : LifecycleDialog() {
return null
}
private fun getRootPath(): String {
return Environment.getExternalStorageDirectory().absolutePath
}
private fun saveAndRestart() {
excludedModel.save {
playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() }
}
}
/** Get *just* the root path, nothing else is really needed. */
private fun getRootPath(): String {
return Environment.getExternalStorageDirectory().absolutePath
}
companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED"
}

View file

@ -20,11 +20,8 @@ package org.oxycblt.auxio.playback
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.color.MaterialColors
import org.oxycblt.auxio.R
@ -33,22 +30,21 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.bindSongInfo
import org.oxycblt.auxio.ui.BottomSheetLayout
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackBarFragment : Fragment() {
class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentPlaybackBarBinding.inflate(inflater)
override fun onBindingCreated(
binding: FragmentPlaybackBarBinding,
savedInstanceState: Bundle?
): View {
val binding = FragmentPlaybackBarBinding.inflate(inflater)
// -- UI SETUP ---
) {
binding.root.apply {
setOnClickListener {
// This is a dumb and fragile hack but this fragment isn't part of the navigation
@ -115,11 +111,9 @@ class PlaybackBarFragment : Fragment() {
binding.playbackPlayPause.isActivated = isPlaying
}
binding.playbackProgressBar.progress = playbackModel.seconds.value!!.toInt()
playbackModel.seconds.observe(viewLifecycleOwner) { position ->
binding.playbackProgressBar.progress = playbackModel.positionSeconds.value!!.toInt()
playbackModel.positionSeconds.observe(viewLifecycleOwner) { position ->
binding.playbackProgressBar.progress = position.toInt()
}
return binding.root
}
}

View file

@ -32,6 +32,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toDuration
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.ui.BottomSheetLayout
@ -55,24 +57,22 @@ class PlaybackPanelFragment :
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
override fun onCreateBinding(inflater: LayoutInflater): FragmentPlaybackPanelBinding {
return FragmentPlaybackPanelBinding.inflate(inflater)
}
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentPlaybackPanelBinding.inflate(inflater)
override fun onBindingCreated(
binding: FragmentPlaybackPanelBinding,
savedInstanceState: Bundle?
) {
val queueItem: MenuItem
// --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { _, insets ->
val bars = insets.systemBarInsetsCompat
binding.root.updatePadding(top = bars.top, bottom = bars.bottom)
insets
}
val queueItem: MenuItem
binding.playbackToolbar.apply {
setNavigationOnClickListener { navigateUp() }
@ -114,7 +114,6 @@ class PlaybackPanelFragment :
}
binding.playbackLoop.setOnClickListener { playbackModel.incrementLoopStatus() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.skipPrev() }
binding.playbackPlayPause.apply {
@ -124,69 +123,21 @@ class PlaybackPanelFragment :
}
binding.playbackSkipNext.setOnClickListener { playbackModel.skipNext() }
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffleStatus() }
// --- VIEWMODEL SETUP --
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (song != null) {
logD("Updating song display to ${song.rawName}")
binding.playbackCover.bindAlbumCover(song)
binding.playbackSong.text = song.resolvedName
binding.playbackArtist.text = song.resolvedArtistName
binding.playbackAlbum.text = song.resolvedAlbumName
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
playbackModel.parent.observe(viewLifecycleOwner, ::updateParent)
playbackModel.positionSeconds.observe(viewLifecycleOwner, ::updatePosition)
playbackModel.loopMode.observe(viewLifecycleOwner, ::updateLoop)
playbackModel.isPlaying.observe(viewLifecycleOwner, ::updatePlayPause)
playbackModel.isShuffling.observe(viewLifecycleOwner, ::updateShuffle)
// Normally if a song had a duration
val seconds = song.seconds
binding.playbackDuration.text = seconds.toDuration(false)
binding.playbackSeekBar.apply {
valueTo = max(seconds, 1L).toFloat()
isEnabled = seconds > 0L
}
}
}
playbackModel.parent.observe(viewLifecycleOwner) { parent ->
binding.playbackToolbar.subtitle =
parent?.resolvedName ?: getString(R.string.lbl_all_songs)
}
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
binding.playbackShuffle.isActivated = isShuffling
}
playbackModel.loopMode.observe(viewLifecycleOwner) { loopMode ->
val resId =
when (loopMode) {
LoopMode.NONE, null -> R.drawable.ic_loop
LoopMode.ALL -> R.drawable.ic_loop_on
LoopMode.TRACK -> R.drawable.ic_loop_one
}
binding.playbackLoop.apply {
isActivated = loopMode != LoopMode.NONE
setImageResource(resId)
}
}
playbackModel.seconds.observe(viewLifecycleOwner) { pos ->
// Don't update the progress while we are seeking, that will make the SeekBar jump
// around.
if (!binding.playbackSeconds.isActivated) {
binding.playbackSeekBar.value = pos.toFloat()
binding.playbackSeconds.text = pos.toDuration(true)
}
}
playbackModel.nextUp.observe(viewLifecycleOwner) {
playbackModel.nextUp.observe(viewLifecycleOwner) { nextUp ->
// The queue icon uses a selector that will automatically tint the icon as active or
// inactive. We just need to set the flag.
queueItem.isEnabled = playbackModel.nextUp.value!!.isNotEmpty()
}
playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying ->
binding.playbackPlayPause.isActivated = isPlaying
queueItem.isEnabled = nextUp.isNotEmpty()
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
@ -205,20 +156,68 @@ class PlaybackPanelFragment :
}
override fun onStartTrackingTouch(slider: Slider) {
requireBinding().playbackSeconds.isActivated = true
requireBinding().playbackPosition.isActivated = true
}
override fun onStopTrackingTouch(slider: Slider) {
requireBinding().playbackSeconds.isActivated = false
requireBinding().playbackPosition.isActivated = false
playbackModel.setPosition(slider.value.toLong())
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (fromUser) {
requireBinding().playbackSeconds.text = value.toLong().toDuration(true)
requireBinding().playbackPosition.text = value.toLong().toDuration(true)
}
}
private fun updateSong(song: Song?) {
if (song == null) return
val binding = requireBinding()
binding.playbackCover.bindAlbumCover(song)
binding.playbackSong.text = song.resolvedName
binding.playbackArtist.text = song.resolvedArtistName
binding.playbackAlbum.text = song.resolvedAlbumName
// Normally if a song had a duration
val seconds = song.seconds
binding.playbackDuration.text = seconds.toDuration(false)
binding.playbackSeekBar.apply {
valueTo = max(seconds, 1L).toFloat()
isEnabled = seconds > 0L
}
}
private fun updateParent(parent: MusicParent?) {
requireBinding().playbackToolbar.subtitle =
parent?.resolvedName ?: getString(R.string.lbl_all_songs)
}
private fun updatePosition(position: Long) {
// Don't update the progress while we are seeking, that will make the SeekBar jump
// around.
val binding = requireBinding()
if (!binding.playbackPosition.isActivated) {
binding.playbackSeekBar.value = position.toFloat()
binding.playbackPosition.text = position.toDuration(true)
}
}
private fun updateLoop(loopMode: LoopMode) {
requireBinding().playbackLoop.apply {
isActivated = loopMode != LoopMode.NONE
setImageResource(loopMode.icon)
}
}
private fun updatePlayPause(isPlaying: Boolean) {
requireBinding().playbackPlayPause.isActivated = isPlaying
}
private fun updateShuffle(isShuffling: Boolean) {
requireBinding().playbackShuffle.isActivated = isShuffling
}
private fun navigateUp() {
// This is a dumb and fragile hack but this fragment isn't part of the navigation stack
// so we can't really do much

View file

@ -58,7 +58,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
private val mIsPlaying = MutableLiveData(false)
private val mIsShuffling = MutableLiveData(false)
private val mLoopMode = MutableLiveData(LoopMode.NONE)
private val mSeconds = MutableLiveData(0L)
private val mPositionSeconds = MutableLiveData(0L)
// Queue
private val mNextUp = MutableLiveData(listOf<Song>())
@ -82,8 +82,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
val loopMode: LiveData<LoopMode>
get() = mLoopMode
/** The current playback position, in seconds */
val seconds: LiveData<Long>
get() = mSeconds
val positionSeconds: LiveData<Long>
get() = mPositionSeconds
/** The queue, without the previous items. */
val nextUp: LiveData<List<Song>>
@ -336,7 +336,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
}
override fun onPositionUpdate(position: Long) {
mSeconds.value = position / 1000
mPositionSeconds.value = position / 1000
}
override fun onQueueUpdate(queue: List<Song>, index: Int) {

View file

@ -19,40 +19,32 @@ package org.oxycblt.auxio.playback.queue
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD
/**
* A [Fragment] that shows the queue and enables editing as well.
* @author OxygenCobalt
*/
class QueueFragment : Fragment() {
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private var lastShuffle: Boolean? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentQueueBinding.inflate(inflater)
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) {
// TODO: Merge ItemTouchHelper with QueueAdapter
val callback = QueueDragCallback(playbackModel)
val helper = ItemTouchHelper(callback)
val queueAdapter = QueueAdapter(helper)
callback.addQueueAdapter(queueAdapter)
var lastShuffle = playbackModel.isShuffling.value
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.queueRecycler.apply {
@ -63,15 +55,7 @@ class QueueFragment : Fragment() {
// --- VIEWMODEL SETUP ----
playbackModel.nextUp.observe(viewLifecycleOwner) { queue ->
if (queue.isEmpty()) {
findNavController().navigateUp()
return@observe
}
queueAdapter.submitList(queue.toMutableList())
}
lastShuffle = playbackModel.isShuffling.value
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
// Try to prevent the queue adapter from going spastic during reshuffle events
// by just scrolling back to the top.
@ -82,6 +66,13 @@ class QueueFragment : Fragment() {
}
}
return binding.root
playbackModel.nextUp.observe(viewLifecycleOwner) { queue ->
if (queue.isEmpty()) {
findNavController().navigateUp()
return@observe
}
queueAdapter.submitList(queue.toMutableList())
}
}
}

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.playback.state
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
/**
* Enum that determines the playback repeat mode.
@ -37,6 +38,14 @@ enum class LoopMode {
}
}
val icon: Int
get() =
when (this) {
NONE -> R.drawable.ic_loop
ALL -> R.drawable.ic_loop_on
TRACK -> R.drawable.ic_loop_one
}
/**
* Convert the LoopMode to an int constant that is saved in PlaybackStateDatabase
* @return The int constant for this mode

View file

@ -72,6 +72,9 @@ import org.oxycblt.auxio.widgets.WidgetProvider
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], so
* therefore there's no need to bind to it to deliver commands.
* @author OxygenCobalt
*
* TODO: Move all external exposal from passing around PlaybackStateManager to passing around the
* MediaMetadata instance. Generally makes it easier to encapsulate this class.
*/
class PlaybackService :
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
@ -199,6 +202,8 @@ class PlaybackService :
// The service coroutines last job is to save the state to the DB, before terminating itself
// FIXME: This is a terrible idea, move this to when the user closes the notification
// FIXME: Why not also encourage the user to disable battery optimizations while were
// at it? Would help prevent state saving issues to an extent.
serviceScope.launch {
playbackManager.saveStateToDatabase(this@PlaybackService)
serviceJob.cancel()

View file

@ -19,9 +19,8 @@ package org.oxycblt.auxio.search
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.core.view.isInvisible
import androidx.core.view.postDelayed
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
@ -35,10 +34,11 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.getSystemServiceSafe
@ -48,41 +48,26 @@ import org.oxycblt.auxio.util.logD
* A [Fragment] that allows for the searching of the entire music library.
* @author OxygenCobalt
*/
class SearchFragment : Fragment() {
class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
// SearchViewModel is only scoped to this Fragment
private val searchModel: SearchViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private var launchedKeyboard = false
private var mustScrollUp = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentSearchBinding.inflate(inflater)
override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) {
val imm = requireContext().getSystemServiceSafe(InputMethodManager::class)
val searchAdapter =
SearchAdapter(doOnClick = { item -> onItemSelection(item, imm) }, ::newMenu)
// --- UI SETUP --
binding.lifecycleOwner = viewLifecycleOwner
binding.searchToolbar.apply {
val itemId =
when (searchModel.filterMode) {
DisplayMode.SHOW_SONGS -> R.id.option_filter_songs
DisplayMode.SHOW_ALBUMS -> R.id.option_filter_albums
DisplayMode.SHOW_ARTISTS -> R.id.option_filter_artists
DisplayMode.SHOW_GENRES -> R.id.option_filter_genres
null -> R.id.option_filter_all
}
menu.findItem(itemId).isChecked = true
menu.findItem(searchModel.filterMode?.itemId ?: R.id.option_filter_all).isChecked = true
setNavigationOnClickListener {
imm.hide()
@ -102,7 +87,6 @@ class SearchFragment : Fragment() {
binding.searchEditText.apply {
addTextChangedListener { text ->
mustScrollUp = true
// Run the search with the updated text as the query
searchModel.search(text?.toString() ?: "")
}
@ -118,49 +102,48 @@ class SearchFragment : Fragment() {
binding.searchRecycler.apply {
adapter = searchAdapter
applySpans { pos -> searchAdapter.currentList[pos] is Header }
}
// --- VIEWMODEL SETUP ---
searchModel.searchResults.observe(viewLifecycleOwner) { results ->
updateResults(results, searchAdapter)
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
handleNavigation(item)
imm.hide()
}
}
override fun onResume() {
super.onResume()
searchModel.setNavigating(false)
}
private fun updateResults(results: List<Item>, searchAdapter: SearchAdapter) {
val binding = requireBinding()
searchAdapter.submitList(results) {
// I would make it so that the position is only scrolled back to the top when
// the query actually changes instead of one every re-creation event, but sadly
// the query actually changes instead of once every re-creation event, but sadly
// that doesn't seem possible.
binding.searchRecycler.scrollToPosition(0)
}
if (results.isEmpty()) {
binding.searchRecycler.visibility = View.INVISIBLE
} else {
binding.searchRecycler.visibility = View.VISIBLE
}
binding.searchRecycler.isInvisible = results.isEmpty()
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
private fun handleNavigation(item: Music?) {
findNavController()
.navigate(
when (item) {
is Song -> SearchFragmentDirections.actionShowAlbum(item.album.id)
is Album -> SearchFragmentDirections.actionShowAlbum(item.id)
is Artist -> SearchFragmentDirections.actionShowArtist(item.id)
else -> return@observe
else -> return
})
imm.hide()
}
logD("Fragment created")
return binding.root
}
override fun onResume() {
super.onResume()
searchModel.setNavigating(false)
}
private fun InputMethodManager.hide() {

View file

@ -23,11 +23,8 @@ import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.net.toUri
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@ -35,6 +32,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAboutBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -43,18 +41,14 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* A [BottomSheetDialogFragment] that shows Auxio's about screen.
* @author OxygenCobalt
*/
class AboutFragment : Fragment() {
class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
private val homeModel: HomeViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentAboutBinding.inflate(layoutInflater)
override fun onCreateBinding(inflater: LayoutInflater) = FragmentAboutBinding.inflate(inflater)
binding.aboutContents.setOnApplyWindowInsetsListener { _, insets ->
binding.aboutContents.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
override fun onBindingCreated(binding: FragmentAboutBinding, savedInstanceState: Bundle?) {
binding.aboutContents.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
insets
}
@ -68,10 +62,6 @@ class AboutFragment : Fragment() {
homeModel.songs.observe(viewLifecycleOwner) { songs ->
binding.aboutSongCount.text = getString(R.string.fmt_songs_loaded, songs.size)
}
logD("Dialog created")
return binding.root
}
/** Go through the process of opening a [link] in a browser. */
@ -100,8 +90,7 @@ class AboutFragment : Fragment() {
requireContext()
.packageManager
.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo
?.packageName
?.run { activityInfo.packageName }
if (pkgName != null) {
if (pkgName == "android") {

View file

@ -19,30 +19,21 @@ package org.oxycblt.auxio.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.databinding.FragmentSettingsBinding
import org.oxycblt.auxio.ui.ViewBindingFragment
/**
* A container [Fragment] for the settings menu.
* @author OxygenCobalt
*/
class SettingsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentSettingsBinding.inflate(inflater)
binding.settingsToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
}
class SettingsFragment : ViewBindingFragment<FragmentSettingsBinding>() {
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentSettingsBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentSettingsBinding, savedInstanceState: Bundle?) {
binding.settingsToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view
return binding.root
}
}

View file

@ -57,12 +57,12 @@ class SettingsListFragment : PreferenceFragmentCompat() {
preferenceManager.onDisplayPreferenceDialogListener = this
preferenceScreen.children.forEach(::recursivelyHandlePreference)
// Make the RecycleBiew edge-to-edge capable
view.findViewById<RecyclerView>(androidx.preference.R.id.recycler_view).apply {
clipToPadding = false
setOnApplyWindowInsetsListener { _, insets ->
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
insets
}
}

View file

@ -17,15 +17,18 @@
package org.oxycblt.auxio.settings.pref
import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ui.LifecycleDialog
/** The dialog shown whenever an [IntListPreference] is shown. */
class IntListPrefDialog : LifecycleDialog() {
override fun onConfigDialog(builder: AlertDialog.Builder) {
class IntListPrefDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireActivity(), theme)
// Since we have to store the preference key as an argument, we have to find the
// preference we need to use manually.
val pref =
@ -41,6 +44,8 @@ class IntListPrefDialog : LifecycleDialog() {
}
builder.setNegativeButton(android.R.string.cancel, null)
return builder.create()
}
companion object {

View file

@ -380,6 +380,11 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// We kind of do a reverse-measure to figure out how we should inset this view.
// Find how much space is lost by the panel and then combine that with the
// bottom inset to find how much space we should apply.
// There is a slight shortcoming to this. If the playback bar has a height of
// zero (usually due to delays with fragment inflation), then it is assumed to
// not apply any window insets at all, which results in scroll desynchronization on
// certain views. This is considered tolerable as the other options are to convert
// the playback fragments to views, which is not nice.
val bars = insets.systemBarInsetsCompat
val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)

View file

@ -50,6 +50,15 @@ enum class DisplayMode {
SHOW_GENRES -> R.drawable.ic_genre
}
val itemId: Int
get() =
when (this) {
SHOW_SONGS -> R.drawable.ic_song
SHOW_ALBUMS -> R.drawable.ic_album
SHOW_ARTISTS -> R.drawable.ic_artist
SHOW_GENRES -> R.drawable.ic_genre
}
val intCode: Int
get() =
when (this) {

View file

@ -1,46 +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.ui
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
* A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle override
* [onCreateView] and [onDestroyView], but with a proper dialog being created.
*/
abstract class LifecycleDialog : AppCompatDialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireActivity(), theme)
onConfigDialog(builder)
return builder.create()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(requireDialog() as AlertDialog).setView(view)
}
protected open fun onConfigDialog(builder: AlertDialog.Builder) {}
}

View file

@ -98,7 +98,7 @@ sealed class Sort(open val isAscending: Boolean) {
}
override fun ascending(newIsAscending: Boolean): Sort {
return ByName(isAscending)
return ByName(newIsAscending)
}
}
@ -239,21 +239,17 @@ sealed class Sort(open val isAscending: Boolean) {
}
class NameComparator<T : Music> : Comparator<T> {
override fun compare(a: T?, b: T?): Int {
if (a == null && b != null) return -1 // -1 -> a < b
if (a == null && b == null) return 0 // 0 -> 0 = b
if (a != null && b == null) return 1 // 1 -> a > b
return a!!.resolvedName
override fun compare(a: T, b: T): Int {
return a.resolvedName
.sliceArticle()
.compareTo(b!!.resolvedName.sliceArticle(), ignoreCase = true)
.compareTo(b.resolvedName.sliceArticle(), ignoreCase = true)
}
}
class NullableComparator<T : Comparable<T>> : Comparator<T?> {
override fun compare(a: T?, b: T?): Int {
if (a == null && b != null) return -1 // -1 -> a < b
if (a == null && b == null) return 0 // 0 -> 0 = b
if (a == null && b == null) return 0 // 0 -> a = b
if (a != null && b == null) return 1 // 1 -> a > b
return a!!.compareTo(b!!)
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2022 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.ui
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.oxycblt.auxio.util.logD
abstract class ViewBindingDialogFragment<T : ViewBinding> : DialogFragment() {
private var mBinding: T? = null
protected abstract fun onCreateBinding(inflater: LayoutInflater): T
protected open fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {}
protected open fun onDestroyBinding(binding: T) {}
protected open fun onConfigDialog(builder: AlertDialog.Builder) {}
protected val binding: T?
get() = mBinding
protected fun requireBinding(): T {
return requireNotNull(mBinding) {
"ViewBinding was not available, as the fragment was not in a valid state"
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = onCreateBinding(inflater).also { mBinding = it }.root
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(requireActivity(), theme).run {
onConfigDialog(this)
create()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onBindingCreated(requireBinding(), savedInstanceState)
(requireDialog() as AlertDialog).setView(view)
logD("Fragment created")
}
override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(requireBinding())
mBinding = null
}
}

View file

@ -23,23 +23,35 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.util.logD
/** A fragment enabling ViewBinding inflation and usage across the fragment lifecycle. */
abstract class ViewBindingFragment<T : ViewBinding> : Fragment() {
private var mBinding: T? = null
abstract fun onCreateBinding(inflater: LayoutInflater): T
abstract fun onBindingCreated(binding: T, savedInstanceState: Bundle?)
abstract fun onDestroyBinding(binding: T)
protected abstract fun onCreateBinding(inflater: LayoutInflater): T
protected open fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {}
protected open fun onDestroyBinding(binding: T) {}
protected val binding: T?
get() = mBinding
protected fun requireBinding(): T {
return requireNotNull(mBinding) {
"ViewBinding was not available, as the fragment was not in a valid state"
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding = onCreateBinding(inflater).also { mBinding = it }
onBindingCreated(binding, savedInstanceState)
return binding.root
): View = onCreateBinding(inflater).also { mBinding = it }.root
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onBindingCreated(requireBinding(), savedInstanceState)
logD("Fragment created")
}
override fun onDestroyView() {
@ -47,13 +59,4 @@ abstract class ViewBindingFragment<T : ViewBinding> : Fragment() {
onDestroyBinding(requireBinding())
mBinding = null
}
protected val binding: T?
get() = mBinding
protected fun requireBinding(): T {
return requireNotNull(mBinding) {
"ViewBinding was not available, as the fragment is not in a valid state"
}
}
}

View file

@ -22,7 +22,6 @@ import android.graphics.Insets
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.util.Log
import android.view.View
import android.view.WindowInsets
import androidx.annotation.ColorRes
@ -93,9 +92,6 @@ private fun isUnderImpl(
if (viewSize >= minTouchTargetSize) {
return position >= viewStart && position < viewEnd
}
Log.d("Auxio.ViewUtil", "isInTouchTarget: $minTouchTargetSize")
var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2
if (touchTargetStart < 0) {

View file

@ -98,14 +98,14 @@
app:thumbRadius="@dimen/slider_thumb_radius" />
<TextView
android:id="@+id/playback_seconds"
android:id="@+id/playback_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginTop="@dimen/spacing_small_inv"
android:layout_marginStart="@dimen/spacing_medium"
android:textAppearance="@style/TextAppearance.Auxio.BodyMedium"
android:textColor="@color/sel_accented_secondary"
app:layout_constraintStart_toStartOf="@+id/playback_seek_bar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playback_seek_bar"
tools:text="11:38" />
@ -117,7 +117,7 @@
android:layout_marginEnd="@dimen/spacing_medium"
android:textAppearance="@style/TextAppearance.Auxio.BodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playback_seek_bar"
tools:text="16:16" />
@ -125,12 +125,12 @@
android:id="@+id/playback_loop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:contentDescription="@string/desc_change_loop"
android:src="@drawable/ic_loop"
app:hasIndicator="true"
android:layout_marginStart="@dimen/spacing_medium"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev"
app:layout_constraintStart_toStartOf="@+id/playback_seek_bar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" />
<org.oxycblt.auxio.playback.PlaybackButton
@ -138,7 +138,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/desc_skip_prev"
android:onClick="@{() -> playbackModel.skipPrev()}"
android:src="@drawable/ic_skip_prev"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
@ -173,12 +172,12 @@
android:id="@+id/playback_shuffle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium"
android:contentDescription="@string/desc_shuffle"
android:src="@drawable/ic_shuffle"
android:layout_marginEnd="@dimen/spacing_medium"
app:hasIndicator="true"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_next"
app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/playback_skip_next"
app:tint="@color/sel_accented" />

View file

@ -4,22 +4,6 @@
xmlns:tools="http://schemas.android.com/tools"
tools:context=".playback.PlaybackPanelFragment">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
<variable
name="playbackModel"
type="org.oxycblt.auxio.playback.PlaybackViewModel" />
<variable
name="detailModel"
type="org.oxycblt.auxio.detail.DetailViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playback_layout"
android:layout_width="match_parent"
@ -112,7 +96,7 @@
app:thumbRadius="@dimen/slider_thumb_radius" />
<TextView
android:id="@+id/playback_seconds"
android:id="@+id/playback_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"

View file

@ -84,7 +84,7 @@
app:thumbRadius="@dimen/slider_thumb_radius" />
<TextView
android:id="@+id/playback_seconds"
android:id="@+id/playback_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"

View file

@ -4,21 +4,6 @@
xmlns:tools="http://schemas.android.com/tools"
tools:context=".playback.PlaybackPanelFragment">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
<variable
name="playbackModel"
type="org.oxycblt.auxio.playback.PlaybackViewModel" />
<variable
name="detailModel"
type="org.oxycblt.auxio.detail.DetailViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playback_layout"
@ -38,8 +23,6 @@
android:id="@+id/playback_cover"
style="@style/Widget.Auxio.Image.Full"
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@{@string/desc_album_cover(song.resolvedName)}"
app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playback_toolbar"
@ -65,8 +48,6 @@
style="@style/Widget.Auxio.TextView.Primary"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song)}"
android:text="@{song.resolvedName}"
tools:text="Song Name" />
</FrameLayout>
@ -78,8 +59,6 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}"
android:text="@{song.resolvedArtistName}"
app:layout_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
@ -94,8 +73,6 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album)}"
android:text="@{song.resolvedAlbumName}"
app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
@ -121,7 +98,7 @@
app:thumbRadius="@dimen/slider_thumb_radius" />
<TextView
android:id="@+id/playback_seconds"
android:id="@+id/playback_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
@ -151,7 +128,6 @@
android:layout_marginStart="@dimen/spacing_medium"
android:contentDescription="@string/desc_change_loop"
app:hasIndicator="true"
android:onClick="@{() -> playbackModel.incrementLoopStatus()}"
android:src="@drawable/ic_loop"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev"
app:layout_constraintHorizontal_chainStyle="packed"
@ -163,7 +139,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/desc_skip_prev"
android:onClick="@{() -> playbackModel.skipPrev()}"
android:src="@drawable/ic_skip_prev"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
@ -176,7 +151,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/desc_play_pause"
android:onClick="@{() -> playbackModel.invertPlayingStatus()}"
android:src="@drawable/sel_playing_state"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar"
@ -190,7 +164,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/desc_skip_next"
android:onClick="@{() -> playbackModel.skipNext()}"
android:src="@drawable/ic_skip_next"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_shuffle"
@ -204,7 +177,6 @@
android:layout_marginEnd="@dimen/spacing_medium"
android:contentDescription="@string/desc_shuffle"
app:hasIndicator="true"
android:onClick="@{() -> playbackModel.invertShuffleStatus()}"
android:src="@drawable/ic_shuffle"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_next"
app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar"

View file

@ -82,7 +82,7 @@
app:thumbRadius="@dimen/slider_thumb_radius" />
<TextView
android:id="@+id/playback_seconds"
android:id="@+id/playback_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
@ -97,11 +97,11 @@
android:id="@+id/playback_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium"
android:layout_marginTop="@dimen/spacing_small_inv"
android:layout_marginEnd="@dimen/spacing_medium"
android:textAppearance="@style/TextAppearance.Auxio.BodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playback_seek_bar"
tools:text="16:16" />
@ -109,12 +109,12 @@
android:id="@+id/playback_loop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:contentDescription="@string/desc_change_loop"
android:src="@drawable/ic_loop"
app:hasIndicator="true"
android:layout_marginStart="@dimen/spacing_medium"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev"
app:layout_constraintStart_toStartOf="@+id/playback_seek_bar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" />
<org.oxycblt.auxio.playback.PlaybackButton
@ -137,17 +137,17 @@
android:contentDescription="@string/desc_play_pause"
android:src="@drawable/sel_playing_state"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar"
app:layout_constraintStart_toStartOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:src="@drawable/ic_play" />
<org.oxycblt.auxio.playback.PlaybackButton
android:id="@+id/playback_skip_next"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="@dimen/size_btn_small"
android:minWidth="@dimen/size_btn_small"
android:contentDescription="@string/desc_skip_next"
android:minWidth="@dimen/size_btn_small"
android:minHeight="@dimen/size_btn_small"
android:src="@drawable/ic_skip_next"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_shuffle"
@ -158,14 +158,14 @@
android:id="@+id/playback_shuffle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="@dimen/size_btn_small"
android:minWidth="@dimen/size_btn_small"
android:layout_marginEnd="@dimen/spacing_medium"
android:contentDescription="@string/desc_shuffle"
android:minWidth="@dimen/size_btn_small"
android:minHeight="@dimen/size_btn_small"
android:src="@drawable/ic_shuffle"
android:layout_marginEnd="@dimen/spacing_medium"
app:hasIndicator="true"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_next"
app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/playback_skip_next"
app:tint="@color/sel_accented" />

View file

@ -26,7 +26,7 @@
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/playback_seconds"
android:id="@+id/playback_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"

View file

@ -39,7 +39,7 @@
<dimen name="fast_scroll_thumb_touch_target_size">16dp</dimen>
<dimen name="slider_thumb_radius">6dp</dimen>
<dimen name="slider_halo_radius">12dp</dimen>
<dimen name="slider_halo_radius">16dp</dimen>
<dimen name="recycler_fab_space_normal">88dp</dimen>
<dimen name="recycler_fab_space_large">128dp</dimen>

View file

@ -43,6 +43,7 @@
<style name="Widget.Auxio.TextView.Primary.AppWidget" parent="Widget.Auxio.TextView.AppWidget">
<item name="android:textStyle">normal</item>
<item name="android:textSize">@dimen/text_size_ext_title_mid_large</item>
<item name="android:letterSpacing">0</item>
<item name="android:textAppearance">@style/TextAppearance.Material3.TitleMedium</item>
</style>