From 431dd4500d2be8571d716f43d35bbb16ca6f640b Mon Sep 17 00:00:00 2001 From: Sebastiano Barezzi Date: Wed, 30 Oct 2024 12:26:12 +0100 Subject: [PATCH] Twelve: Album disc header Change-Id: Ib0e193ed1b33301d7eda209f2e4becbc589c3db4 --- .../twelve/fragments/AlbumFragment.kt | 235 +++++++++++------- .../twelve/viewmodels/AlbumViewModel.kt | 67 +++++ app/src/main/res/layout/audio_track_index.xml | 25 ++ app/src/main/res/values/strings.xml | 4 + 4 files changed, 239 insertions(+), 92 deletions(-) create mode 100644 app/src/main/res/layout/audio_track_index.xml diff --git a/app/src/main/java/org/lineageos/twelve/fragments/AlbumFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/AlbumFragment.kt index a946bad3..fe8d0072 100644 --- a/app/src/main/java/org/lineageos/twelve/fragments/AlbumFragment.kt +++ b/app/src/main/java/org/lineageos/twelve/fragments/AlbumFragment.kt @@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView import coil3.load import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.lineageos.twelve.R @@ -34,7 +35,6 @@ import org.lineageos.twelve.ext.getParcelable import org.lineageos.twelve.ext.getViewProperty import org.lineageos.twelve.ext.setProgressCompat import org.lineageos.twelve.ext.updatePadding -import org.lineageos.twelve.models.Audio import org.lineageos.twelve.models.RequestStatus import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter import org.lineageos.twelve.ui.recyclerview.UniqueItemDiffCallback @@ -65,42 +65,90 @@ class AlbumFragment : Fragment(R.layout.fragment_album) { // Recyclerview private val adapter by lazy { - object : SimpleListAdapter( + object : SimpleListAdapter( UniqueItemDiffCallback(), ::ListItem, ) { + private val ViewHolder.trackTextView + get() = view.leadingView!!.findViewById(R.id.trackTextView) + override fun ViewHolder.onPrepareView() { - view.setLeadingIconImage(R.drawable.ic_music_note) + view.setLeadingView(R.layout.audio_track_index) + view.setOnClickListener { - item?.let { - viewModel.playAudio(currentList, bindingAdapterPosition) + when (val item = item) { + is AlbumViewModel.AlbumContent.AudioItem -> { + val audios = currentList.mapNotNull { + (it as? AlbumViewModel.AlbumContent.AudioItem)?.audio + } - findNavController().navigate( - R.id.action_albumFragment_to_fragment_now_playing - ) + viewModel.playAudio(audios, audios.indexOf(item.audio)) + + findNavController().navigate( + R.id.action_albumFragment_to_fragment_now_playing + ) + } + + else -> {} } } + view.setOnLongClickListener { - item?.let { - findNavController().navigate( - R.id.action_albumFragment_to_fragment_audio_bottom_sheet_dialog, - AudioBottomSheetDialogFragment.createBundle( - it.uri, - fromAlbum = true, + when (val item = item) { + is AlbumViewModel.AlbumContent.AudioItem -> { + findNavController().navigate( + R.id.action_albumFragment_to_fragment_audio_bottom_sheet_dialog, + AudioBottomSheetDialogFragment.createBundle( + item.audio.uri, + fromAlbum = true, + ) ) - ) - } - true + true + } + + else -> false + } } } - override fun ViewHolder.onBindView(item: Audio) { - view.headlineText = item.title - view.supportingText = item.artistName - view.trailingSupportingText = TimestampFormatter.formatTimestampMillis( - item.durationMs - ) + override fun ViewHolder.onBindView(item: AlbumViewModel.AlbumContent) { + when (item) { + is AlbumViewModel.AlbumContent.DiscHeader -> { + view.setLeadingIconImage(R.drawable.ic_album) + view.leadingViewIsVisible = false + view.setHeadlineText( + R.string.album_disc_header, + item.discNumber, + ) + view.supportingText = null + view.trailingSupportingText = null + view.isClickable = false + view.isLongClickable = false + } + + is AlbumViewModel.AlbumContent.AudioItem -> { + item.audio.trackNumber?.also { + view.leadingIconImage = null + trackTextView.text = getString( + R.string.track_number, + it + ) + view.leadingViewIsVisible = true + } ?: run { + view.setLeadingIconImage(R.drawable.ic_music_note) + view.leadingViewIsVisible = false + } + + view.headlineText = item.audio.title + view.supportingText = item.audio.artistName + view.trailingSupportingText = TimestampFormatter.formatTimestampMillis( + item.audio.durationMs + ) + view.isClickable = true + view.isLongClickable = true + } + } } } } @@ -187,84 +235,87 @@ class AlbumFragment : Fragment(R.layout.fragment_album) { } private suspend fun loadData() { - viewModel.album.collectLatest { - linearProgressIndicator.setProgressCompat(it, true) - - when (it) { - is RequestStatus.Loading -> { - // Do nothing - } - - is RequestStatus.Success -> { - val (album, audios) = it.data + coroutineScope { + launch { + viewModel.album.collectLatest { + linearProgressIndicator.setProgressCompat(it, true) + + when (it) { + is RequestStatus.Loading -> { + // Do nothing + } + + is RequestStatus.Success -> { + val (album, audios) = it.data + + toolbar.title = album.title + albumTitleTextView.text = album.title + + album.thumbnail?.uri?.also { uri -> + thumbnailImageView.load(uri) + } ?: album.thumbnail?.bitmap?.also { bitmap -> + thumbnailImageView.load(bitmap) + } ?: thumbnailImageView.setImageResource(R.drawable.ic_album) + + artistNameTextView.text = album.artistName + artistNameTextView.setOnClickListener { + findNavController().navigate( + R.id.action_albumFragment_to_fragment_artist, + ArtistFragment.createBundle(album.artistUri) + ) + } + + album.year?.also { year -> + yearTextView.isVisible = true + yearTextView.text = year.toString() + } ?: run { + yearTextView.isVisible = false + } + + val totalDurationMs = audios.sumOf { audio -> + audio.durationMs + } + val totalDurationMinutes = totalDurationMs / 1000 / 60 + + val tracksCount = resources.getQuantityString( + R.plurals.tracks_count, + audios.size, + audios.size + ) + val tracksDuration = resources.getQuantityString( + R.plurals.tracks_duration, + totalDurationMinutes, + totalDurationMinutes + ) + tracksInfoTextView.text = getString( + R.string.tracks_info, + tracksCount, tracksDuration + ) + } - toolbar.title = album.title - albumTitleTextView.text = album.title + is RequestStatus.Error -> { + Log.e(LOG_TAG, "Error loading album, error: ${it.type}") - album.thumbnail?.uri?.also { uri -> - thumbnailImageView.load(uri) - } ?: album.thumbnail?.bitmap?.also { bitmap -> - thumbnailImageView.load(bitmap) - } ?: thumbnailImageView.setImageResource(R.drawable.ic_album) + toolbar.title = "" + albumTitleTextView.text = "" - artistNameTextView.text = album.artistName - artistNameTextView.setOnClickListener { - findNavController().navigate( - R.id.action_albumFragment_to_fragment_artist, - ArtistFragment.createBundle(album.artistUri) - ) + if (it.type == RequestStatus.Error.Type.NOT_FOUND) { + // Get out of here + findNavController().navigateUp() + } + } } + } + } - album.year?.also { year -> - yearTextView.isVisible = true - yearTextView.text = year.toString() - } ?: run { - yearTextView.isVisible = false - } + launch { + viewModel.albumContent.collectLatest { + adapter.submitList(it) - val totalDurationMs = audios.sumOf { audio -> - audio.durationMs - } - val totalDurationMinutes = totalDurationMs / 1000 / 60 - - val tracksCount = resources.getQuantityString( - R.plurals.tracks_count, - audios.size, - audios.size - ) - val tracksDuration = resources.getQuantityString( - R.plurals.tracks_duration, - totalDurationMinutes, - totalDurationMinutes - ) - tracksInfoTextView.text = getString( - R.string.tracks_info, - tracksCount, tracksDuration - ) - - adapter.submitList(audios) - - val isEmpty = audios.isEmpty() + val isEmpty = it.isEmpty() recyclerView.isVisible = !isEmpty noElementsNestedScrollView.isVisible = isEmpty } - - is RequestStatus.Error -> { - Log.e(LOG_TAG, "Error loading album, error: ${it.type}") - - toolbar.title = "" - albumTitleTextView.text = "" - - adapter.submitList(listOf()) - - recyclerView.isVisible = false - noElementsNestedScrollView.isVisible = true - - if (it.type == RequestStatus.Error.Type.NOT_FOUND) { - // Get out of here - findNavController().navigateUp() - } - } } } } diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumViewModel.kt index 8a991205..1a710369 100644 --- a/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumViewModel.kt +++ b/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumViewModel.kt @@ -12,11 +12,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn +import org.lineageos.twelve.models.Audio import org.lineageos.twelve.models.RequestStatus +import org.lineageos.twelve.models.UniqueItem +import kotlin.reflect.safeCast class AlbumViewModel(application: Application) : TwelveViewModel(application) { private val albumUri = MutableStateFlow(null) @@ -34,6 +39,68 @@ class AlbumViewModel(application: Application) : TwelveViewModel(application) { RequestStatus.Loading() ) + sealed interface AlbumContent : UniqueItem { + data class DiscHeader(val discNumber: Int) : AlbumContent { + override fun areItemsTheSame(other: AlbumContent) = + DiscHeader::class.safeCast(other)?.let { + discNumber == it.discNumber + } ?: false + + override fun areContentsTheSame(other: AlbumContent) = true + } + + class AudioItem(val audio: Audio) : AlbumContent { + override fun areItemsTheSame(other: AlbumContent) = AudioItem::class.safeCast( + other + )?.let { + audio.areItemsTheSame(it.audio) + } ?: false + + override fun areContentsTheSame(other: AlbumContent) = AudioItem::class.safeCast( + other + )?.let { + audio.areContentsTheSame(it.audio) + } ?: false + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val albumContent = album + .mapLatest { + when (it) { + is RequestStatus.Loading -> null + + is RequestStatus.Success -> { + val discToTracks = it.data.second.groupBy { audio -> + audio.discNumber ?: 1 + } + + mutableListOf().apply { + discToTracks.keys.sorted().forEach { discNumber -> + add(AlbumContent.DiscHeader(discNumber)) + + discToTracks[discNumber]?.let { tracks -> + addAll( + tracks.map { audio -> + AlbumContent.AudioItem(audio) + } + ) + } + } + }.toList() + } + + is RequestStatus.Error -> listOf() + } + } + .filterNotNull() + .flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + listOf() + ) + fun loadAlbum(albumUri: Uri) { this.albumUri.value = albumUri } diff --git a/app/src/main/res/layout/audio_track_index.xml b/app/src/main/res/layout/audio_track_index.xml new file mode 100644 index 00000000..616563ae --- /dev/null +++ b/app/src/main/res/layout/audio_track_index.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b4f3dfb..cc390085 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,4 +143,8 @@ Title Artist Album + + + Disc %1$d + %1$d