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>