music: rework picker system

Rework the music picker system to be a reactive, viewmodel-based system
instead of a janky UI system.

This should make it much easier to maintain and extend in the future.
This commit is contained in:
Alexander Capehart 2022-11-19 16:12:04 -07:00
parent 086f7836bd
commit c13a57f694
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
24 changed files with 264 additions and 211 deletions

View file

@ -34,6 +34,7 @@ import com.google.android.material.transition.MaterialFadeThrough
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.Artist
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.PlaybackSheetBehavior import org.oxycblt.auxio.playback.PlaybackSheetBehavior
@ -124,7 +125,9 @@ class MainFragment :
collect(navModel.mainNavigationAction, ::handleMainNavigation) collect(navModel.mainNavigationAction, ::handleMainNavigation)
collect(navModel.exploreNavigationItem, ::handleExploreNavigation) collect(navModel.exploreNavigationItem, ::handleExploreNavigation)
collect(navModel.exploreNavigationArtists, ::handleExplorePicker)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPlaybackPickerSong, ::handlePlaybackPicker)
} }
override fun onStart() { override fun onStart() {
@ -213,14 +216,6 @@ class MainFragment :
return true return true
} }
private fun updateSong(song: Song?) {
if (song != null) {
tryUnhideAll()
} else {
tryHideAll()
}
}
private fun handleMainNavigation(action: MainNavigationAction?) { private fun handleMainNavigation(action: MainNavigationAction?) {
if (action == null) return if (action == null) return
@ -239,6 +234,32 @@ class MainFragment :
} }
} }
private fun handleExplorePicker(items: List<Artist>?) {
if (items != null) {
navModel.mainNavigateTo(MainNavigationAction.Directions(
MainFragmentDirections.actionPickNavigationArtist(items.map { it.uid }.toTypedArray())
))
navModel.finishExploreNavigation()
}
}
private fun updateSong(song: Song?) {
if (song != null) {
tryUnhideAll()
} else {
tryHideAll()
}
}
private fun handlePlaybackPicker(song: Song?) {
if (song != null) {
navModel.mainNavigateTo(MainNavigationAction.Directions(
MainFragmentDirections.actionPickPlaybackArtist(song.uid)
))
playbackModel.finishPlaybackArtistPicker()
}
}
private fun tryExpandAll() { private fun tryExpandAll() {
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =

View file

@ -39,9 +39,8 @@ import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.fragment.MusicFragment import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
@ -56,7 +55,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class AlbumDetailFragment : class AlbumDetailFragment :
MusicFragment<FragmentDetailBinding>(), MenuFragment<FragmentDetailBinding>(),
Toolbar.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener,
AlbumDetailAdapter.Listener { AlbumDetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
@ -127,7 +126,7 @@ class AlbumDetailFragment :
null, null,
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ARTISTS -> doArtistDependentAction(item, PickerMode.PLAY) MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
} }
} }
@ -164,7 +163,7 @@ class AlbumDetailFragment :
} }
override fun onNavigateToArtist() { override fun onNavigateToArtist() {
navigateToArtist(unlikelyToBeNull(detailModel.currentAlbum.value)) navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artists)
} }
private fun handleItemChange(album: Album?) { private fun handleItemChange(album: Album?) {

View file

@ -37,9 +37,8 @@ import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.fragment.MusicFragment import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
@ -53,9 +52,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ArtistDetailFragment : class ArtistDetailFragment :
MusicFragment<FragmentDetailBinding>(), MenuFragment<FragmentDetailBinding>(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener {
Toolbar.OnMenuItemClickListener,
DetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val args: ArtistDetailFragmentArgs by navArgs() private val args: ArtistDetailFragmentArgs by navArgs()
@ -123,7 +120,7 @@ class ArtistDetailFragment :
item, unlikelyToBeNull(detailModel.currentArtist.value)) item, unlikelyToBeNull(detailModel.currentArtist.value))
MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
MusicMode.ARTISTS -> doArtistDependentAction(item, PickerMode.PLAY) MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
} }
} }

View file

@ -38,9 +38,8 @@ import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.fragment.MusicFragment import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
@ -54,9 +53,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class GenreDetailFragment : class GenreDetailFragment :
MusicFragment<FragmentDetailBinding>(), MenuFragment<FragmentDetailBinding>(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener {
Toolbar.OnMenuItemClickListener,
DetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val args: GenreDetailFragmentArgs by navArgs() private val args: GenreDetailFragmentArgs by navArgs()
@ -125,7 +122,7 @@ class GenreDetailFragment :
item, unlikelyToBeNull(detailModel.currentGenre.value)) item, unlikelyToBeNull(detailModel.currentGenre.value))
MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
MusicMode.ARTISTS -> doArtistDependentAction(item, PickerMode.PLAY) MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
} }
else -> error("Unexpected datatype: ${item::class.simpleName}") else -> error("Unexpected datatype: ${item::class.simpleName}")

View file

@ -23,7 +23,7 @@ import androidx.fragment.app.Fragment
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.ui.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.ui.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.ui.fragment.MusicFragment import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.androidActivityViewModels
@ -33,7 +33,7 @@ import org.oxycblt.auxio.util.androidActivityViewModels
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class HomeListFragment<T : Item> : abstract class HomeListFragment<T : Item> :
MusicFragment<FragmentHomeListBinding>(), MenuFragment<FragmentHomeListBinding>(),
MenuItemListener, MenuItemListener,
FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.OnFastScrollListener { FastScrollRecyclerView.OnFastScrollListener {

View file

@ -28,7 +28,6 @@ import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
@ -110,7 +109,7 @@ class SongListFragment : HomeListFragment<Song>() {
when (settings.libPlaybackMode) { when (settings.libPlaybackMode) {
MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
MusicMode.ARTISTS -> doArtistDependentAction(item, PickerMode.PLAY) MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
else -> error("Unexpected playback mode: ${settings.libPlaybackMode}") else -> error("Unexpected playback mode: ${settings.libPlaybackMode}")
} }
} }

View file

@ -204,8 +204,8 @@ abstract class MediaStoreExtractor(
// Since we can't obtain the genre tag from a song query, we must construct // Since we can't obtain the genre tag from a song query, we must construct
// our own equivalent from genre database queries. Theoretically, this isn't // our own equivalent from genre database queries. Theoretically, this isn't
// needed since MetadataLayer will fill this in for us, but there are some // needed since MetadataLayer will fill this in for us, but I'd imagine there
// obscure formats where genre support is only really covered by this. // are some obscure formats where genre support is only really covered by this.
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor -> arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->

View file

@ -51,7 +51,7 @@ fun String.parseYear() = toIntOrNull()?.toDate()
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */ /** Parse an ISO-8601 time-stamp from this field into a [Date]. */
fun String.parseTimestamp() = Date.from(this) fun String.parseTimestamp() = Date.from(this)
/** Parse a string by [selector], also handling string escaping. */ /** Split a string by [selector], also handling escaping. */
inline fun String.splitEscaped(selector: (Char) -> Boolean): MutableList<String> { inline fun String.splitEscaped(selector: (Char) -> Boolean): MutableList<String> {
val split = mutableListOf<String>() val split = mutableListOf<String>()
var currentString = "" var currentString = ""

View file

@ -0,0 +1,48 @@
/*
* 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.music.picker
import android.os.Bundle
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.collectImmediately
/**
* The [ArtistPickerDialog] for ambiguous artist navigation operations.
* @author OxygenCobalt
*/
class ArtistNavigationPickerDialog : ArtistPickerDialog() {
private val navModel: NavigationViewModel by activityViewModels()
private val args: ArtistNavigationPickerDialogArgs by navArgs()
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
pickerModel.setArtistUids(args.artistUids)
super.onBindingCreated(binding, savedInstanceState)
}
override fun onItemClick(item: Item) {
super.onItemClick(item)
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
navModel.exploreNavigateTo(item)
}
}

View file

@ -20,38 +20,19 @@ package org.oxycblt.auxio.music.picker
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.ItemClickListener import org.oxycblt.auxio.ui.recycler.ItemClickListener
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
/** abstract class ArtistPickerDialog :
* A dialog that shows several artist options if the result of an artist-reliant operation is
* ambiguous.
* @author OxygenCobalt
*
* TODO: Clean up the picker flow to reduce the amount of duplication I had to do.
*/
class ArtistPickerDialog :
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ItemClickListener { ViewBindingDialogFragment<DialogMusicPickerBinding>(), ItemClickListener {
private val pickerModel: PickerViewModel by viewModels() protected val pickerModel: MusicPickerViewModel by viewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val artistAdapter = ArtistChoiceAdapter(this)
private val navModel: NavigationViewModel by activityViewModels()
private val args: ArtistPickerDialogArgs by navArgs()
private val adapter = ArtistChoiceAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicPickerBinding.inflate(inflater) DialogMusicPickerBinding.inflate(inflater)
@ -61,16 +42,12 @@ class ArtistPickerDialog :
} }
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
pickerModel.setSongUid(args.uid) binding.pickerRecycler.adapter = artistAdapter
collectImmediately(pickerModel.currentArtists) { artists ->
binding.pickerRecycler.adapter = adapter if (artists != null) {
artistAdapter.submitList(artists)
collectImmediately(pickerModel.currentItem) { item -> } else {
when (item) { findNavController().navigateUp()
is Song -> adapter.submitList(item.artists)
is Album -> adapter.submitList(item.artists)
null -> findNavController().navigateUp()
else -> error("Invalid datatype: ${item::class.java}")
} }
} }
} }
@ -80,15 +57,6 @@ class ArtistPickerDialog :
} }
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
findNavController().navigateUp() findNavController().navigateUp()
when (args.pickerMode) {
PickerMode.SHOW -> navModel.exploreNavigateTo(item)
PickerMode.PLAY -> {
val currentItem = pickerModel.currentItem.value
check(currentItem is Song) { "PickerMode.PLAY is only allowed with Songs" }
playbackModel.playFromArtist(currentItem, item)
}
}
} }
} }

View file

@ -0,0 +1,49 @@
/*
* 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.music.picker
import android.os.Bundle
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* The [ArtistPickerDialog] for ambiguous artist playback operations.
* @author OxygenCobalt
*/
class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val args: ArtistPlaybackPickerDialogArgs by navArgs()
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
pickerModel.setSongUid(args.songUid)
super.onBindingCreated(binding, savedInstanceState)
}
override fun onItemClick(item: Item) {
super.onItemClick(item)
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
pickerModel.currentSong.value?.let { song ->
playbackModel.playFromArtist(song, item)
}
}
}

View file

@ -0,0 +1,31 @@
package org.oxycblt.auxio.music.picker
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.unlikelyToBeNull
class MusicPickerViewModel : ViewModel() {
private val musicStore = MusicStore.getInstance()
private val _currentSong = MutableStateFlow<Song?>(null)
val currentSong: StateFlow<Song?> get() = _currentSong
private val _currentArtists = MutableStateFlow<List<Artist>?>(null)
val currentArtists: StateFlow<List<Artist>?> get() = _currentArtists
fun setSongUid(uid: Music.UID) {
val library = unlikelyToBeNull(musicStore.library)
_currentSong.value = library.find(uid)
_currentArtists.value = _currentSong.value?.artists
}
fun setArtistUids(uids: Array<Music.UID>) {
val library = unlikelyToBeNull(musicStore.library)
_currentArtists.value = uids.mapNotNull { library.find<Artist>(it) }.ifEmpty { null }
}
}

View file

@ -1,24 +0,0 @@
/*
* 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.music.picker
/** Represents the actions available to the picker UI. */
enum class PickerMode {
PLAY,
SHOW
}

View file

@ -1,60 +0,0 @@
/*
* 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.music.picker
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A small ViewModel holding and updating the current song being shown in the picker UI.
* @author OxygenCobalt
*/
class PickerViewModel : ViewModel(), MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
private var _currentItem = MutableStateFlow<Music?>(null)
val currentItem: StateFlow<Music?> = _currentItem
fun setSongUid(uid: Music.UID) {
if (_currentItem.value?.uid == uid) return
val library = unlikelyToBeNull(musicStore.library)
val item = requireNotNull(library.find(uid)) { "Invalid song id provided" }
_currentItem.value = item
}
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
when (val item = currentItem.value) {
is Song -> {
_currentItem.value = library.sanitize(item)
}
is Album -> {
_currentItem.value = library.sanitize(item)
}
null -> {}
else -> error("Invalid datatype: ${item::class.java}")
}
}
}
}

View file

@ -32,11 +32,10 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.playback.ui.StyledSeekBar
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.fragment.MusicFragment import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -49,7 +48,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* TODO: Make seek thumb grow when selected * TODO: Make seek thumb grow when selected
*/ */
class PlaybackPanelFragment : class PlaybackPanelFragment :
MusicFragment<FragmentPlaybackPanelBinding>(), MenuFragment<FragmentPlaybackPanelBinding>(),
StyledSeekBar.Callback, StyledSeekBar.Callback,
Toolbar.OnMenuItemClickListener { Toolbar.OnMenuItemClickListener {
// AudioEffect expects you to use startActivityForResult with the panel intent. Use // AudioEffect expects you to use startActivityForResult with the panel intent. Use
@ -207,7 +206,7 @@ class PlaybackPanelFragment :
private fun showCurrentArtist() { private fun showCurrentArtist() {
val song = playbackModel.song.value ?: return val song = playbackModel.song.value ?: return
doArtistDependentAction(song, PickerMode.SHOW) navModel.exploreNavigateTo(song.artists)
} }
private fun showCurrentAlbum() { private fun showCurrentAlbum() {
val song = playbackModel.song.value ?: return val song = playbackModel.song.value ?: return

View file

@ -77,11 +77,18 @@ class PlaybackViewModel(application: Application) :
val isShuffled: StateFlow<Boolean> val isShuffled: StateFlow<Boolean>
get() = _isShuffled get() = _isShuffled
/** The current ID of the app's audio session. */
val currentAudioSessionId: Int? val currentAudioSessionId: Int?
get() = playbackManager.currentAudioSessionId get() = playbackManager.currentAudioSessionId
private var lastPositionJob: Job? = null private var lastPositionJob: Job? = null
private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null)
/** Flag for resolving an ambiguous artist choice when playing from a song's artists. */
val artistPlaybackPickerSong: StateFlow<Song?>
get() = _artistPlaybackPickerSong
init { init {
playbackManager.addCallback(this) playbackManager.addCallback(this)
} }
@ -104,9 +111,22 @@ class PlaybackViewModel(application: Application) :
} }
/** Play a song from it's artist. */ /** Play a song from it's artist. */
fun playFromArtist(song: Song, artist: Artist) { fun playFromArtist(song: Song, artist: Artist? = null) {
check(artist.songs.contains(song)) { "Invalid input: Artist is not linked to song" } if (artist != null) {
check(artist in song.artists) { "Artist not in song artists" }
playbackManager.play(song, artist, settings) playbackManager.play(song, artist, settings)
} else {
if (song.artists.size == 1) {
playbackManager.play(song, song.artists[0], settings)
} else {
_artistPlaybackPickerSong.value = song
}
}
}
/** Complete the picker opening process when playing from an artist. */
fun finishPlaybackArtistPicker() {
_artistPlaybackPickerSong.value = null
} }
/** Play a song from the specific genre that contains the song. */ /** Play a song from the specific genre that contains the song. */

View file

@ -71,6 +71,8 @@ import org.oxycblt.auxio.widgets.WidgetProvider
* not the source of truth for the state, but rather the means to control system-side playback. Both * not the source of truth for the state, but rather the means to control system-side playback. Both
* of those tasks are what [PlaybackStateManager] is for. * of those tasks are what [PlaybackStateManager] is for.
* *
* TODO: Refactor lifecycle to run completely headless (i.e no activity needed)
*
* TODO: Android Auto * TODO: Android Auto
* *
* @author OxygenCobalt * @author OxygenCobalt

View file

@ -38,9 +38,8 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.fragment.MusicFragment import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.util.androidViewModels import org.oxycblt.auxio.util.androidViewModels
@ -55,7 +54,7 @@ import org.oxycblt.auxio.util.logW
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SearchFragment : class SearchFragment :
MusicFragment<FragmentSearchBinding>(), MenuItemListener, Toolbar.OnMenuItemClickListener { MenuFragment<FragmentSearchBinding>(), MenuItemListener, Toolbar.OnMenuItemClickListener {
// SearchViewModel is only scoped to this Fragment // SearchViewModel is only scoped to this Fragment
private val searchModel: SearchViewModel by androidViewModels() private val searchModel: SearchViewModel by androidViewModels()
@ -149,7 +148,7 @@ class SearchFragment :
when (settings.libPlaybackMode) { when (settings.libPlaybackMode) {
MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
MusicMode.ARTISTS -> doArtistDependentAction(item, PickerMode.PLAY) MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
else -> error("Unexpected playback mode: ${settings.libPlaybackMode}") else -> error("Unexpected playback mode: ${settings.libPlaybackMode}")
} }
is MusicParent -> navModel.exploreNavigateTo(item) is MusicParent -> navModel.exploreNavigateTo(item)

View file

@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -44,6 +45,15 @@ class NavigationViewModel : ViewModel() {
val exploreNavigationItem: StateFlow<Music?> val exploreNavigationItem: StateFlow<Music?>
get() = _exploreNavigationItem get() = _exploreNavigationItem
private val _exploreNavigationArtists = MutableStateFlow<List<Artist>?>(null)
/**
* Flag for navigation within the explore fragments. In this case, it involves an ambiguous list
* of artist choices.
*/
val exploreNavigationArtists: StateFlow<List<Artist>?>
get() = _exploreNavigationArtists
/** Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. */ /** Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. */
fun mainNavigateTo(action: MainNavigationAction) { fun mainNavigateTo(action: MainNavigationAction) {
if (_mainNavigationAction.value != null) { if (_mainNavigationAction.value != null) {
@ -72,10 +82,26 @@ class NavigationViewModel : ViewModel() {
_exploreNavigationItem.value = item _exploreNavigationItem.value = item
} }
/** Navigate to one item out of a list of items. */
fun exploreNavigateTo(items: List<Artist>) {
if (_exploreNavigationArtists.value != null) {
logD("Already navigating, not doing explore action")
return
}
if (items.size == 1) {
exploreNavigateTo(items[0])
} else {
logD("Navigating to a choice of ${items.map { it.rawName }}")
_exploreNavigationArtists.value = items
}
}
/** Mark that the item navigation process is done. */ /** Mark that the item navigation process is done. */
fun finishExploreNavigation() { fun finishExploreNavigation() {
logD("Finishing explore navigation process") logD("Finishing explore navigation process")
_exploreNavigationItem.value = null _exploreNavigationItem.value = null
_exploreNavigationArtists.value = null
} }
} }
@ -91,5 +117,6 @@ sealed class MainNavigationAction {
/** Collapse the playback panel. */ /** Collapse the playback panel. */
object Collapse : MainNavigationAction() object Collapse : MainNavigationAction()
/** Provide raw navigation directions. */
data class Directions(val directions: NavDirections) : MainNavigationAction() data class Directions(val directions: NavDirections) : MainNavigationAction()
} }

View file

@ -67,6 +67,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* *
* TODO: Add vibration when popup changes * TODO: Add vibration when popup changes
* *
* TODO: Improve this for variably sized items
*
* @author Hai Zhang, OxygenCobalt * @author Hai Zhang, OxygenCobalt
*/ */
class FastScrollRecyclerView class FastScrollRecyclerView

View file

@ -29,7 +29,6 @@ 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.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
@ -42,40 +41,12 @@ import org.oxycblt.auxio.util.showToast
* preventing UI issues and memory leaks. * preventing UI issues and memory leaks.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class MusicFragment<T : ViewBinding> : ViewBindingFragment<T>() { abstract class MenuFragment<T : ViewBinding> : ViewBindingFragment<T>() {
private var currentMenu: PopupMenu? = null private var currentMenu: PopupMenu? = null
protected val playbackModel: PlaybackViewModel by androidActivityViewModels() protected val playbackModel: PlaybackViewModel by androidActivityViewModels()
protected val navModel: NavigationViewModel by activityViewModels() protected val navModel: NavigationViewModel by activityViewModels()
/**
* Run the UI flow to perform a specific [PickerMode] action with a particular artist from
* [song].
*/
fun doArtistDependentAction(song: Song, mode: PickerMode) {
if (song.artists.size == 1) {
when (mode) {
PickerMode.PLAY -> playbackModel.playFromArtist(song, song.artists[0])
PickerMode.SHOW -> navModel.exploreNavigateTo(song.artists[0])
}
} else {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.actionPickArtist(song.uid, mode)))
}
}
/** Run the UI flow to navigate to a particular artist from [album]. */
fun navigateToArtist(album: Album) {
if (album.artists.size == 1) {
navModel.exploreNavigateTo(album.artists[0])
} else {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.actionPickArtist(album.uid, PickerMode.SHOW)))
}
}
/** /**
* Opens the given menu in context of [song]. Assumes that the menu is only composed of common * Opens the given menu in context of [song]. Assumes that the menu is only composed of common
* [Song] options. * [Song] options.
@ -94,7 +65,7 @@ abstract class MusicFragment<T : ViewBinding> : ViewBindingFragment<T>() {
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
} }
R.id.action_go_artist -> { R.id.action_go_artist -> {
doArtistDependentAction(song, PickerMode.SHOW) navModel.exploreNavigateTo(song.artists)
} }
R.id.action_go_album -> { R.id.action_go_album -> {
navModel.exploreNavigateTo(song.album) navModel.exploreNavigateTo(song.album)
@ -137,7 +108,7 @@ abstract class MusicFragment<T : ViewBinding> : ViewBindingFragment<T>() {
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
} }
R.id.action_go_artist -> { R.id.action_go_artist -> {
navigateToArtist(album) navModel.exploreNavigateTo(album.artists)
} }
else -> { else -> {
error("Unexpected menu item selected") error("Unexpected menu item selected")

View file

@ -65,8 +65,6 @@ class WidgetComponent(private val context: Context) :
// 1. We can't use the typical primitives like ViewModels // 1. We can't use the typical primitives like ViewModels
// 2. The component range is far smaller, so we have to do some odd hacks to get // 2. The component range is far smaller, so we have to do some odd hacks to get
// the same UX. // the same UX.
// 3. RemoteView memory is limited, so we want to batch updates as much as physically
// possible.
val song = playbackManager.song val song = playbackManager.song
if (song == null) { if (song == null) {
logD("No song, resetting widget") logD("No song, resetting widget")

View file

@ -18,22 +18,31 @@
android:id="@+id/action_show_details" android:id="@+id/action_show_details"
app:destination="@id/song_detail_dialog" /> app:destination="@id/song_detail_dialog" />
<action <action
android:id="@+id/action_pick_artist" android:id="@+id/action_pick_playback_artist"
app:destination="@id/artist_picker_dialog" /> app:destination="@id/artist_playback_picker_dialog" />
<action
android:id="@+id/action_pick_navigation_artist"
app:destination="@id/artist_navigation_picker_dialog" />
</fragment> </fragment>
<dialog <dialog
android:id="@+id/artist_picker_dialog" android:id="@+id/artist_playback_picker_dialog"
android:name="org.oxycblt.auxio.music.picker.ArtistPickerDialog" android:name="org.oxycblt.auxio.music.picker.ArtistPlaybackPickerDialog"
android:label="artist_picker_dialog" android:label="artist_playback_picker_dialog"
tools:layout="@layout/dialog_music_picker"> tools:layout="@layout/dialog_music_picker">
<argument <argument
android:name="uid" android:name="songUid"
app:argType="org.oxycblt.auxio.music.Music$UID" /> app:argType="org.oxycblt.auxio.music.Music$UID" />
</dialog>
<dialog
android:id="@+id/artist_navigation_picker_dialog"
android:name="org.oxycblt.auxio.music.picker.ArtistNavigationPickerDialog"
android:label="artist_navigation_picker_dialog"
tools:layout="@layout/dialog_music_picker">
<argument <argument
android:name="pickerMode" android:name="artistUids"
app:argType="org.oxycblt.auxio.music.picker.PickerMode" /> app:argType="org.oxycblt.auxio.music.Music$UID[]" />
</dialog> </dialog>
<dialog <dialog

View file

@ -29,6 +29,7 @@ OK="\033[1;92m"
NC="\033[0m" NC="\033[0m"
# We do some shell scripting later on, so we can't support windows. # We do some shell scripting later on, so we can't support windows.
# TODO: Support windows
system = platform.system() system = platform.system()
if system not in ["Linux", "Darwin"]: if system not in ["Linux", "Darwin"]:
print("fatal: unsupported platform " + system) print("fatal: unsupported platform " + system)