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

View file

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

View file

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

View file

@ -19,54 +19,23 @@ package org.oxycblt.auxio.accent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.databinding.DialogAccentBinding
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.LifecycleDialog import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* Dialog responsible for showing the list of accents to select. * Dialog responsible for showing the list of accents to select.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class AccentCustomizeDialog : LifecycleDialog() { class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>() {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private var pendingAccent = settingsManager.accent private var pendingAccent = settingsManager.accent
override fun onCreateView( override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
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 onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.set_accent) builder.setTitle(R.string.set_accent)
@ -85,6 +54,25 @@ class AccentCustomizeDialog : LifecycleDialog() {
builder.setNegativeButton(android.R.string.cancel, null) 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 { companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.ACCENT_PICKER" const val TAG = BuildConfig.APPLICATION_ID + ".tag.ACCENT_PICKER"
const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT" 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 * 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? { protected suspend fun fetchArt(context: Context, album: Album): InputStream? {
if (!settingsManager.showCovers) { 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? { private suspend fun fetchQualityCovers(context: Context, album: Album): InputStream? {
// Loading quality covers basically means to parse the file metadata ourselves // Loading quality covers basically means to parse the file metadata ourselves
// and then extract the cover. // and then extract the cover.
@ -207,6 +199,14 @@ abstract class BaseFetcher : Fetcher {
return stream 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 * Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph * https://github.com/kabouzeid/Phonograph

View file

@ -39,14 +39,14 @@ class SquareFrameTransform : Transformation {
val x = (input.width - dstSize) / 2 val x = (input.width - dstSize) / 2
val y = (input.height - dstSize) / 2 val y = (input.height - dstSize) / 2
val wantedWidth = size.width.pxOrElse { dstSize } val desiredWidth = size.width.pxOrElse { dstSize }
val wantedHeight = size.height.pxOrElse { dstSize } val desiredHeight = size.height.pxOrElse { dstSize }
val dst = Bitmap.createBitmap(input, x, y, dstSize, 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. // 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 return dst

View file

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

View file

@ -18,9 +18,6 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle 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.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Header 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.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu import org.oxycblt.auxio.ui.ActionMenu
@ -44,21 +43,15 @@ import org.oxycblt.auxio.util.logW
class ArtistDetailFragment : DetailFragment() { class ArtistDetailFragment : DetailFragment() {
private val args: ArtistDetailFragmentArgs by navArgs() private val args: ArtistDetailFragmentArgs by navArgs()
override fun onCreateView( override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
inflater: LayoutInflater, detailModel.setArtistId(args.artistId)
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
detailModel.setArtist(args.artistId)
val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = val detailAdapter =
ArtistDetailAdapter( ArtistDetailAdapter(
playbackModel, playbackModel,
doOnClick = { data -> doOnClick = { data ->
if (!detailModel.isNavigating) { if (!detailModel.isNavigating) {
detailModel.setNavigating(true) detailModel.setNavigating(true)
findNavController() findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id)) .navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id))
} }
@ -66,12 +59,8 @@ class ArtistDetailFragment : DetailFragment() {
doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) }, doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) }) doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) })
// --- UI SETUP --- setupToolbar(detailModel.currentArtist.value!!)
setupRecycler(detailAdapter) { pos ->
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curArtist.value!!, binding)
setupRecycler(binding, detailAdapter) { pos ->
// If the item is an ActionHeader we need to also make the item full-width // If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos] val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Artist item is Header || item is ActionHeader || item is Artist
@ -79,9 +68,7 @@ class ArtistDetailFragment : DetailFragment() {
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
detailModel.artistData.observe(viewLifecycleOwner) { data -> detailModel.artistData.observe(viewLifecycleOwner, detailAdapter::submitList)
detailAdapter.submitList(data)
}
detailModel.showMenu.observe(viewLifecycleOwner) { config -> detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) { 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) { when (item) {
is Artist -> { is Artist -> {
if (item.id == detailModel.curArtist.value?.id) { if (item.id == detailModel.currentArtist.value?.id) {
logD("Navigating to the top of this artist") logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0) binding.detailRecycler.scrollToPosition(0)
detailModel.finishNavToItem() detailModel.finishNavToItem()
@ -117,28 +117,23 @@ class ArtistDetailFragment : DetailFragment() {
} }
} }
// Highlight albums if they are being played private fun updateSong(song: Song?, adapter: ArtistDetailAdapter) {
playbackModel.parent.observe(viewLifecycleOwner) { parent -> val binding = requireBinding()
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 ->
if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST && if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST &&
playbackModel.parent.value?.id == detailModel.curArtist.value?.id) { playbackModel.parent.value?.id == detailModel.currentArtist.value?.id) {
detailAdapter.highlightSong(song, binding.detailRecycler) adapter.highlightSong(song, binding.detailRecycler)
} else { } else {
// Clear the ViewHolders if the mode isn't ALL_SONGS // Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler) adapter.highlightSong(null, binding.detailRecycler)
} }
} }
logD("Fragment created") private fun updateParent(parent: MusicParent?, adapter: ArtistDetailAdapter) {
val binding = requireBinding()
return binding.root 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 package org.oxycblt.auxio.detail
import android.view.LayoutInflater
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.view.children import androidx.core.view.children
@ -28,6 +29,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD 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. * A Base [Fragment] implementing the base features shared across all detail fragments.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class DetailFragment : Fragment() { abstract class DetailFragment : ViewBindingFragment<FragmentDetailBinding>() {
protected val detailModel: DetailViewModel by activityViewModels() protected val detailModel: DetailViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels() protected val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateBinding(inflater: LayoutInflater): FragmentDetailBinding =
FragmentDetailBinding.inflate(inflater)
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
detailModel.setNavigating(false) detailModel.setNavigating(false)
@ -58,11 +63,10 @@ abstract class DetailFragment : Fragment() {
*/ */
protected fun setupToolbar( protected fun setupToolbar(
data: MusicParent, data: MusicParent,
binding: FragmentDetailBinding,
@MenuRes menuId: Int = -1, @MenuRes menuId: Int = -1,
onMenuClick: ((itemId: Int) -> Boolean)? = null onMenuClick: ((itemId: Int) -> Boolean)? = null
) { ) {
binding.detailToolbar.apply { requireBinding().detailToolbar.apply {
title = data.resolvedName title = data.resolvedName
if (menuId != -1) { if (menuId != -1) {
@ -79,11 +83,10 @@ abstract class DetailFragment : Fragment() {
/** Shortcut method for recyclerview setup */ /** Shortcut method for recyclerview setup */
protected fun setupRecycler( protected fun setupRecycler(
binding: FragmentDetailBinding,
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>, detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
gridLookup: (Int) -> Boolean gridLookup: (Int) -> Boolean
) { ) {
binding.detailRecycler.apply { requireBinding().detailRecycler.apply {
adapter = detailAdapter adapter = detailAdapter
setHasFixedSize(true) setHasFixedSize(true)
applySpans(gridLookup) 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.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
@ -43,39 +44,37 @@ import org.oxycblt.auxio.util.logD
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class DetailViewModel : ViewModel() { class DetailViewModel : ViewModel() {
// --- CURRENT VALUES --- private val mCurrentAlbum = MutableLiveData<Album?>()
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?>()
val curAlbum: LiveData<Album?> val curAlbum: LiveData<Album?>
get() = mCurAlbum get() = mCurrentAlbum
private val mAlbumData = MutableLiveData(listOf<Item>()) private val mAlbumData = MutableLiveData(listOf<Item>())
val albumData: LiveData<List<Item>> val albumData: LiveData<List<Item>>
get() = mAlbumData 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) data class MenuConfig(val anchor: View, val sortMode: Sort)
private val mShowMenu = MutableLiveData<MenuConfig?>(null) private val mShowMenu = MutableLiveData<MenuConfig?>(null)
val showMenu: LiveData<MenuConfig?> = mShowMenu 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. */ /** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */
val navToItem: LiveData<Item?> val navToItem: LiveData<Music?>
get() = mNavToItem get() = mNavToItem
var isNavigating = false var isNavigating = false
@ -84,25 +83,25 @@ class DetailViewModel : ViewModel() {
private var currentMenuContext: DisplayMode? = null private var currentMenuContext: DisplayMode? = null
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
fun setGenre(id: Long) { fun setAlbumId(id: Long) {
if (mCurGenre.value?.id == id) return if (mCurrentAlbum.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurGenre.value = musicStore.genres.find { it.id == id } mCurrentAlbum.value = musicStore.albums.find { it.id == id }
refreshGenreData() refreshAlbumData()
} }
fun setArtist(id: Long) { fun setArtistId(id: Long) {
if (mCurArtist.value?.id == id) return if (mCurrentArtist.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurArtist.value = musicStore.artists.find { it.id == id } mCurrentArtist.value = musicStore.artists.find { it.id == id }
refreshArtistData() refreshArtistData()
} }
fun setAlbum(id: Long) { fun setGenreId(id: Long) {
if (mCurAlbum.value?.id == id) return if (mCurrentGenre.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurAlbum.value = musicStore.albums.find { it.id == id } mCurrentGenre.value = musicStore.genres.find { it.id == id }
refreshAlbumData() refreshGenreData()
} }
/** Mark that the menu process is done with the new [Sort]. Pass null if there was no change. */ /** 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 */ /** Navigate to an item, whether a song/album/artist */
fun navToItem(item: Item) { fun navToItem(item: Music) {
mNavToItem.value = item mNavToItem.value = item
} }
@ -148,7 +147,7 @@ class DetailViewModel : ViewModel() {
private fun refreshGenreData() { private fun refreshGenreData() {
logD("Refreshing genre data") logD("Refreshing genre data")
val genre = requireNotNull(curGenre.value) val genre = requireNotNull(currentGenre.value)
val data = mutableListOf<Item>(genre) val data = mutableListOf<Item>(genre)
data.add( data.add(
@ -162,14 +161,14 @@ class DetailViewModel : ViewModel() {
mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort) mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort)
})) }))
data.addAll(settingsManager.detailGenreSort.genre(curGenre.value!!)) data.addAll(settingsManager.detailGenreSort.genre(currentGenre.value!!))
mGenreData.value = data mGenreData.value = data
} }
private fun refreshArtistData() { private fun refreshArtistData() {
logD("Refreshing artist data") logD("Refreshing artist data")
val artist = requireNotNull(curArtist.value) val artist = requireNotNull(currentArtist.value)
val data = mutableListOf<Item>(artist) val data = mutableListOf<Item>(artist)
data.add(Header(id = -2, string = R.string.lbl_albums)) data.add(Header(id = -2, string = R.string.lbl_albums))

View file

@ -18,9 +18,6 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle 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.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.FragmentDetailBinding 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.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu import org.oxycblt.auxio.ui.ActionMenu
@ -44,35 +42,37 @@ import org.oxycblt.auxio.util.logW
class GenreDetailFragment : DetailFragment() { class GenreDetailFragment : DetailFragment() {
private val args: GenreDetailFragmentArgs by navArgs() private val args: GenreDetailFragmentArgs by navArgs()
override fun onCreateView( override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
inflater: LayoutInflater, detailModel.setGenreId(args.genreId)
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
detailModel.setGenre(args.genreId)
val binding = FragmentDetailBinding.inflate(inflater)
val detailAdapter = val detailAdapter =
GenreDetailAdapter( GenreDetailAdapter(
playbackModel, playbackModel,
doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) }, doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) }) doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) })
// --- UI SETUP --- setupToolbar(detailModel.currentGenre.value!!)
setupRecycler(detailAdapter) { pos ->
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curGenre.value!!, binding)
setupRecycler(binding, detailAdapter) { pos ->
val item = detailAdapter.currentList[pos] val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Genre 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) { when (item) {
// All items will launch new detail fragments. // All items will launch new detail fragments.
is Artist -> { is Artist -> {
@ -82,8 +82,7 @@ class GenreDetailFragment : DetailFragment() {
} }
is Album -> { is Album -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() findNavController().navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id))
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id))
} }
is Song -> { is Song -> {
logD("Navigating to another song") logD("Navigating to another song")
@ -95,26 +94,14 @@ class GenreDetailFragment : DetailFragment() {
} }
} }
// --- PLAYBACKVIEWMODEL SETUP --- private fun updateSong(song: Song?, adapter: GenreDetailAdapter) {
val binding = requireBinding()
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE && if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE &&
playbackModel.parent.value?.id == detailModel.curGenre.value!!.id) { playbackModel.parent.value?.id == detailModel.currentGenre.value!!.id) {
detailAdapter.highlightSong(song, binding.detailRecycler) adapter.highlightSong(song, binding.detailRecycler)
} else { } else {
// Clear the ViewHolders if the mode isn't ALL_SONGS // 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.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow import org.oxycblt.auxio.util.logTraceOrThrow
@ -59,26 +59,58 @@ import org.oxycblt.auxio.util.logTraceOrThrow
* *
* TODO: Add duration and song count sorts * TODO: Add duration and song count sorts
*/ */
class HomeFragment : Fragment() { class HomeFragment : ViewBindingFragment<FragmentHomeBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels()
override fun onCreateView( override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
inflater: LayoutInflater,
container: ViewGroup?, override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeBinding.inflate(inflater)
val sortItem: MenuItem val sortItem: MenuItem
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
binding.homeToolbar.apply { binding.homeToolbar.apply {
sortItem = menu.findItem(R.id.submenu_sorting)
setOnMenuItemClickListener { item -> 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) { when (item.itemId) {
R.id.action_search -> { R.id.action_search -> {
logD("Navigating to search") logD("Navigating to search")
@ -101,161 +133,65 @@ class HomeFragment : Fragment() {
R.id.submenu_sorting -> {} R.id.submenu_sorting -> {}
R.id.option_sort_asc -> { R.id.option_sort_asc -> {
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
val new = homeModel.updateCurrentSort(
requireNotNull(
homeModel homeModel
.getSortForDisplay(homeModel.curTab.value!!) .getSortForDisplay(homeModel.currentTab.value!!)
.ascending(item.isChecked) .ascending(item.isChecked)))
homeModel.updateCurrentSort(new)
} }
// Sorting option was selected, mark it as selected and update the mode // Sorting option was selected, mark it as selected and update the mode
else -> { else -> {
item.isChecked = true item.isChecked = true
val new = homeModel.updateCurrentSort(
requireNotNull(
homeModel homeModel
.getSortForDisplay(homeModel.curTab.value!!) .getSortForDisplay(homeModel.currentTab.value!!)
.assignId(item.itemId) .assignId(item.itemId)))
homeModel.updateCurrentSort(requireNotNull(new)) }
} }
} }
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 // Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response. // loader response.
if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) { if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) {
return@observe return
} }
if (scrolling) { if (isFastScrolling) {
binding.homeFab.hide() binding.homeFab.hide()
} else { } else {
binding.homeFab.show() binding.homeFab.show()
} }
} }
homeModel.recreateTabs.observe(viewLifecycleOwner) { recreate -> private fun updateCurrentTab(sortItem: MenuItem, tab: DisplayMode) {
// 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)
// Make sure that we update the scrolling view and allowed menu items whenever // Make sure that we update the scrolling view and allowed menu items whenever
// the tab changes. // the tab changes.
val binding = requireBinding()
when (tab) { when (tab) {
DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab) DisplayMode.SHOW_SONGS -> {
DisplayMode.SHOW_ALBUMS -> updateSortMenu(sortItem, tab)
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list
}
DisplayMode.SHOW_ALBUMS -> {
updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_album } 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 } 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 } updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc }
} binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_genre_list
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 -> {}
} }
} }
} }
logD("Fragment Created")
return binding.root
}
private fun updateSortMenu( private fun updateSortMenu(
item: MenuItem, item: MenuItem,
displayMode: DisplayMode, displayMode: DisplayMode,
@ -276,13 +212,71 @@ class HomeFragment : Fragment() {
} }
} }
private val DisplayMode.viewId: Int private fun handleRecreateTabs(recreate: Boolean) {
get() = if (recreate) {
when (this) { requireBinding().homePager.recreate()
DisplayMode.SHOW_SONGS -> R.id.home_song_list homeModel.finishRecreateTabs()
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 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 : private inner class HomePagerAdapter :

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,15 +19,13 @@ package org.oxycblt.auxio.home.tabs
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.LifecycleDialog import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
@ -35,17 +33,25 @@ import org.oxycblt.auxio.util.logD
* serializes it's state instead of * serializes it's state instead of
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class TabCustomizeDialog : LifecycleDialog() { class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>() {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private var pendingTabs = settingsManager.libTabs private var pendingTabs = settingsManager.libTabs
override fun onCreateView( override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = 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) { if (savedInstanceState != null) {
// Restore any pending tab configurations // Restore any pending tab configurations
val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS)) val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
@ -57,11 +63,22 @@ class TabCustomizeDialog : LifecycleDialog() {
// Set up adapter & drag callback // Set up adapter & drag callback
val callback = TabDragCallback { pendingTabs } val callback = TabDragCallback { pendingTabs }
val helper = ItemTouchHelper(callback) val helper = ItemTouchHelper(callback)
val tabAdapter = val tabAdapter = TabAdapter(helper, getTabs = { pendingTabs }, onTabSwitch = ::moveTabs)
TabAdapter(
helper, callback.addTabAdapter(tabAdapter)
getTabs = { pendingTabs },
onTabSwitch = { tab -> 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 // 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 // of how ViewHolders are bound], but instead simply look for the mode in
// the list of pending tabs and update that instead. // the list of pending tabs and update that instead.
@ -76,35 +93,8 @@ class TabCustomizeDialog : LifecycleDialog() {
} }
} }
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
.isEnabled = pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty() 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)
} }
companion object { companion object {

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -33,7 +31,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogExcludedBinding import org.oxycblt.auxio.databinding.DialogExcludedBinding
import org.oxycblt.auxio.playback.PlaybackViewModel 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.hardRestart
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
@ -42,27 +40,29 @@ import org.oxycblt.auxio.util.showToast
* Dialog that manages the currently excluded directories. * Dialog that manages the currently excluded directories.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExcludedDialog : LifecycleDialog() { class ExcludedDialog : ViewBindingDialogFragment<DialogExcludedBinding>() {
private val excludedModel: ExcludedViewModel by viewModels { private val excludedModel: ExcludedViewModel by viewModels {
ExcludedViewModel.Factory(requireContext()) ExcludedViewModel.Factory(requireContext())
} }
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView( override fun onCreateBinding(inflater: LayoutInflater) = DialogExcludedBinding.inflate(inflater)
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = 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 adapter = ExcludedEntryAdapter { path -> excludedModel.removePath(path) }
val launcher = val launcher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath) registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
// --- UI SETUP ---
binding.excludedRecycler.adapter = adapter binding.excludedRecycler.adapter = adapter
// Now that the dialog exists, we get the view manually when the dialog is shown // Now that the dialog exists, we get the view manually when the dialog is shown
@ -90,23 +90,14 @@ class ExcludedDialog : LifecycleDialog() {
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
excludedModel.paths.observe(viewLifecycleOwner) { paths -> excludedModel.paths.observe(viewLifecycleOwner) { paths -> updatePaths(paths, adapter) }
adapter.submitList(paths)
binding.excludedEmpty.isVisible = paths.isEmpty()
}
logD("Dialog created") logD("Dialog created")
return binding.root
} }
override fun onConfigDialog(builder: AlertDialog.Builder) { private fun updatePaths(paths: MutableList<String>, adapter: ExcludedEntryAdapter) {
builder.setTitle(R.string.set_excluded) adapter.submitList(paths)
requireBinding().excludedEmpty.isVisible = paths.isEmpty()
// 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 addDocTreePath(uri: Uri?) { private fun addDocTreePath(uri: Uri?) {
@ -147,17 +138,16 @@ class ExcludedDialog : LifecycleDialog() {
return null return null
} }
private fun getRootPath(): String {
return Environment.getExternalStorageDirectory().absolutePath
}
private fun saveAndRestart() { private fun saveAndRestart() {
excludedModel.save { excludedModel.save {
playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() } 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 { companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED" 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.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import org.oxycblt.auxio.R 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.detail.DetailViewModel
import org.oxycblt.auxio.music.bindSongInfo import org.oxycblt.auxio.music.bindSongInfo
import org.oxycblt.auxio.ui.BottomSheetLayout import org.oxycblt.auxio.ui.BottomSheetLayout
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackBarFragment : Fragment() { class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override fun onCreateView( override fun onCreateBinding(inflater: LayoutInflater) =
inflater: LayoutInflater, FragmentPlaybackBarBinding.inflate(inflater)
container: ViewGroup?,
override fun onBindingCreated(
binding: FragmentPlaybackBarBinding,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ) {
val binding = FragmentPlaybackBarBinding.inflate(inflater)
// -- UI SETUP ---
binding.root.apply { binding.root.apply {
setOnClickListener { setOnClickListener {
// This is a dumb and fragile hack but this fragment isn't part of the navigation // 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.playbackPlayPause.isActivated = isPlaying
} }
binding.playbackProgressBar.progress = playbackModel.seconds.value!!.toInt() binding.playbackProgressBar.progress = playbackModel.positionSeconds.value!!.toInt()
playbackModel.seconds.observe(viewLifecycleOwner) { position -> playbackModel.positionSeconds.observe(viewLifecycleOwner) { position ->
binding.playbackProgressBar.progress = position.toInt() 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.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.detail.DetailViewModel 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.music.toDuration
import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.ui.BottomSheetLayout import org.oxycblt.auxio.ui.BottomSheetLayout
@ -55,24 +57,22 @@ class PlaybackPanelFragment :
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override fun onCreateBinding(inflater: LayoutInflater): FragmentPlaybackPanelBinding { override fun onCreateBinding(inflater: LayoutInflater) =
return FragmentPlaybackPanelBinding.inflate(inflater) FragmentPlaybackPanelBinding.inflate(inflater)
}
override fun onBindingCreated( override fun onBindingCreated(
binding: FragmentPlaybackPanelBinding, binding: FragmentPlaybackPanelBinding,
savedInstanceState: Bundle? savedInstanceState: Bundle?
) { ) {
val queueItem: MenuItem
// --- UI SETUP --- // --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { _, insets -> binding.root.setOnApplyWindowInsetsListener { _, insets ->
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
binding.root.updatePadding(top = bars.top, bottom = bars.bottom) binding.root.updatePadding(top = bars.top, bottom = bars.bottom)
insets insets
} }
val queueItem: MenuItem
binding.playbackToolbar.apply { binding.playbackToolbar.apply {
setNavigationOnClickListener { navigateUp() } setNavigationOnClickListener { navigateUp() }
@ -114,7 +114,6 @@ class PlaybackPanelFragment :
} }
binding.playbackLoop.setOnClickListener { playbackModel.incrementLoopStatus() } binding.playbackLoop.setOnClickListener { playbackModel.incrementLoopStatus() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.skipPrev() } binding.playbackSkipPrev.setOnClickListener { playbackModel.skipPrev() }
binding.playbackPlayPause.apply { binding.playbackPlayPause.apply {
@ -124,69 +123,21 @@ class PlaybackPanelFragment :
} }
binding.playbackSkipNext.setOnClickListener { playbackModel.skipNext() } binding.playbackSkipNext.setOnClickListener { playbackModel.skipNext() }
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffleStatus() } binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffleStatus() }
// --- VIEWMODEL SETUP -- // --- VIEWMODEL SETUP --
playbackModel.song.observe(viewLifecycleOwner) { song -> playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
if (song != null) { playbackModel.parent.observe(viewLifecycleOwner, ::updateParent)
logD("Updating song display to ${song.rawName}") playbackModel.positionSeconds.observe(viewLifecycleOwner, ::updatePosition)
binding.playbackCover.bindAlbumCover(song) playbackModel.loopMode.observe(viewLifecycleOwner, ::updateLoop)
binding.playbackSong.text = song.resolvedName playbackModel.isPlaying.observe(viewLifecycleOwner, ::updatePlayPause)
binding.playbackArtist.text = song.resolvedArtistName playbackModel.isShuffling.observe(viewLifecycleOwner, ::updateShuffle)
binding.playbackAlbum.text = song.resolvedAlbumName
// Normally if a song had a duration playbackModel.nextUp.observe(viewLifecycleOwner) { nextUp ->
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) {
// The queue icon uses a selector that will automatically tint the icon as active or // The queue icon uses a selector that will automatically tint the icon as active or
// inactive. We just need to set the flag. // inactive. We just need to set the flag.
queueItem.isEnabled = playbackModel.nextUp.value!!.isNotEmpty() queueItem.isEnabled = nextUp.isNotEmpty()
}
playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying ->
binding.playbackPlayPause.isActivated = isPlaying
} }
detailModel.navToItem.observe(viewLifecycleOwner) { item -> detailModel.navToItem.observe(viewLifecycleOwner) { item ->
@ -205,20 +156,68 @@ class PlaybackPanelFragment :
} }
override fun onStartTrackingTouch(slider: Slider) { override fun onStartTrackingTouch(slider: Slider) {
requireBinding().playbackSeconds.isActivated = true requireBinding().playbackPosition.isActivated = true
} }
override fun onStopTrackingTouch(slider: Slider) { override fun onStopTrackingTouch(slider: Slider) {
requireBinding().playbackSeconds.isActivated = false requireBinding().playbackPosition.isActivated = false
playbackModel.setPosition(slider.value.toLong()) playbackModel.setPosition(slider.value.toLong())
} }
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (fromUser) { 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() { private fun navigateUp() {
// This is a dumb and fragile hack but this fragment isn't part of the navigation stack // This is a dumb and fragile hack but this fragment isn't part of the navigation stack
// so we can't really do much // 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 mIsPlaying = MutableLiveData(false)
private val mIsShuffling = MutableLiveData(false) private val mIsShuffling = MutableLiveData(false)
private val mLoopMode = MutableLiveData(LoopMode.NONE) private val mLoopMode = MutableLiveData(LoopMode.NONE)
private val mSeconds = MutableLiveData(0L) private val mPositionSeconds = MutableLiveData(0L)
// Queue // Queue
private val mNextUp = MutableLiveData(listOf<Song>()) private val mNextUp = MutableLiveData(listOf<Song>())
@ -82,8 +82,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
val loopMode: LiveData<LoopMode> val loopMode: LiveData<LoopMode>
get() = mLoopMode get() = mLoopMode
/** The current playback position, in seconds */ /** The current playback position, in seconds */
val seconds: LiveData<Long> val positionSeconds: LiveData<Long>
get() = mSeconds get() = mPositionSeconds
/** The queue, without the previous items. */ /** The queue, without the previous items. */
val nextUp: LiveData<List<Song>> val nextUp: LiveData<List<Song>>
@ -336,7 +336,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
} }
override fun onPositionUpdate(position: Long) { override fun onPositionUpdate(position: Long) {
mSeconds.value = position / 1000 mPositionSeconds.value = position / 1000
} }
override fun onQueueUpdate(queue: List<Song>, index: Int) { 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.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A [Fragment] that shows the queue and enables editing as well. * A [Fragment] that shows the queue and enables editing as well.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class QueueFragment : Fragment() { class QueueFragment : ViewBindingFragment<FragmentQueueBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private var lastShuffle: Boolean? = null
override fun onCreateView( override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
inflater: LayoutInflater,
container: ViewGroup?, override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) {
savedInstanceState: Bundle? // TODO: Merge ItemTouchHelper with QueueAdapter
): View {
val binding = FragmentQueueBinding.inflate(inflater)
val callback = QueueDragCallback(playbackModel) val callback = QueueDragCallback(playbackModel)
val helper = ItemTouchHelper(callback) val helper = ItemTouchHelper(callback)
val queueAdapter = QueueAdapter(helper) val queueAdapter = QueueAdapter(helper)
callback.addQueueAdapter(queueAdapter) callback.addQueueAdapter(queueAdapter)
var lastShuffle = playbackModel.isShuffling.value
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() } binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.queueRecycler.apply { binding.queueRecycler.apply {
@ -63,15 +55,7 @@ class QueueFragment : Fragment() {
// --- VIEWMODEL SETUP ---- // --- VIEWMODEL SETUP ----
playbackModel.nextUp.observe(viewLifecycleOwner) { queue -> lastShuffle = playbackModel.isShuffling.value
if (queue.isEmpty()) {
findNavController().navigateUp()
return@observe
}
queueAdapter.submitList(queue.toMutableList())
}
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling -> playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
// Try to prevent the queue adapter from going spastic during reshuffle events // Try to prevent the queue adapter from going spastic during reshuffle events
// by just scrolling back to the top. // 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 package org.oxycblt.auxio.playback.state
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
/** /**
* Enum that determines the playback repeat mode. * 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 * Convert the LoopMode to an int constant that is saved in PlaybackStateDatabase
* @return The int constant for this mode * @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 * This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], so
* therefore there's no need to bind to it to deliver commands. * therefore there's no need to bind to it to deliver commands.
* @author OxygenCobalt * @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 : class PlaybackService :
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback { 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 // 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: 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 { serviceScope.launch {
playbackManager.saveStateToDatabase(this@PlaybackService) playbackManager.saveStateToDatabase(this@PlaybackService)
serviceJob.cancel() serviceJob.cancel()

View file

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

View file

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

View file

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

View file

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

View file

@ -17,15 +17,18 @@
package org.oxycblt.auxio.settings.pref package org.oxycblt.auxio.settings.pref
import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ui.LifecycleDialog
/** The dialog shown whenever an [IntListPreference] is shown. */ /** The dialog shown whenever an [IntListPreference] is shown. */
class IntListPrefDialog : LifecycleDialog() { class IntListPrefDialog : DialogFragment() {
override fun onConfigDialog(builder: AlertDialog.Builder) { 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 // Since we have to store the preference key as an argument, we have to find the
// preference we need to use manually. // preference we need to use manually.
val pref = val pref =
@ -41,6 +44,8 @@ class IntListPrefDialog : LifecycleDialog() {
} }
builder.setNegativeButton(android.R.string.cancel, null) builder.setNegativeButton(android.R.string.cancel, null)
return builder.create()
} }
companion object { 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. // 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 // 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. // 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 bars = insets.systemBarInsetsCompat
val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0) val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)

View file

@ -50,6 +50,15 @@ enum class DisplayMode {
SHOW_GENRES -> R.drawable.ic_genre 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 val intCode: Int
get() = get() =
when (this) { 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 { 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> { class NameComparator<T : Music> : Comparator<T> {
override fun compare(a: T?, b: T?): Int { override fun compare(a: T, b: T): Int {
if (a == null && b != null) return -1 // -1 -> a < b return a.resolvedName
if (a == null && b == null) return 0 // 0 -> 0 = b
if (a != null && b == null) return 1 // 1 -> a > b
return a!!.resolvedName
.sliceArticle() .sliceArticle()
.compareTo(b!!.resolvedName.sliceArticle(), ignoreCase = true) .compareTo(b.resolvedName.sliceArticle(), ignoreCase = true)
} }
} }
class NullableComparator<T : Comparable<T>> : Comparator<T?> { class NullableComparator<T : Comparable<T>> : Comparator<T?> {
override fun compare(a: T?, b: T?): Int { override fun compare(a: T?, b: T?): Int {
if (a == null && b != null) return -1 // -1 -> a < b 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 if (a != null && b == null) return 1 // 1 -> a > b
return a!!.compareTo(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 android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.util.logD
/** A fragment enabling ViewBinding inflation and usage across the fragment lifecycle. */ /** A fragment enabling ViewBinding inflation and usage across the fragment lifecycle. */
abstract class ViewBindingFragment<T : ViewBinding> : Fragment() { abstract class ViewBindingFragment<T : ViewBinding> : Fragment() {
private var mBinding: T? = null private var mBinding: T? = null
abstract fun onCreateBinding(inflater: LayoutInflater): T protected abstract fun onCreateBinding(inflater: LayoutInflater): T
abstract fun onBindingCreated(binding: T, savedInstanceState: Bundle?) protected open fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {}
abstract fun onDestroyBinding(binding: T) 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View = onCreateBinding(inflater).also { mBinding = it }.root
val binding = onCreateBinding(inflater).also { mBinding = it }
onBindingCreated(binding, savedInstanceState) override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
return binding.root super.onViewCreated(view, savedInstanceState)
onBindingCreated(requireBinding(), savedInstanceState)
logD("Fragment created")
} }
override fun onDestroyView() { override fun onDestroyView() {
@ -47,13 +59,4 @@ abstract class ViewBindingFragment<T : ViewBinding> : Fragment() {
onDestroyBinding(requireBinding()) onDestroyBinding(requireBinding())
mBinding = null 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.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import android.util.Log
import android.view.View import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
@ -93,9 +92,6 @@ private fun isUnderImpl(
if (viewSize >= minTouchTargetSize) { if (viewSize >= minTouchTargetSize) {
return position >= viewStart && position < viewEnd return position >= viewStart && position < viewEnd
} }
Log.d("Auxio.ViewUtil", "isInTouchTarget: $minTouchTargetSize")
var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2 var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2
if (touchTargetStart < 0) { if (touchTargetStart < 0) {

View file

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

View file

@ -4,22 +4,6 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
tools:context=".playback.PlaybackPanelFragment"> 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 <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playback_layout" android:id="@+id/playback_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -112,7 +96,7 @@
app:thumbRadius="@dimen/slider_thumb_radius" /> app:thumbRadius="@dimen/slider_thumb_radius" />
<TextView <TextView
android:id="@+id/playback_seconds" android:id="@+id/playback_position"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium" android:layout_marginStart="@dimen/spacing_medium"

View file

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

View file

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

View file

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

View file

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

View file

@ -39,7 +39,7 @@
<dimen name="fast_scroll_thumb_touch_target_size">16dp</dimen> <dimen name="fast_scroll_thumb_touch_target_size">16dp</dimen>
<dimen name="slider_thumb_radius">6dp</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_normal">88dp</dimen>
<dimen name="recycler_fab_space_large">128dp</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"> <style name="Widget.Auxio.TextView.Primary.AppWidget" parent="Widget.Auxio.TextView.AppWidget">
<item name="android:textStyle">normal</item> <item name="android:textStyle">normal</item>
<item name="android:textSize">@dimen/text_size_ext_title_mid_large</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> <item name="android:textAppearance">@style/TextAppearance.Material3.TitleMedium</item>
</style> </style>