Twelve: Album disc header

Change-Id: Ib0e193ed1b33301d7eda209f2e4becbc589c3db4
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 a946bad..fe8d007 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 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.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 @@
 
     // Recyclerview
     private val adapter by lazy {
-        object : SimpleListAdapter<Audio, ListItem>(
+        object : SimpleListAdapter<AlbumViewModel.AlbumContent, ListItem>(
             UniqueItemDiffCallback(),
             ::ListItem,
         ) {
-            override fun ViewHolder.onPrepareView() {
-                view.setLeadingIconImage(R.drawable.ic_music_note)
-                view.setOnClickListener {
-                    item?.let {
-                        viewModel.playAudio(currentList, bindingAdapterPosition)
+            private val ViewHolder.trackTextView
+                get() = view.leadingView!!.findViewById<TextView>(R.id.trackTextView)
 
-                        findNavController().navigate(
-                            R.id.action_albumFragment_to_fragment_now_playing
-                        )
+            override fun ViewHolder.onPrepareView() {
+                view.setLeadingView(R.layout.audio_track_index)
+
+                view.setOnClickListener {
+                    when (val item = item) {
+                        is AlbumViewModel.AlbumContent.AudioItem -> {
+                            val audios = currentList.mapNotNull {
+                                (it as? AlbumViewModel.AlbumContent.AudioItem)?.audio
+                            }
+
+                            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,
-                            )
-                        )
-                    }
 
-                    true
+                view.setOnLongClickListener {
+                    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
+                        }
+
+                        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 @@
     }
 
     private suspend fun loadData() {
-        viewModel.album.collectLatest {
-            linearProgressIndicator.setProgressCompat(it, true)
+        coroutineScope {
+            launch {
+                viewModel.album.collectLatest {
+                    linearProgressIndicator.setProgressCompat(it, true)
 
-            when (it) {
-                is RequestStatus.Loading -> {
-                    // Do nothing
+                    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
+                            )
+                        }
+
+                        is RequestStatus.Error -> {
+                            Log.e(LOG_TAG, "Error loading album, error: ${it.type}")
+
+                            toolbar.title = ""
+                            albumTitleTextView.text = ""
+
+                            if (it.type == RequestStatus.Error.Type.NOT_FOUND) {
+                                // Get out of here
+                                findNavController().navigateUp()
+                            }
+                        }
+                    }
                 }
+            }
 
-                is RequestStatus.Success -> {
-                    val (album, audios) = it.data
+            launch {
+                viewModel.albumContent.collectLatest {
+                    adapter.submitList(it)
 
-                    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
-                    )
-
-                    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 8a99120..1a71036 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.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<Uri?>(null)
@@ -34,6 +39,68 @@
             RequestStatus.Loading()
         )
 
+    sealed interface AlbumContent : UniqueItem<AlbumContent> {
+        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<AlbumContent>().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 0000000..616563a
--- /dev/null
+++ b/app/src/main/res/layout/audio_track_index.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    style="@style/Widget.Material3.CardView.Filled"
+    android:layout_width="24dp"
+    android:layout_height="24dp"
+    app:cardBackgroundColor="?attr/colorPrimaryContainer"
+    app:cardCornerRadius="4dp">
+
+    <TextView
+        android:id="@+id/trackTextView"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:gravity="center"
+        android:textAlignment="gravity"
+        android:textAppearance="?attr/textAppearanceLabelMedium"
+        android:textColor="?attr/colorOnPrimaryContainer"
+        tools:text="99" />
+
+</com.google.android.material.card.MaterialCardView>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2b4f3df..cc39008 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -143,4 +143,8 @@
     <string name="now_playing_widget_dummy_title">Title</string>
     <string name="now_playing_widget_dummy_artist">Artist</string>
     <string name="now_playing_widget_dummy_album">Album</string>
+
+    <!-- Album fragment -->
+    <string name="album_disc_header">Disc %1$d</string>
+    <string name="track_number" translatable="false">%1$d</string>
 </resources>