Twelve: Genre fragment
Change-Id: I7248d1ee8d54ccc92c30217e98a1540f7858b1e2
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/GenreFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/GenreFragment.kt
new file mode 100644
index 0000000..9fc07bc
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/GenreFragment.kt
@@ -0,0 +1,326 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.os.bundleOf
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
+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.navigation.fragment.findNavController
+import androidx.navigation.ui.setupWithNavController
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.progressindicator.LinearProgressIndicator
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.R
+import org.lineageos.twelve.datasources.MediaError
+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.Album
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.Playlist
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter
+import org.lineageos.twelve.ui.recyclerview.UniqueItemDiffCallback
+import org.lineageos.twelve.ui.views.HorizontalListItem
+import org.lineageos.twelve.utils.PermissionsChecker
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.viewmodels.GenreViewModel
+
+/**
+ * Single genre viewer.
+ */
+class GenreFragment : Fragment(R.layout.fragment_genre) {
+ // View models
+ private val viewModel by viewModels<GenreViewModel>()
+
+ // Views
+ private val appearsInAlbumsLinearLayout by getViewProperty<LinearLayout>(R.id.appearsInAlbumsLinearLayout)
+ private val appearsInAlbumsRecyclerView by getViewProperty<RecyclerView>(R.id.appearsInAlbumsRecyclerView)
+ private val appearsInPlaylistsLinearLayout by getViewProperty<LinearLayout>(R.id.appearsInPlaylistsLinearLayout)
+ private val appearsInPlaylistsRecyclerView by getViewProperty<RecyclerView>(R.id.appearsInPlaylistsRecyclerView)
+ private val audiosLinearLayout by getViewProperty<LinearLayout>(R.id.audiosLinearLayout)
+ private val audiosRecyclerView by getViewProperty<RecyclerView>(R.id.audiosRecyclerView)
+ private val genreNameTextView by getViewProperty<TextView>(R.id.genreNameTextView)
+ private val infoNestedScrollView by getViewProperty<NestedScrollView?>(R.id.infoNestedScrollView)
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val nestedScrollView by getViewProperty<NestedScrollView>(R.id.nestedScrollView)
+ private val noElementsNestedScrollView by getViewProperty<NestedScrollView>(R.id.noElementsNestedScrollView)
+ private val thumbnailImageView by getViewProperty<ImageView>(R.id.thumbnailImageView)
+ private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
+
+ // RecyclerView
+ private val appearsInAlbumsAdapter by lazy {
+ object : SimpleListAdapter<Album, HorizontalListItem>(
+ UniqueItemDiffCallback(),
+ ::HorizontalListItem,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.headlineMaxLines = 2
+
+ view.setOnClickListener {
+ item?.let {
+ findNavController().navigate(
+ R.id.action_genreFragment_to_fragment_album,
+ AlbumFragment.createBundle(it.uri)
+ )
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Album) {
+ item.thumbnail?.uri?.also { uri ->
+ view.loadThumbnailImage(uri)
+ } ?: item.thumbnail?.bitmap?.also { bitmap ->
+ view.loadThumbnailImage(bitmap)
+ } ?: view.setThumbnailImage(R.drawable.ic_album)
+
+ view.headlineText = item.title
+ view.supportingText = item.artistName
+ view.tertiaryText = item.year?.toString()
+ }
+ }
+ }
+ private val appearsInPlaylistsAdapter by lazy {
+ object : SimpleListAdapter<Playlist, HorizontalListItem>(
+ UniqueItemDiffCallback(),
+ ::HorizontalListItem,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setThumbnailImage(R.drawable.ic_playlist_play)
+ view.setOnClickListener {
+ item?.let {
+ findNavController().navigate(
+ R.id.action_genreFragment_to_fragment_playlist,
+ PlaylistFragment.createBundle(it.uri)
+ )
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Playlist) {
+ view.headlineText = item.name
+ }
+ }
+ }
+ private val audiosAdapter by lazy {
+ object : SimpleListAdapter<Audio, HorizontalListItem>(
+ UniqueItemDiffCallback(),
+ ::HorizontalListItem,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setThumbnailImage(R.drawable.ic_music_note)
+ view.headlineMaxLines = 2
+
+ view.setOnClickListener {
+ item?.let {
+ viewModel.playAudio(currentList, bindingAdapterPosition)
+ findNavController().navigate(
+ R.id.action_genreFragment_to_fragment_now_playing
+ )
+ }
+ }
+
+ view.setOnLongClickListener {
+ item?.let {
+ findNavController().navigate(
+ R.id.action_genreFragment_to_fragment_audio_bottom_sheet_dialog,
+ AudioBottomSheetDialogFragment.createBundle(it.uri)
+ )
+
+ true
+ } ?: false
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Audio) {
+ view.headlineText = item.title
+ view.supportingText = item.artistName
+ view.tertiaryText = item.albumTitle
+ }
+ }
+ }
+
+ // Arguments
+ private val genreUri: Uri
+ get() = requireArguments().getParcelable(ARG_GENRE_URI, Uri::class)!!
+
+ // Permissions
+ private val permissionsChecker = PermissionsChecker(
+ this, PermissionsUtils.mainPermissions
+ )
+
+ 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
+ }
+
+ infoNestedScrollView?.let {
+ ViewCompat.setOnApplyWindowInsetsListener(it) { v, windowInsets ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+
+ v.updatePadding(
+ insets,
+ bottom = true,
+ )
+
+ windowInsets
+ }
+ }
+
+ ViewCompat.setOnApplyWindowInsetsListener(nestedScrollView) { 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())
+
+ appearsInAlbumsRecyclerView.adapter = appearsInAlbumsAdapter
+ appearsInPlaylistsRecyclerView.adapter = appearsInPlaylistsAdapter
+ audiosRecyclerView.adapter = audiosAdapter
+
+ viewModel.loadGenre(genreUri)
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ permissionsChecker.withPermissionsGranted {
+ loadData()
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ appearsInAlbumsRecyclerView.adapter = null
+ appearsInPlaylistsRecyclerView.adapter = null
+ audiosRecyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+
+ private suspend fun loadData() {
+ viewModel.genre.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ val (genre, genreContent) = it.data
+
+ (genre.name ?: getString(R.string.genre_unknown)).let { genreName ->
+ toolbar.title = genreName
+ genreNameTextView.text = genreName
+ }
+
+ thumbnailImageView.setImageResource(R.drawable.ic_genres)
+
+ appearsInAlbumsAdapter.submitList(genreContent.appearsInAlbums)
+ appearsInPlaylistsAdapter.submitList(genreContent.appearsInPlaylists)
+ audiosAdapter.submitList(genreContent.audios)
+
+ val isAppearsInAlbumsEmpty = genreContent.appearsInAlbums.isEmpty()
+ appearsInAlbumsLinearLayout.isVisible = !isAppearsInAlbumsEmpty
+
+ val isAppearsInPlaylistsEmpty = genreContent.appearsInPlaylists.isEmpty()
+ appearsInPlaylistsLinearLayout.isVisible = !isAppearsInPlaylistsEmpty
+
+ val isAudiosEmpty = genreContent.audios.isEmpty()
+ audiosLinearLayout.isVisible = !isAudiosEmpty
+
+ val isEmpty = listOf(
+ isAppearsInAlbumsEmpty,
+ isAppearsInPlaylistsEmpty,
+ isAudiosEmpty,
+ ).all { isEmpty -> isEmpty }
+ nestedScrollView.isVisible = !isEmpty
+ noElementsNestedScrollView.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Error loading genre, error: ${it.error}")
+
+ toolbar.title = ""
+ genreNameTextView.text = ""
+
+ appearsInAlbumsAdapter.submitList(listOf())
+ appearsInPlaylistsAdapter.submitList(listOf())
+ audiosAdapter.submitList(listOf())
+
+ nestedScrollView.isVisible = false
+ noElementsNestedScrollView.isVisible = true
+
+ if (it.error == MediaError.NOT_FOUND) {
+ // Get out of here
+ findNavController().navigateUp()
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val LOG_TAG = GenreFragment::class.simpleName!!
+
+ private const val ARG_GENRE_URI = "genre_uri"
+
+ /**
+ * Create a [Bundle] to use as the arguments for this fragment.
+ * @param genreUri The URI of the genre to display
+ */
+ fun createBundle(
+ genreUri: Uri,
+ ) = bundleOf(
+ ARG_GENRE_URI to genreUri,
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/GenresFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/GenresFragment.kt
index 85b266f..1257f72 100644
--- a/app/src/main/java/org/lineageos/twelve/fragments/GenresFragment.kt
+++ b/app/src/main/java/org/lineageos/twelve/fragments/GenresFragment.kt
@@ -15,6 +15,7 @@
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.progressindicator.LinearProgressIndicator
import kotlinx.coroutines.flow.collectLatest
@@ -52,7 +53,12 @@
override fun ViewHolder.onPrepareView() {
view.setLeadingIconImage(R.drawable.ic_genres)
view.setOnClickListener {
- // TODO: Open genre fragment
+ item?.let {
+ findNavController().navigate(
+ R.id.action_mainFragment_to_fragment_genre,
+ GenreFragment.createBundle(it.uri)
+ )
+ }
}
}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/GenreViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/GenreViewModel.kt
new file mode 100644
index 0000000..23a99d7
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/GenreViewModel.kt
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import android.net.Uri
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+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.models.RequestStatus
+
+class GenreViewModel(application: Application) : TwelveViewModel(application) {
+ private val genreUri = MutableStateFlow<Uri?>(null)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val genre = genreUri
+ .filterNotNull()
+ .flatMapLatest {
+ mediaRepository.genre(it)
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ RequestStatus.Loading()
+ )
+
+ fun loadGenre(genreUri: Uri) {
+ this.genreUri.value = genreUri
+ }
+}
diff --git a/app/src/main/res/layout-land/fragment_genre.xml b/app/src/main/res/layout-land/fragment_genre.xml
new file mode 100644
index 0000000..532b965
--- /dev/null
+++ b/app/src/main/res/layout-land/fragment_genre.xml
@@ -0,0 +1,98 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools"
+ 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"
+ app:liftOnScrollTargetViewId="@+id/nestedScrollView">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ tools:title="Rock" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:baselineAligned="false"
+ android:orientation="horizontal"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/infoNestedScrollView"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:clipToPadding="false"
+ android:fillViewport="true">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:paddingHorizontal="32dp"
+ android:paddingVertical="8dp">
+
+ <com.google.android.material.card.MaterialCardView
+ style="@style/Widget.Material3.CardView.Elevated"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginVertical="16dp"
+ app:cardCornerRadius="16dp">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <ImageView
+ android:id="@+id/thumbnailImageView"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:layout_constraintDimensionRatio="H,1:1"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <include
+ layout="@layout/genre_labels"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+ <include
+ layout="@layout/genre_content"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="2" />
+
+ </LinearLayout>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/fragment_genre.xml b/app/src/main/res/layout/fragment_genre.xml
new file mode 100644
index 0000000..bd217f3
--- /dev/null
+++ b/app/src/main/res/layout/fragment_genre.xml
@@ -0,0 +1,100 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools"
+ 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"
+ app:liftOnScrollTargetViewId="@+id/nestedScrollView">
+
+ <com.google.android.material.appbar.CollapsingToolbarLayout
+ style="?attr/collapsingToolbarLayoutLargeStyle"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:expandedTitleTextColor="@android:color/transparent"
+ app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
+ app:maxLines="3">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+
+ <ImageView
+ android:id="@+id/thumbnailImageView"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:scaleType="centerCrop"
+ app:layout_constraintDimensionRatio="H,1:1"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <View
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:background="@drawable/bg_item_header_scrim"
+ android:fitsSystemWindows="true"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <View
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:alpha="0.4"
+ android:background="?attr/colorSurface"
+ android:fitsSystemWindows="true"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <include
+ layout="@layout/genre_labels"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom"
+ android:layout_marginBottom="28dp"
+ android:layout_marginHorizontal="16dp" />
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ android:elevation="0dp"
+ app:layout_collapseMode="pin"
+ app:layout_scrollFlags="scroll|enterAlways|snap"
+ tools:title="Rock" />
+
+ </com.google.android.material.appbar.CollapsingToolbarLayout>
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <include
+ layout="@layout/genre_content"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/genre_content.xml b/app/src/main/res/layout/genre_content.xml
new file mode 100644
index 0000000..f988bec
--- /dev/null
+++ b/app/src/main/res/layout/genre_content.xml
@@ -0,0 +1,137 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<FrameLayout 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:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/nestedScrollView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:id="@+id/appearsInAlbumsLinearLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ android:orientation="vertical"
+ android:paddingTop="16dp"
+ android:visibility="gone"
+ tools:visibility="visible">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="18dp"
+ android:paddingHorizontal="16dp"
+ android:text="@string/genre_appears_in_albums_header"
+ android:textAppearance="?attr/textAppearanceTitleLarge" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/appearsInAlbumsRecyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/appearsInPlaylistsLinearLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ android:orientation="vertical"
+ android:paddingTop="16dp"
+ android:visibility="gone"
+ tools:visibility="visible">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="18dp"
+ android:paddingHorizontal="16dp"
+ android:text="@string/genre_appears_in_playlists_header"
+ android:textAppearance="?attr/textAppearanceTitleLarge" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/appearsInPlaylistsRecyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/audiosLinearLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ android:orientation="vertical"
+ android:paddingTop="16dp"
+ android:visibility="gone"
+ tools:visibility="visible">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="18dp"
+ android:paddingHorizontal="16dp"
+ android:text="@string/genre_audios_header"
+ android:textAppearance="?attr/textAppearanceTitleLarge" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/audiosRecyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+ <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_genres"
+ android:src="@drawable/ic_genres" />
+
+ <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+ <TextView
+ style="@style/Theme.Twelve.NoElements.TextView"
+ android:text="@string/no_genres" />
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</FrameLayout>
diff --git a/app/src/main/res/layout/genre_labels.xml b/app/src/main/res/layout/genre_labels.xml
new file mode 100644
index 0000000..63c605a
--- /dev/null
+++ b/app/src/main/res/layout/genre_labels.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/genreNameTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceHeadlineMedium"
+ tools:text="Rock" />
+
+</LinearLayout>
diff --git a/app/src/main/res/navigation/fragment_genre.xml b/app/src/main/res/navigation/fragment_genre.xml
new file mode 100644
index 0000000..507a3c9
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_genre.xml
@@ -0,0 +1,51 @@
+<?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_genre"
+ app:startDestination="@id/genreFragment">
+
+ <fragment
+ android:id="@+id/genreFragment"
+ android:name="org.lineageos.twelve.fragments.GenreFragment"
+ tools:layout="@layout/fragment_genre">
+
+ <action
+ android:id="@+id/action_genreFragment_to_fragment_album"
+ app:destination="@+id/fragment_album"
+ 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" />
+
+ <action
+ android:id="@+id/action_genreFragment_to_fragment_audio_bottom_sheet_dialog"
+ app:destination="@+id/fragment_audio_bottom_sheet_dialog"
+ 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" />
+
+ <action
+ android:id="@+id/action_genreFragment_to_fragment_now_playing"
+ app:destination="@+id/fragment_now_playing"
+ 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" />
+
+ <action
+ android:id="@+id/action_genreFragment_to_fragment_playlist"
+ app:destination="@+id/fragment_playlist"
+ 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_main.xml b/app/src/main/res/navigation/fragment_main.xml
index f314846..4b5193e 100644
--- a/app/src/main/res/navigation/fragment_main.xml
+++ b/app/src/main/res/navigation/fragment_main.xml
@@ -13,6 +13,7 @@
<include app:graph="@navigation/fragment_album" />
<include app:graph="@navigation/fragment_artist" />
<include app:graph="@navigation/fragment_audio_bottom_sheet_dialog" />
+ <include app:graph="@navigation/fragment_genre" />
<include app:graph="@navigation/fragment_manage_provider" />
<include app:graph="@navigation/fragment_now_playing" />
<include app:graph="@navigation/fragment_now_playing_stats_dialog" />
@@ -42,6 +43,14 @@
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
+ android:id="@+id/action_mainFragment_to_fragment_genre"
+ app:destination="@+id/fragment_genre"
+ 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" />
+
+ <action
android:id="@+id/action_mainFragment_to_fragment_now_playing"
app:destination="@+id/fragment_now_playing"
app:enterAnim="@anim/nav_default_enter_anim"
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cc39008..a5b4542 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -147,4 +147,9 @@
<!-- Album fragment -->
<string name="album_disc_header">Disc %1$d</string>
<string name="track_number" translatable="false">%1$d</string>
+
+ <!-- Genre fragment -->
+ <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>
</resources>