playlist: add basic ui support

Add extremely basic UI support for playlists.
This commit is contained in:
Alexander Capehart 2023-03-20 19:35:28 -06:00
parent 9a282e2be9
commit 686290a6c1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
33 changed files with 505 additions and 133 deletions

View file

@ -33,18 +33,20 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST = 0xA002
/** GenreViewHolder */
const val VIEW_TYPE_GENRE = 0xA003
/** PlaylistViewHolder */
const val VIEW_TYPE_PLAYLIST = 0xA004
/** BasicHeaderViewHolder */
const val VIEW_TYPE_BASIC_HEADER = 0xA004
const val VIEW_TYPE_BASIC_HEADER = 0xA005
/** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA005
const val VIEW_TYPE_SORT_HEADER = 0xA006
/** AlbumSongViewHolder */
const val VIEW_TYPE_ALBUM_SONG = 0xA007
/** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
const val VIEW_TYPE_ARTIST_ALBUM = 0xA008
/** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
const val VIEW_TYPE_ARTIST_SONG = 0xA009
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00C
const val VIEW_TYPE_DISC_HEADER = 0xA00A
/** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */
@ -65,16 +67,16 @@ object IntegerTable {
const val PLAYBACK_MODE_IN_ALBUM = 0xA105
/** PlaybackMode.ALL_SONGS */
const val PLAYBACK_MODE_ALL_SONGS = 0xA106
/** DisplayMode.NONE (No Longer used but still reserved) */
// const val DISPLAY_MODE_NONE = 0xA107
/** MusicMode._GENRES */
const val MUSIC_MODE_GENRES = 0xA108
/** MusicMode._ARTISTS */
const val MUSIC_MODE_ARTISTS = 0xA109
/** MusicMode._ALBUMS */
const val MUSIC_MODE_ALBUMS = 0xA10A
/** MusicMode.SONGS */
const val MUSIC_MODE_SONGS = 0xA10B
/** MusicMode.ALBUMS */
const val MUSIC_MODE_ALBUMS = 0xA10A
/** MusicMode.ARTISTS */
const val MUSIC_MODE_ARTISTS = 0xA109
/** MusicMode.GENRES */
const val MUSIC_MODE_GENRES = 0xA108
/** MusicMode.PLAYLISTS */
const val MUSIC_MODE_PLAYLISTS = 0xA107
/** Sort.ByName */
const val SORT_BY_NAME = 0xA10C
/** Sort.ByArtist */

View file

@ -149,7 +149,7 @@ class GenreDetailFragment :
override fun onOpenMenu(item: Music, anchor: View) {
when (item) {
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
else -> error("Unexpected datatype: ${item::class.simpleName}")
}

View file

@ -46,10 +46,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.home.list.AlbumListFragment
import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.list.*
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionFragment
@ -278,16 +275,8 @@ class HomeFragment :
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
// Disallow sorting by album for albums
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
// Only allow sorting by name, count, and duration for artists
MusicMode.ARTISTS -> { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_dec ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
// Only allow sorting by name, count, and duration for genres
MusicMode.GENRES -> { id ->
// Only allow sorting by name, count, and duration for parents
else -> { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_dec ||
id == R.id.option_sort_name ||
@ -325,6 +314,7 @@ class HomeFragment :
MusicMode.ALBUMS -> R.id.home_album_recycler
MusicMode.ARTISTS -> R.id.home_artist_recycler
MusicMode.GENRES -> R.id.home_genre_recycler
MusicMode.PLAYLISTS -> R.id.home_playlist_recycler
}
}
@ -497,6 +487,7 @@ class HomeFragment :
MusicMode.ALBUMS -> AlbumListFragment()
MusicMode.ARTISTS -> ArtistListFragment()
MusicMode.GENRES -> GenreListFragment()
MusicMode.PLAYLISTS -> PlaylistListFragment()
}
}

View file

@ -24,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -64,10 +65,29 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override val shouldHideCollaborators: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
override fun migrate() {
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
val oldTabs =
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
// Add the new playlist tab to old tab configurations
val correctedTabs = oldTabs + Tab.Visible(MusicMode.PLAYLISTS)
sharedPreferences.edit {
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(correctedTabs))
remove(OLD_KEY_LIB_TABS)
}
}
}
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
when (key) {
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
}
}
companion object {
const val OLD_KEY_LIB_TABS = "auxio_lib_tabs"
}
}

View file

@ -87,6 +87,15 @@ constructor(
val genresInstructions: Event<UpdateInstructions>
get() = _genresInstructions
private val _playlistsList = MutableStateFlow(listOf<Playlist>())
/** A list of [Playlist]s, sorted by the preferred [Sort], to be shown in the home view. */
val playlistsList: StateFlow<List<Playlist>>
get() = _playlistsList
private val _playlistsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genresList] in the UI. */
val playlistsInstructions: Event<UpdateInstructions>
get() = _playlistsInstructions
/** The [MusicMode] to use when playing a [Song] from the UI. */
val playbackMode: MusicMode
get() = playbackSettings.inListPlaybackMode
@ -127,26 +136,34 @@ constructor(
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.library) return
val library = musicRepository.library ?: return
logD("Library changed, refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
_songsInstructions.put(UpdateInstructions.Diff)
_songsList.value = musicSettings.songSort.songs(library.songs)
_albumsInstructions.put(UpdateInstructions.Diff)
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
_artistsInstructions.put(UpdateInstructions.Diff)
_artistsList.value =
musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
// Hide Collaborators is enabled, filter out collaborators.
library.artists.filter { !it.isCollaborator }
} else {
library.artists
})
_genresInstructions.put(UpdateInstructions.Diff)
_genresList.value = musicSettings.genreSort.genres(library.genres)
val library = musicRepository.library
if (changes.library && library != null) {
logD("Refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
_songsInstructions.put(UpdateInstructions.Diff)
_songsList.value = musicSettings.songSort.songs(library.songs)
_albumsInstructions.put(UpdateInstructions.Diff)
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
_artistsInstructions.put(UpdateInstructions.Diff)
_artistsList.value =
musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
// Hide Collaborators is enabled, filter out collaborators.
library.artists.filter { !it.isCollaborator }
} else {
library.artists
})
_genresInstructions.put(UpdateInstructions.Diff)
_genresList.value = musicSettings.genreSort.genres(library.genres)
}
val playlists = musicRepository.playlists
if (changes.playlists && playlists != null) {
logD("Refreshing playlists")
_playlistsInstructions.put(UpdateInstructions.Diff)
_playlistsList.value = musicSettings.playlistSort.playlists(playlists)
}
}
override fun onTabsChanged() {
@ -173,6 +190,7 @@ constructor(
MusicMode.ALBUMS -> musicSettings.albumSort
MusicMode.ARTISTS -> musicSettings.artistSort
MusicMode.GENRES -> musicSettings.genreSort
MusicMode.PLAYLISTS -> musicSettings.playlistSort
}
/**
@ -204,6 +222,11 @@ constructor(
_genresInstructions.put(UpdateInstructions.Replace(0))
_genresList.value = sort.genres(_genresList.value)
}
MusicMode.PLAYLISTS -> {
musicSettings.playlistSort = sort
_playlistsInstructions.put(UpdateInstructions.Replace(0))
_playlistsList.value = sort.playlists(_playlistsList.value)
}
}
}

View file

@ -115,7 +115,7 @@ class ArtistListFragment :
}
override fun onOpenMenu(item: Artist, anchor: View) {
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
private fun updateArtists(artists: List<Artist>) {

View file

@ -114,7 +114,7 @@ class GenreListFragment :
}
override fun onOpenMenu(item: Genre, anchor: View) {
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
private fun updateGenres(genres: List<Genre>) {

View file

@ -0,0 +1,141 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistListFragment.kt is part of Auxio.
*
* 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.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
class PlaylistListFragment :
ListFragment<Playlist, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val playlistAdapter = PlaylistAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply {
id = R.id.home_playlist_recycler
adapter = playlistAdapter
popupProvider = this@PlaylistListFragment
listener = this@PlaylistListFragment
}
collectImmediately(homeModel.playlistsList, ::updatePlaylists)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
super.onDestroyBinding(binding)
binding.homeRecycler.apply {
adapter = null
popupProvider = null
listener = null
}
}
override fun getPopup(pos: Int): String? {
val playlist = homeModel.playlistsList.value[pos]
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> playlist.sortName?.thumbString
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
// Count -> Use song count
is Sort.Mode.ByCount -> playlist.songs.size.toString()
// Unsupported sort, error gracefully
else -> null
}
}
override fun onFastScrollingChanged(isFastScrolling: Boolean) {
homeModel.setFastScrolling(isFastScrolling)
}
override fun onRealClick(item: Playlist) {
navModel.exploreNavigateTo(item)
}
override fun onOpenMenu(item: Playlist, anchor: View) {
openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
private fun updatePlaylists(playlists: List<Playlist>) {
playlistAdapter.update(
playlists, homeModel.playlistsInstructions.consume().also { logD(it) })
}
private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
// If a playlist is playing, highlight it within this adapter.
playlistAdapter.setPlaying(parent as? Playlist, isPlaying)
}
/**
* A [SelectionIndicatorAdapter] that shows a list of [Playlist]s using [PlaylistViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class PlaylistAdapter(private val listener: SelectableListListener<Playlist>) :
SelectionIndicatorAdapter<Playlist, PlaylistViewHolder>(PlaylistViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistViewHolder.from(parent)
override fun onBindViewHolder(holder: PlaylistViewHolder, position: Int) {
holder.bind(getItem(position), listener)
}
}
}

View file

@ -58,6 +58,10 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
icon = R.drawable.ic_genre_24
string = R.string.lbl_genres
}
MusicMode.PLAYLISTS -> {
icon = R.drawable.ic_playlist_24
string = R.string.lbl_playlists
}
}
// Use expected sw* size thresholds when choosing a configuration.

View file

@ -49,7 +49,7 @@ sealed class Tab(open val mode: MusicMode) {
//
// 0bTAB1_TAB2_TAB3_TAB4_TAB5
//
// Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists.
// Where TABN is a chunk representing a tab at position N.
// Each chunk in a sequence is represented as:
//
// VTTT
@ -57,18 +57,23 @@ sealed class Tab(open val mode: MusicMode) {
// Where V is a bit representing the visibility and T is a 3-bit integer representing the
// MusicMode for this tab.
/** The length a well-formed tab sequence should be. */
private const val SEQUENCE_LEN = 4
/** The maximum index that a well-formed tab sequence should be. */
private const val MAX_SEQUENCE_IDX = 4
/**
* The default tab sequence, in integer form. This represents a set of four visible tabs
* ordered as "Song", "Album", "Artist", and "Genre".
* ordered as "Song", "Album", "Artist", "Genre", and "Playlists
*/
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_1100
/** Maps between the integer code in the tab sequence and it's [MusicMode]. */
private val MODE_TABLE =
arrayOf(MusicMode.SONGS, MusicMode.ALBUMS, MusicMode.ARTISTS, MusicMode.GENRES)
arrayOf(
MusicMode.SONGS,
MusicMode.ALBUMS,
MusicMode.ARTISTS,
MusicMode.GENRES,
MusicMode.PLAYLISTS)
/**
* Convert an array of [Tab]s into it's integer representation.
@ -81,7 +86,7 @@ sealed class Tab(open val mode: MusicMode) {
val distinct = tabs.distinctBy { it.mode }
var sequence = 0b0100
var shift = SEQUENCE_LEN * 4
var shift = MAX_SEQUENCE_IDX * 4
for (tab in distinct) {
val bin =
when (tab) {
@ -107,9 +112,8 @@ sealed class Tab(open val mode: MusicMode) {
// Try to parse a mode for each chunk in the sequence.
// If we can't parse one, just skip it.
for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
for (shift in (0..MAX_SEQUENCE_IDX * 4).reversed() step 4) {
val chunk = intCode.shr(shift) and 0b1111
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue
// Figure out the visibility
@ -125,7 +129,7 @@ sealed class Tab(open val mode: MusicMode) {
val distinct = tabs.distinctBy { it.mode }
// For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < SEQUENCE_LEN) {
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
logE("Sequence size was ${distinct.size}, which is invalid")
return null
}

View file

@ -110,6 +110,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
MusicMode.ALBUMS -> R.string.lbl_albums
MusicMode.ARTISTS -> R.string.lbl_artists
MusicMode.GENRES -> R.string.lbl_genres
MusicMode.PLAYLISTS -> R.string.lbl_playlists
})
// Unlike in other adapters, we update the checked state alongside

View file

@ -30,10 +30,7 @@ import androidx.annotation.AttrRes
import androidx.core.view.updateMarginsRelative
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels
@ -177,6 +174,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) = innerImageView.bind(genre)
/**
* Bind a [Playlist]'s image to the internal [StyledImageView].
*
* @param playlist the [Playlist] to bind.
* @see StyledImageView.bind
*/
fun bind(playlist: Playlist) = innerImageView.bind(playlist)
/**
* Whether this view should be indicated to have ongoing playback or not. See
* PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this

View file

@ -46,7 +46,8 @@ class CoilModule {
songFactory: AlbumCoverFetcher.SongFactory,
albumFactory: AlbumCoverFetcher.AlbumFactory,
artistFactory: ArtistImageFetcher.Factory,
genreFactory: GenreImageFetcher.Factory
genreFactory: GenreImageFetcher.Factory,
playlistFactory: PlaylistImageFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
@ -56,6 +57,7 @@ class CoilModule {
add(albumFactory)
add(artistFactory)
add(genreFactory)
add(playlistFactory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -38,11 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
@ -123,6 +119,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
/**
* Bind a [Playlist]'s image to this view, also updating the content description.
*
* @param playlist the [Playlist] to bind.
*/
fun bind(playlist: Playlist) =
bindImpl(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image)
/**
* Internally bind a [Music]'s image to this view.
*

View file

@ -33,11 +33,7 @@ import kotlin.math.min
import okio.buffer
import okio.source
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
/**
* A [Keyer] implementation for [Music] data.
@ -74,14 +70,12 @@ private constructor(
dataSource = DataSource.DISK)
}
/** A [Fetcher.Factory] implementation that works with [Song]s. */
class SongFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, coverExtractor, data.album)
}
/** A [Fetcher.Factory] implementation that works with [Album]s. */
class AlbumFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Album> {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
@ -108,7 +102,6 @@ private constructor(
return Images.createMosaic(context, results, size)
}
/** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Artist> {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
@ -133,7 +126,6 @@ private constructor(
return Images.createMosaic(context, results, size)
}
/** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Genre> {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
@ -141,6 +133,30 @@ private constructor(
}
}
/**
* [Fetcher] for [Playlist] images. Use [Factory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistImageFetcher
private constructor(
private val context: Context,
private val extractor: CoverExtractor,
private val size: Size,
private val playlist: Playlist
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val results = playlist.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
return Images.createMosaic(context, results, size)
}
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Playlist> {
override fun create(data: Playlist, options: Options, imageLoader: ImageLoader) =
PlaylistImageFetcher(options.context, extractor, options.size, data)
}
}
/**
* Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be
* transformed into [R].

View file

@ -217,6 +217,40 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
}
}
/**
* Opens a menu in the context of a [Playlist]. This menu will be managed by the Fragment and
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
*
* @param anchor The [View] to anchor the menu to.
* @param menuRes The resource of the menu to load.
* @param genre The [Playlist] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Playlist) {
logD("Launching new genre menu: ${genre.rawName}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
R.id.action_play -> {
// playbackModel.play(genre)
}
R.id.action_shuffle -> {
// playbackModel.shuffle(genre)
}
R.id.action_play_next -> {
// playbackModel.playNext(genre)
// requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_queue_add -> {
// playbackModel.addToQueue(genre)
// requireContext().showToast(R.string.lng_queue_added)
}
else -> {
error("Unexpected menu item selected")
}
}
}
}
private fun openMusicMenuImpl(
anchor: View,
@MenuRes menuRes: Int,

View file

@ -102,41 +102,37 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
/**
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
* Sort a list of [Playlist]s.
*
* @param songs The [Song]s to sort.
* @param playlists The list of [Playlist]s.
* @return A new list of [Playlist]s sorted by this [Sort]'s configuration
*/
fun <T : Playlist> playlists(playlists: Collection<T>): List<T> {
val mutable = playlists.toMutableList()
playlistsInPlace(mutable)
return mutable
}
private fun songsInPlace(songs: MutableList<out Song>) {
songs.sortWith(mode.getSongComparator(direction))
}
/**
* Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration.
*
* @param albums The [Album]s to sort.
*/
private fun albumsInPlace(albums: MutableList<out Album>) {
albums.sortWith(mode.getAlbumComparator(direction))
}
/**
* Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration.
*
* @param artists The [Album]s to sort.
*/
private fun artistsInPlace(artists: MutableList<out Artist>) {
artists.sortWith(mode.getArtistComparator(direction))
}
/**
* Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration.
*
* @param genres The [Genre]s to sort.
*/
private fun genresInPlace(genres: MutableList<out Genre>) {
genres.sortWith(mode.getGenreComparator(direction))
}
private fun playlistsInPlace(playlists: MutableList<out Playlist>) {
playlists.sortWith(mode.getPlaylistComparator(direction))
}
/**
* The integer representation of this instance.
*
@ -200,6 +196,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
throw UnsupportedOperationException()
}
/**
* Return a [Comparator] that sorts [Playlist]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
*/
open fun getPlaylistComparator(direction: Direction): Comparator<Playlist> {
throw UnsupportedOperationException()
}
/**
* Sort by the item's name.
*
@ -223,12 +229,15 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.GENRE)
override fun getPlaylistComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.PLAYLIST)
}
/**
* Sort by the [Album] of an item. Only available for [Song]s.
*
* @see Album.collationKey
* @see Album.sortName
*/
object ByAlbum : Mode() {
override val intCode: Int
@ -324,6 +333,11 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator(
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.GENRE))
override fun getPlaylistComparator(direction: Direction): Comparator<Playlist> =
MultiComparator(
compareByDynamic(direction) { it.durationMs },
compareBy(BasicComparator.PLAYLIST))
}
/**
@ -350,6 +364,11 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator(
compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.GENRE))
override fun getPlaylistComparator(direction: Direction): Comparator<Playlist> =
MultiComparator(
compareByDynamic(direction) { it.songs.size },
compareBy(BasicComparator.PLAYLIST))
}
/**
@ -555,6 +574,8 @@ data class Sort(val mode: Mode, val direction: Direction) {
val ARTIST: Comparator<Artist> = BasicComparator()
/** A re-usable instance configured for [Genre]s. */
val GENRE: Comparator<Genre> = BasicComparator()
/** A re-usable instance configured for [Playlist]s. */
val PLAYLIST: Comparator<Playlist> = BasicComparator()
}
}

View file

@ -249,6 +249,60 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
oldItem.rawName == newItem.rawName &&
oldItem.artists.size == newItem.artists.size &&
oldItem.songs.size == newItem.songs.size
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Playlist]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param playlist The new [Playlist] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(playlist: Playlist, listener: SelectableListListener<Playlist>) {
listener.bind(playlist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(playlist)
binding.parentName.text = playlist.resolveName(binding.context)
binding.parentInfo.text =
binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying
}
override fun updateSelectionIndicator(isSelected: Boolean) {
binding.root.isActivated = isSelected
}
companion object {
/** Unique ID for this ViewHolder type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_PLAYLIST
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Playlist>() {
override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean =
oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
}
}

View file

@ -20,9 +20,7 @@ package org.oxycblt.auxio.music
import android.os.Build
/**
* Version-aware permission identifier for reading audio files.
*/
/** Version-aware permission identifier for reading audio files. */
val PERMISSION_READ_AUDIO =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_AUDIO
@ -32,25 +30,29 @@ val PERMISSION_READ_AUDIO =
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
* @param error If music loading has failed, the error that occurred will be here. Otherwise,
* it will be null.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Throwable?) : IndexingState
}
/**
* Represents the current progress of music loading.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingProgress {
@ -59,6 +61,7 @@ sealed interface IndexingProgress {
/**
* Songs are currently being loaded.
*
* @param current The current amount of songs loaded.
* @param total The projected total amount of songs.
*/
@ -67,6 +70,7 @@ sealed interface IndexingProgress {
/**
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoAudioPermissionException : Exception() {
@ -75,6 +79,7 @@ class NoAudioPermissionException : Exception() {
/**
* Thrown when no music was found.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoMusicException : Exception() {

View file

@ -370,7 +370,12 @@ interface Genre : MusicParent {
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Playlist : MusicParent
interface Playlist : MusicParent {
/** The albums indirectly linked to by the [Song]s of this [Playlist]. */
val albums: List<Album>
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
}
/**
* A black-box datatype for a variation of music names that is suitable for music-oriented sorting.

View file

@ -33,7 +33,9 @@ enum class MusicMode {
/** Configure with respect to [Artist] instances. */
ARTISTS,
/** Configure with respect to [Genre] instances. */
GENRES;
GENRES,
/** Configure with respect to [Playlist] instances. */
PLAYLISTS;
/**
* The integer representation of this instance.
@ -47,6 +49,7 @@ enum class MusicMode {
ALBUMS -> IntegerTable.MUSIC_MODE_ALBUMS
ARTISTS -> IntegerTable.MUSIC_MODE_ARTISTS
GENRES -> IntegerTable.MUSIC_MODE_GENRES
PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
}
companion object {
@ -63,6 +66,7 @@ enum class MusicMode {
IntegerTable.MUSIC_MODE_ALBUMS -> ALBUMS
IntegerTable.MUSIC_MODE_ARTISTS -> ARTISTS
IntegerTable.MUSIC_MODE_GENRES -> GENRES
IntegerTable.MUSIC_MODE_PLAYLISTS -> PLAYLISTS
else -> null
}
}

View file

@ -37,8 +37,8 @@ import org.oxycblt.auxio.util.logW
/**
* Primary manager of music information and loading.
*
* Music information is loaded in-memory by this repository using an [IndexingWorker].
* Changes in music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
* Music information is loaded in-memory by this repository using an [IndexingWorker]. Changes in
* music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@ -52,6 +52,7 @@ interface MusicRepository {
/**
* Add an [UpdateListener] to receive updates from this instance.
*
* @param listener The [UpdateListener] to add.
*/
fun addUpdateListener(listener: UpdateListener)
@ -59,12 +60,14 @@ interface MusicRepository {
/**
* Remove an [UpdateListener] such that it does not receive any further updates from this
* instance.
*
* @param listener The [UpdateListener] to remove.
*/
fun removeUpdateListener(listener: UpdateListener)
/**
* Add an [IndexingListener] to receive updates from this instance.
*
* @param listener The [UpdateListener] to add.
*/
fun addIndexingListener(listener: IndexingListener)
@ -72,6 +75,7 @@ interface MusicRepository {
/**
* Remove an [IndexingListener] such that it does not receive any further updates from this
* instance.
*
* @param listener The [IndexingListener] to remove.
*/
fun removeIndexingListener(listener: IndexingListener)
@ -79,13 +83,15 @@ interface MusicRepository {
/**
* Register an [IndexingWorker] to handle loading operations. Will do nothing if one is already
* registered.
*
* @param worker The [IndexingWorker] to register.
*/
fun registerWorker(worker: IndexingWorker)
/**
* Unregister an [IndexingWorker] and drop any work currently being done by it. Does nothing
* if given [IndexingWorker] is not the currently registered instance.
* Unregister an [IndexingWorker] and drop any work currently being done by it. Does nothing if
* given [IndexingWorker] is not the currently registered instance.
*
* @param worker The [IndexingWorker] to unregister.
*/
fun unregisterWorker(worker: IndexingWorker)
@ -93,62 +99,56 @@ interface MusicRepository {
/**
* Request that a music loading operation is started by the current [IndexingWorker]. Does
* nothing if one is not available.
*
* @param withCache Whether to load with the music cache or not.
*/
fun requestIndex(withCache: Boolean)
/**
* Load the music library. Any prior loads will be canceled.
*
* @param worker The [IndexingWorker] to perform the work with.
* @param withCache Whether to load with the music cache or not.
* @return The top-level music loading [Job] started.
*/
fun index(worker: IndexingWorker, withCache: Boolean): Job
/**
* A listener for changes to the stored music information.
*/
/** A listener for changes to the stored music information. */
interface UpdateListener {
/**
* Called when a change to the stored music information occurs.
*
* @param changes The [Changes] that have occured.
*/
fun onMusicChanges(changes: Changes)
}
/**
* Flags indicating which kinds of music information changed.
*
* @param library Whether the current [Library] has changed.
* @param playlists Whether the current [Playlist]s have changed.
*/
data class Changes(val library: Boolean, val playlists: Boolean)
/**
* A listener for events in the music loading process.
*/
/** A listener for events in the music loading process. */
interface IndexingListener {
/**
* Called when the music loading state changed.
*/
/** Called when the music loading state changed. */
fun onIndexingStateChanged()
}
/**
* A persistent worker that can load music in the background.
*/
/** A persistent worker that can load music in the background. */
interface IndexingWorker {
/**
* A [Context] required to read device storage
*/
/** A [Context] required to read device storage */
val context: Context
/**
* The [CoroutineScope] to perform coroutine music loading work on.
*/
/** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope
/**
* Request that the music loading process ([index]) should be started. Any prior
* loads should be canceled.
* Request that the music loading process ([index]) should be started. Any prior loads
* should be canceled.
*
* @param withCache Whether to use the music cache when loading.
*/
fun requestIndex(withCache: Boolean)
@ -301,6 +301,7 @@ constructor(
cacheRepository.writeCache(rawSongs)
}
val newLibrary = libraryJob.await()
// TODO: Make real playlist reading
withContext(Dispatchers.Main) {
emitComplete(null)
emitData(newLibrary, listOf())

View file

@ -54,6 +54,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
var artistSort: Sort
/** The [Sort] mode used in [Genre] lists. */
var genreSort: Sort
/** The [Sort] mode used in [Playlist] lists. */
var playlistSort: Sort
/** The [Sort] mode used in an [Album]'s [Song] list. */
var albumSongSort: Sort
/** The [Sort] mode used in an [Artist]'s [Song] list. */
@ -161,6 +163,17 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
}
}
override var playlistSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_playlists_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_playlists_sort), value.intCode)
apply()
}
}
override var albumSongSort: Sort
get() {
var sort =

View file

@ -30,4 +30,7 @@ class PlaylistImpl(rawPlaylist: RawPlaylist, library: Library, musicSettings: Mu
override val rawSortName = null
override val sortName = SortName(rawName, musicSettings)
override val songs = rawPlaylist.songs.mapNotNull { library.find<Song>(it.songUid) }
override val durationMs = songs.sumOf { it.durationMs }
override val albums =
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
}

View file

@ -178,6 +178,7 @@ constructor(
MusicMode.ALBUMS -> playImpl(song, song.album)
MusicMode.ARTISTS -> playFromArtist(song)
MusicMode.GENRES -> playFromGenre(song)
MusicMode.PLAYLISTS -> error("Playing from a playlist is not supported.")
}
}

View file

@ -148,9 +148,9 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
when (item) {
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item)
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
is Genre -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
is Playlist -> TODO("handle this")
is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
is Genre -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
is Playlist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
}

View file

@ -154,6 +154,7 @@ constructor(
MusicMode.ALBUMS -> R.id.option_filter_albums
MusicMode.ARTISTS -> R.id.option_filter_artists
MusicMode.GENRES -> R.id.option_filter_genres
MusicMode.PLAYLISTS -> R.id.option_filter_all // TODO: Handle
// Null maps to filtering nothing.
null -> R.id.option_filter_all
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M2.5,16V14H10.5V16ZM2.5,12V10H14.5V12ZM2.5,8V6H14.5V8ZM15.5,21V13L21.5,17Z"/>
</vector>

View file

@ -5,6 +5,7 @@
<item name="home_album_recycler" type="id" />
<item name="home_artist_recycler" type="id" />
<item name="home_genre_recycler" type="id" />
<item name="home_playlist_recycler" type="id" />
<integer name="anim_fade_enter_duration">200</integer>
<integer name="anim_fade_exit_duration">100</integer>

View file

@ -9,7 +9,7 @@
<string name="fmt_number" translatable="false">%d</string>
<string name="fmt_zipped_names" translatable="false">%1$s (%2$s)</string>
<string name="fmt_date_range" translatable="false">%s - %s</string>
<string name="fmt_path">%1$s/%2$s</string>
<string name="fmt_path" translatable="false">%1$s/%2$s</string>
<!-- Codec Namespace | Format names -->
<string name="cdc_vorbis">Vorbis</string>

View file

@ -35,7 +35,7 @@
<string name="set_key_wipe_state" translatable="false">auxio_wipe_state</string>
<string name="set_key_restore_state" translatable="false">auxio_restore_state</string>
<string name="set_key_home_tabs" translatable="false">auxio_lib_tabs</string>
<string name="set_key_home_tabs" translatable="false">auxio_home_tabs</string>
<string name="set_key_hide_collaborators" translatable="false">auxio_hide_collaborators</string>
<string name="set_key_round_mode" translatable="false">auxio_round_covers</string>
<string name="set_key_bar_action" translatable="false">auxio_bar_action</string>
@ -47,6 +47,7 @@
<string name="set_key_albums_sort" translatable="false">auxio_albums_sort</string>
<string name="set_key_artists_sort" translatable="false">auxio_artists_sort</string>
<string name="set_key_genres_sort" translatable="false">auxio_genres_sort</string>
<string name="set_key_playlists_sort" translatable="false">auxio_playlists_sort</string>
<string name="set_key_album_songs_sort" translatable="false">auxio_album_sort</string>
<string name="set_key_artist_songs_sort" translatable="false">auxio_artist_sort</string>

View file

@ -76,6 +76,9 @@
<string name="lbl_genre">Genre</string>
<string name="lbl_genres">Genres</string>
<string name="lbl_playlist">Playlist</string>
<string name="lbl_playlists">Playlists</string>
<!-- Search for music -->
<string name="lbl_search">Search</string>
<!-- As in filtering to particular types of music in the search view -->
@ -307,6 +310,7 @@
<string name="desc_album_cover">Album cover for %s</string>
<string name="desc_artist_image">Artist image for %s</string>
<string name="desc_genre_image">Genre image for %s</string>
<string name="desc_playlist_image">Playlist image for %s</string>
<!-- Default Namespace | Placeholder values -->
<eat-comment />