Twelve: Queue fragment

Change-Id: Ib5e1b1c7659e941fd6dee9c023b45f47591b2d2f
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Player.kt b/app/src/main/java/org/lineageos/twelve/ext/Player.kt
index b7e525b..0a711d0 100644
--- a/app/src/main/java/org/lineageos/twelve/ext/Player.kt
+++ b/app/src/main/java/org/lineageos/twelve/ext/Player.kt
@@ -10,6 +10,7 @@
 import androidx.media3.common.MediaMetadata
 import androidx.media3.common.PlaybackParameters
 import androidx.media3.common.Player
+import androidx.media3.common.Timeline
 import androidx.media3.common.Tracks
 import androidx.media3.common.util.UnstableApi
 import kotlinx.coroutines.channels.awaitClose
@@ -152,6 +153,35 @@
     }
 }
 
+fun Player.queueFlow() = conflatedCallbackFlow {
+    val emitQueue = {
+        val currentMediaItemIndex = currentMediaItemIndex
+
+        trySend(
+            mediaItems.mapIndexed { index, mediaItem ->
+                mediaItem to (index == currentMediaItemIndex)
+            }
+        )
+    }
+
+    val listener = object : Player.Listener {
+        override fun onTimelineChanged(timeline: Timeline, reason: Int) {
+            emitQueue()
+        }
+
+        override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+            emitQueue()
+        }
+    }
+
+    addListener(listener)
+    emitQueue()
+
+    awaitClose {
+        removeListener(listener)
+    }
+}
+
 var Player.typedRepeatMode: RepeatMode
     get() = when (repeatMode) {
         Player.REPEAT_MODE_OFF -> RepeatMode.NONE
@@ -175,3 +205,8 @@
         Player.STATE_ENDED -> PlaybackState.ENDED
         else -> throw Exception("Unknown playback state")
     }
+
+val Player.mediaItems: List<MediaItem>
+    get() = (0 until mediaItemCount).map {
+        getMediaItemAt(it)
+    }
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
index a2f8a1d..fc47470 100644
--- a/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
+++ b/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
@@ -75,13 +75,13 @@
     private val fileTypeMaterialCardView by getViewProperty<MaterialCardView>(R.id.fileTypeMaterialCardView)
     private val fileTypeTextView by getViewProperty<TextView>(R.id.fileTypeTextView)
     private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
-    private val moreMaterialButton by getViewProperty<MaterialButton>(R.id.moreMaterialButton)
     private val nestedScrollView by getViewProperty<NestedScrollView>(R.id.nestedScrollView)
     private val nextTrackMaterialButton by getViewProperty<MaterialButton>(R.id.nextTrackMaterialButton)
     private val playPauseMaterialButton by getViewProperty<MaterialButton>(R.id.playPauseMaterialButton)
     private val playbackSpeedMaterialButton by getViewProperty<MaterialButton>(R.id.playbackSpeedMaterialButton)
     private val previousTrackMaterialButton by getViewProperty<MaterialButton>(R.id.previousTrackMaterialButton)
     private val progressSlider by getViewProperty<Slider>(R.id.progressSlider)
+    private val queueMaterialButton by getViewProperty<MaterialButton>(R.id.queueMaterialButton)
     private val repeatMarkerImageView by getViewProperty<ImageView>(R.id.repeatMarkerImageView)
     private val repeatMaterialButton by getViewProperty<MaterialButton>(R.id.repeatMaterialButton)
     private val shuffleMarkerImageView by getViewProperty<ImageView>(R.id.shuffleMarkerImageView)
@@ -281,6 +281,10 @@
             true
         }
 
+        queueMaterialButton.setOnClickListener {
+            findNavController().navigate(R.id.action_nowPlayingFragment_to_fragment_queue)
+        }
+
         viewLifecycleOwner.lifecycleScope.launch {
             viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                 launch {
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/QueueFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/QueueFragment.kt
new file mode 100644
index 0000000..e26c0db
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/QueueFragment.kt
@@ -0,0 +1,186 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.view.View
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.widget.NestedScrollView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.media3.common.MediaItem
+import androidx.media3.common.util.UnstableApi
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.ui.setupWithNavController
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.appbar.MaterialToolbar
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.getViewProperty
+import org.lineageos.twelve.ext.updatePadding
+import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter
+import org.lineageos.twelve.ui.views.ListItem
+import org.lineageos.twelve.utils.TimestampFormatter
+import org.lineageos.twelve.viewmodels.QueueViewModel
+import java.util.Collections
+
+/**
+ * Playback service queue.
+ */
+@androidx.annotation.OptIn(UnstableApi::class)
+class QueueFragment : Fragment(R.layout.fragment_queue) {
+    // View models
+    private val viewModel by viewModels<QueueViewModel>()
+
+    // Views
+    private val noElementsNestedScrollView by getViewProperty<NestedScrollView>(R.id.noElementsNestedScrollView)
+    private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+    private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
+
+    // RecyclerView
+    private val adapter by lazy {
+        object : SimpleListAdapter<Pair<MediaItem, Boolean>, ListItem>(
+            diffCallback,
+            ::ListItem,
+        ) {
+            override fun ViewHolder.onPrepareView() {
+                view.setTrailingIconImage(R.drawable.ic_drag_handle)
+            }
+
+            override fun ViewHolder.onBindView(item: Pair<MediaItem, Boolean>) {
+                val (mediaItem, isCurrent) = item
+
+                view.setLeadingIconImage(
+                    when (isCurrent) {
+                        true -> R.drawable.ic_play_arrow
+                        false -> R.drawable.ic_music_note
+                    }
+                )
+                view.headlineText = mediaItem.mediaMetadata.title
+                view.supportingText = mediaItem.mediaMetadata.artist
+                view.trailingSupportingText = mediaItem.mediaMetadata.durationMs?.let {
+                    TimestampFormatter.formatTimestampMillis(it)
+                }
+
+                view.setOnClickListener {
+                    viewModel.playItem(bindingAdapterPosition)
+                }
+            }
+        }
+    }
+    private val itemTouchHelperCallback = object : ItemTouchHelper.SimpleCallback(
+        ItemTouchHelper.UP or ItemTouchHelper.DOWN,
+        ItemTouchHelper.START or ItemTouchHelper.END,
+    ) {
+        override fun onMove(
+            recyclerView: RecyclerView,
+            viewHolder: RecyclerView.ViewHolder,
+            target: RecyclerView.ViewHolder
+        ): Boolean {
+            val from = viewHolder.bindingAdapterPosition
+            val to = target.bindingAdapterPosition
+
+            // First update our adapter list
+            val list = adapter.currentList.toMutableList()
+            Collections.swap(list, from, to)
+            adapter.setListWithoutDiffing(list)
+            adapter.notifyItemMoved(from, to)
+
+            // Then update the queue
+            viewModel.moveItem(
+                viewHolder.bindingAdapterPosition,
+                target.bindingAdapterPosition
+            )
+
+            return true
+        }
+
+        override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+            viewModel.removeItem(viewHolder.bindingAdapterPosition)
+        }
+    }
+    private val itemTouchHelper = ItemTouchHelper(itemTouchHelperCallback)
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        // Insets
+        ViewCompat.setOnApplyWindowInsetsListener(toolbar) { v, windowInsets ->
+            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+            v.updatePadding(
+                insets,
+                start = true,
+                end = true,
+            )
+
+            windowInsets
+        }
+
+        ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, windowInsets ->
+            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+
+            v.updatePadding(
+                insets,
+                bottom = true,
+            )
+
+            windowInsets
+        }
+
+        ViewCompat.setOnApplyWindowInsetsListener(noElementsNestedScrollView) { v, windowInsets ->
+            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+
+            v.updatePadding(
+                insets,
+                bottom = true,
+            )
+
+            windowInsets
+        }
+
+        toolbar.setupWithNavController(findNavController())
+
+        recyclerView.adapter = adapter
+        itemTouchHelper.attachToRecyclerView(recyclerView)
+
+        viewLifecycleOwner.lifecycleScope.launch {
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                viewModel.queue.collectLatest {
+                    adapter.submitList(it)
+                }
+            }
+        }
+    }
+
+    override fun onDestroyView() {
+        itemTouchHelper.attachToRecyclerView(null)
+        recyclerView.adapter = null
+
+        super.onDestroyView()
+    }
+
+    companion object {
+        private val diffCallback = object : DiffUtil.ItemCallback<Pair<MediaItem, Boolean>>() {
+            override fun areItemsTheSame(
+                oldItem: Pair<MediaItem, Boolean>,
+                newItem: Pair<MediaItem, Boolean>,
+            ) = oldItem.first.mediaId == newItem.first.mediaId
+
+            override fun areContentsTheSame(
+                oldItem: Pair<MediaItem, Boolean>,
+                newItem: Pair<MediaItem, Boolean>,
+            ) = oldItem.first == newItem.first && oldItem.second == newItem.second
+        }
+    }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/QueueViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/QueueViewModel.kt
new file mode 100644
index 0000000..391fc48
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/QueueViewModel.kt
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import org.lineageos.twelve.ext.queueFlow
+
+class QueueViewModel(application: Application) : TwelveViewModel(application) {
+    @OptIn(ExperimentalCoroutinesApi::class)
+    val queue = mediaController
+        .filterNotNull()
+        .flatMapLatest { it.queueFlow() }
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            SharingStarted.WhileSubscribed(),
+            listOf()
+        )
+
+    fun moveItem(from: Int, to: Int) {
+        mediaController.value?.moveMediaItem(from, to)
+    }
+
+    fun removeItem(index: Int) {
+        mediaController.value?.removeMediaItem(index)
+    }
+
+    fun playItem(index: Int) {
+        mediaController.value?.apply {
+            seekTo(index, 0)
+            play()
+        }
+    }
+}
diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml
new file mode 100644
index 0000000..ca39e0c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_drag_handle.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+     SPDX-FileCopyrightText: Material Design Authors / Google LLC
+     SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="#000000"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M160,600L160,520L800,520L800,600L160,600ZM160,440L160,360L800,360L800,440L160,440Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml
deleted file mode 100644
index f6f9e7e..0000000
--- a/app/src/main/res/drawable/ic_more_vert.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-     SPDX-FileCopyrightText: Material Design Authors / Google LLC
-     SPDX-License-Identifier: Apache-2.0
--->
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:tint="#000000"
-    android:viewportWidth="960"
-    android:viewportHeight="960">
-
-    <path
-        android:fillColor="@android:color/white"
-        android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z" />
-
-</vector>
diff --git a/app/src/main/res/layout/fragment_queue.xml b/app/src/main/res/layout/fragment_queue.xml
new file mode 100644
index 0000000..e569543
--- /dev/null
+++ b/app/src/main/res/layout/fragment_queue.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:fitsSystemWindows="true">
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/appBarLayout"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:fitsSystemWindows="true">
+
+        <com.google.android.material.appbar.MaterialToolbar
+            android:id="@+id/toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content" />
+
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/recyclerView"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:clipToPadding="false"
+        android:orientation="vertical"
+        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+        app:layout_behavior="@string/appbar_scrolling_view_behavior"
+        app:spanCount="1" />
+
+    <androidx.core.widget.NestedScrollView
+        android:id="@+id/noElementsNestedScrollView"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:clipToPadding="false"
+        android:visibility="gone">
+
+        <LinearLayout
+            style="@style/Theme.Twelve.NoElements.LinearLayout"
+            android:layout_gravity="center">
+
+            <ImageView
+                style="@style/Theme.Twelve.NoElements.ImageView"
+                android:contentDescription="@string/no_audios"
+                android:src="@drawable/ic_queue_play_next" />
+
+            <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+            <TextView
+                style="@style/Theme.Twelve.NoElements.TextView"
+                android:text="@string/no_audios" />
+
+        </LinearLayout>
+
+    </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/now_playing_bottom_bar.xml b/app/src/main/res/layout/now_playing_bottom_bar.xml
index e632d4c..797a7e6 100644
--- a/app/src/main/res/layout/now_playing_bottom_bar.xml
+++ b/app/src/main/res/layout/now_playing_bottom_bar.xml
@@ -37,9 +37,9 @@
             app:icon="@drawable/ic_graphic_eq" />
 
         <com.google.android.material.button.MaterialButton
-            android:id="@+id/moreMaterialButton"
+            android:id="@+id/queueMaterialButton"
             style="@style/Theme.Twelve.NowPlayingFragment.BottomBarButton"
-            app:icon="@drawable/ic_more_vert" />
+            app:icon="@drawable/ic_queue_play_next" />
 
     </LinearLayout>
 
diff --git a/app/src/main/res/navigation/fragment_main.xml b/app/src/main/res/navigation/fragment_main.xml
index 4b5193e..2400573 100644
--- a/app/src/main/res/navigation/fragment_main.xml
+++ b/app/src/main/res/navigation/fragment_main.xml
@@ -19,6 +19,7 @@
     <include app:graph="@navigation/fragment_now_playing_stats_dialog" />
     <include app:graph="@navigation/fragment_playlist" />
     <include app:graph="@navigation/fragment_provider_selector_dialog" />
+    <include app:graph="@navigation/fragment_queue" />
 
     <fragment
         android:id="@+id/mainFragment"
diff --git a/app/src/main/res/navigation/fragment_now_playing.xml b/app/src/main/res/navigation/fragment_now_playing.xml
index b7736ef..fc8b867 100644
--- a/app/src/main/res/navigation/fragment_now_playing.xml
+++ b/app/src/main/res/navigation/fragment_now_playing.xml
@@ -30,6 +30,14 @@
             app:popEnterAnim="@anim/nav_default_pop_enter_anim"
             app:popExitAnim="@anim/nav_default_pop_exit_anim" />
 
+        <action
+            android:id="@+id/action_nowPlayingFragment_to_fragment_queue"
+            app:destination="@+id/fragment_queue"
+            app:enterAnim="@anim/nav_default_enter_anim"
+            app:exitAnim="@anim/nav_default_exit_anim"
+            app:popEnterAnim="@anim/nav_default_pop_enter_anim"
+            app:popExitAnim="@anim/nav_default_pop_exit_anim" />
+
     </fragment>
 
 </navigation>
diff --git a/app/src/main/res/navigation/fragment_queue.xml b/app/src/main/res/navigation/fragment_queue.xml
new file mode 100644
index 0000000..a15a1d4
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_queue.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<navigation xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/fragment_queue"
+    app:startDestination="@id/queueFragment">
+
+    <fragment
+        android:id="@+id/queueFragment"
+        android:name="org.lineageos.twelve.fragments.QueueFragment"
+        android:label="@string/queue"
+        tools:layout="@layout/fragment_queue" />
+
+</navigation>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index eebb486..4271651 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -153,4 +153,7 @@
     <string name="genre_appears_in_albums_header">Appears in albums</string>
     <string name="genre_appears_in_playlists_header">Appears in playlists</string>
     <string name="genre_audios_header">Audios</string>
+
+    <!-- Queue fragment -->
+    <string name="queue">Queue</string>
 </resources>