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>