Twelve: Initial commit
Co-authored-by: Luca Stefani <luca.stefani.ge1@gmail.com>
Co-authored-by: Asher Simonds <dayanhammer@gmail.com>
Change-Id: Iaecc3b21a8b579ec0e4455f7aabdaa8c56d3045c
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..fd3fa45
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,14 @@
+name: build
+
+on: [push, pull_request, workflow_dispatch]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v3
+
+ - name: Build
+ uses: ./.github/workflows/build
diff --git a/.github/workflows/build/action.yml b/.github/workflows/build/action.yml
new file mode 100644
index 0000000..479781d
--- /dev/null
+++ b/.github/workflows/build/action.yml
@@ -0,0 +1,30 @@
+name: build
+
+runs:
+ using: composite
+
+ steps:
+ - name: Setup JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'zulu'
+ java-version: 17
+ cache: 'gradle'
+
+ - name: Build with Gradle
+ shell: bash
+ run: ./gradlew assembleDebug
+
+ - name: Generate Android.bp
+ shell: bash
+ run: |
+ ./gradlew app:generateBp
+ if [[ ! -z $(git status -s) ]]; then
+ git status
+ exit -1
+ fi
+
+ - uses: actions/upload-artifact@v3
+ with:
+ name: app-debug.apk
+ path: app/build/outputs/apk/debug/app-debug.apk
diff --git a/.github/workflows/gerrit.yml b/.github/workflows/gerrit.yml
new file mode 100644
index 0000000..692075e
--- /dev/null
+++ b/.github/workflows/gerrit.yml
@@ -0,0 +1,30 @@
+name: gerrit checks
+
+on:
+ workflow_dispatch:
+ inputs:
+ ref:
+ type: string
+ gerrit-ref:
+ type: string
+ change:
+ type: string
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: lineageos-infra/fetch-gerrit-change@main
+ with:
+ gerrit-ref: ${{ inputs.gerrit-ref }}
+ ref: ${{ inputs.ref }}
+
+ - name: Build
+ uses: ./.github/workflows/build
+
+ - uses: lineageos-infra/gerrit-vote@main
+ if: always()
+ with:
+ auth: ${{ secrets.GERRIT_VOTE_CREDS }}
+ change: ${{ inputs.change }}
+ ref: ${{ inputs.ref }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..10cfdbf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/app/Android.bp b/app/Android.bp
new file mode 100644
index 0000000..6fa056b
--- /dev/null
+++ b/app/Android.bp
@@ -0,0 +1,74 @@
+//
+// SPDX-FileCopyrightText: 2024 The LineageOS Project
+// SPDX-License-Identifier: Apache-2.0
+//
+
+package {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+ name: "Twelve",
+
+ defaults: ["aapt_version_code_defaults"],
+
+ srcs: ["src/main/java/**/*.kt"],
+ resource_dirs: ["src/main/res"],
+ manifest: "src/main/AndroidManifest.xml",
+
+ sdk_version: "35",
+ product_specific: true,
+
+ use_embedded_native_libs: true,
+
+ overrides: [
+ "Music",
+ ],
+
+ required: [
+ "initial-package-stopped-states-org.lineageos.twelve",
+ "preinstalled-packages-org.lineageos.twelve",
+ ],
+
+ static_libs: [
+ // DO NOT EDIT THIS SECTION MANUALLY
+ "androidx.activity_activity",
+ "androidx.appcompat_appcompat",
+ "androidx-constraintlayout_constraintlayout",
+ "androidx.core_core-ktx",
+ "androidx.fragment_fragment-ktx",
+ "androidx.lifecycle_lifecycle-service",
+ "Twelve_androidx.media3_media3-common-ktx",
+ "Twelve_androidx.media3_media3-exoplayer",
+ "Twelve_androidx.media3_media3-exoplayer-midi",
+ "Twelve_androidx.media3_media3-session",
+ "Twelve_androidx.media3_media3-ui",
+ "androidx.navigation_navigation-fragment-ktx",
+ "androidx.navigation_navigation-ui-ktx",
+ "androidx.recyclerview_recyclerview",
+ "androidx.viewpager2_viewpager2",
+ "kotlinx_coroutines_guava",
+ "Twelve_com.google.android.material_material",
+ "kotlin-stdlib-jdk8",
+ ],
+
+ optimize: {
+ proguard_flags_files: ["proguard-rules.pro"],
+ },
+}
+
+prebuilt_etc {
+ name: "initial-package-stopped-states-org.lineageos.twelve",
+ product_specific: true,
+ sub_dir: "sysconfig",
+ src: "initial-package-stopped-states-org.lineageos.twelve.xml",
+ filename_from_src: true,
+}
+
+prebuilt_etc {
+ name: "preinstalled-packages-org.lineageos.twelve",
+ product_specific: true,
+ sub_dir: "sysconfig",
+ src: "preinstalled-packages-org.lineageos.twelve.xml",
+ filename_from_src: true,
+}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..45c2624
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,103 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import org.lineageos.generatebp.GenerateBpPlugin
+import org.lineageos.generatebp.GenerateBpPluginExtension
+import org.lineageos.generatebp.models.Module
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+}
+
+apply {
+ plugin<GenerateBpPlugin>()
+}
+
+buildscript {
+ repositories {
+ maven("https://raw.githubusercontent.com/lineage-next/gradle-generatebp/v1.11/.m2")
+ }
+
+ dependencies {
+ classpath("org.lineageos:gradle-generatebp:+")
+ }
+}
+
+android {
+ namespace = "org.lineageos.twelve"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "org.lineageos.twelve"
+ minSdk = 30
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+
+ debug {
+ // Append .dev to package name so we won't conflict with AOSP build.
+ applicationIdSuffix = ".dev"
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.activity)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.lifecycle.service)
+ implementation(libs.androidx.media3.common.ktx)
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.exoplayer.midi)
+ implementation(libs.androidx.media3.session)
+ implementation(libs.androidx.media3.ui)
+ implementation(libs.androidx.navigation.fragment.ktx)
+ implementation(libs.androidx.navigation.ui.ktx)
+ implementation(libs.androidx.recyclerview)
+ implementation(libs.androidx.viewpager2)
+ implementation(libs.kotlinx.coroutines.guava)
+ implementation(libs.material)
+}
+
+configure<GenerateBpPluginExtension> {
+ targetSdk.set(android.defaultConfig.targetSdk!!)
+ availableInAOSP.set { module: Module ->
+ when {
+ module.group.startsWith("androidx") -> {
+ // We provide our own androidx.media3
+ !module.group.startsWith("androidx.media3")
+ }
+ module.group.startsWith("org.jetbrains") -> true
+ module.group == "com.google.auto.value" -> true
+ module.group == "com.google.code.findbugs" -> true
+ module.group == "com.google.errorprone" -> true
+ module.group == "com.google.guava" -> true
+ module.group == "junit" -> true
+ else -> false
+ }
+ }
+}
diff --git a/app/initial-package-stopped-states-org.lineageos.twelve.xml b/app/initial-package-stopped-states-org.lineageos.twelve.xml
new file mode 100644
index 0000000..8fa6fa3
--- /dev/null
+++ b/app/initial-package-stopped-states-org.lineageos.twelve.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<config>
+ <initial-package-state package="org.lineageos.twelve" stopped="false"/>
+</config>
diff --git a/app/libs/Android.bp b/app/libs/Android.bp
new file mode 100644
index 0000000..778f521
--- /dev/null
+++ b/app/libs/Android.bp
@@ -0,0 +1,524 @@
+//
+// SPDX-FileCopyrightText: 2024 The LineageOS Project
+// SPDX-License-Identifier: Apache-2.0
+//
+
+// DO NOT EDIT THIS FILE MANUALLY
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-common-nodeps",
+ aars: ["androidx/media3/media3-common/1.5.0-alpha01/media3-common-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "androidx.annotation_annotation",
+ "androidx.annotation_annotation-experimental",
+ "guava",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-common",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-common/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-common-nodeps",
+ "androidx.annotation_annotation",
+ "androidx.annotation_annotation-experimental",
+ "guava",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-common-ktx-nodeps",
+ aars: ["androidx/media3/media3-common-ktx/1.5.0-alpha01/media3-common-ktx-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-exoplayer",
+ "androidx.core_core",
+ "kotlin-stdlib-jdk8",
+ "kotlinx-coroutines-android",
+ "kotlinx-coroutines-core",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-common-ktx",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-common-ktx/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-common-ktx-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-exoplayer",
+ "androidx.core_core",
+ "kotlin-stdlib-jdk8",
+ "kotlinx-coroutines-android",
+ "kotlinx-coroutines-core",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-container-nodeps",
+ aars: ["androidx/media3/media3-container/1.5.0-alpha01/media3-container-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-container",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-container/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-container-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-database-nodeps",
+ aars: ["androidx/media3/media3-database/1.5.0-alpha01/media3-database-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-database",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-database/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-database-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-datasource-nodeps",
+ aars: ["androidx/media3/media3-datasource/1.5.0-alpha01/media3-datasource-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-database",
+ "androidx.annotation_annotation",
+ "androidx.exifinterface_exifinterface",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-datasource",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-datasource/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-datasource-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-database",
+ "androidx.annotation_annotation",
+ "androidx.exifinterface_exifinterface",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-decoder-nodeps",
+ aars: ["androidx/media3/media3-decoder/1.5.0-alpha01/media3-decoder-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-decoder",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-decoder/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-decoder-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-exoplayer-nodeps",
+ aars: ["androidx/media3/media3-exoplayer/1.5.0-alpha01/media3-exoplayer-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-container",
+ "Twelve_androidx.media3_media3-database",
+ "Twelve_androidx.media3_media3-datasource",
+ "Twelve_androidx.media3_media3-decoder",
+ "Twelve_androidx.media3_media3-extractor",
+ "androidx.annotation_annotation",
+ "androidx.collection_collection",
+ "androidx.core_core",
+ "androidx.exifinterface_exifinterface",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-exoplayer",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-exoplayer/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-exoplayer-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-container",
+ "Twelve_androidx.media3_media3-database",
+ "Twelve_androidx.media3_media3-datasource",
+ "Twelve_androidx.media3_media3-decoder",
+ "Twelve_androidx.media3_media3-extractor",
+ "androidx.annotation_annotation",
+ "androidx.collection_collection",
+ "androidx.core_core",
+ "androidx.exifinterface_exifinterface",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-exoplayer-midi-nodeps",
+ aars: ["androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/media3-exoplayer-midi-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-decoder",
+ "Twelve_androidx.media3_media3-exoplayer",
+ "Twelve_androidx.media3_media3-extractor",
+ "Twelve_com.github.philburk_jsyn",
+ "androidx.annotation_annotation",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-exoplayer-midi",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-exoplayer-midi-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-decoder",
+ "Twelve_androidx.media3_media3-exoplayer",
+ "Twelve_androidx.media3_media3-extractor",
+ "Twelve_com.github.philburk_jsyn",
+ "androidx.annotation_annotation",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-extractor-nodeps",
+ aars: ["androidx/media3/media3-extractor/1.5.0-alpha01/media3-extractor-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-container",
+ "Twelve_androidx.media3_media3-decoder",
+ "androidx.annotation_annotation",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-extractor",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-extractor/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-extractor-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-container",
+ "Twelve_androidx.media3_media3-decoder",
+ "androidx.annotation_annotation",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-session-nodeps",
+ aars: ["androidx/media3/media3-session/1.5.0-alpha01/media3-session-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-datasource",
+ "androidx.collection_collection",
+ "androidx.core_core",
+ "androidx.media_media",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-session",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-session/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-session-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "Twelve_androidx.media3_media3-datasource",
+ "androidx.collection_collection",
+ "androidx.core_core",
+ "androidx.media_media",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_androidx.media3_media3-ui-nodeps",
+ aars: ["androidx/media3/media3-ui/1.5.0-alpha01/media3-ui-1.5.0-alpha01.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ "androidx.media_media",
+ "androidx.recyclerview_recyclerview",
+ ],
+}
+
+android_library {
+ name: "Twelve_androidx.media3_media3-ui",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "androidx/media3/media3-ui/1.5.0-alpha01/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_androidx.media3_media3-ui-nodeps",
+ "Twelve_androidx.media3_media3-common",
+ "androidx.annotation_annotation",
+ "androidx.media_media",
+ "androidx.recyclerview_recyclerview",
+ ],
+ java_version: "1.7",
+}
+
+java_import {
+ name: "Twelve_com.github.philburk_jsyn-nodeps",
+ jars: ["com/github/philburk/jsyn/40a41092cbab558d7d410ec43d93bb1e4121e86a/jsyn-40a41092cbab558d7d410ec43d93bb1e4121e86a.jar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+}
+
+java_library_static {
+ name: "Twelve_com.github.philburk_jsyn",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_com.github.philburk_jsyn-nodeps",
+ ],
+ java_version: "1.7",
+}
+
+android_library_import {
+ name: "Twelve_com.google.android.material_material-nodeps",
+ aars: ["com/google/android/material/material/1.12.0/material-1.12.0.aar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "androidx-constraintlayout_constraintlayout",
+ "androidx.activity_activity",
+ "androidx.annotation_annotation",
+ "androidx.annotation_annotation-experimental",
+ "androidx.appcompat_appcompat",
+ "androidx.cardview_cardview",
+ "androidx.coordinatorlayout_coordinatorlayout",
+ "androidx.core_core",
+ "androidx.drawerlayout_drawerlayout",
+ "androidx.dynamicanimation_dynamicanimation",
+ "androidx.fragment_fragment",
+ "androidx.lifecycle_lifecycle-runtime",
+ "androidx.recyclerview_recyclerview",
+ "androidx.resourceinspection_resourceinspection-annotation",
+ "androidx.transition_transition",
+ "androidx.vectordrawable_vectordrawable",
+ "androidx.viewpager2_viewpager2",
+ "error_prone_annotations",
+ ],
+}
+
+android_library {
+ name: "Twelve_com.google.android.material_material",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ manifest: "com/google/android/material/material/1.12.0/AndroidManifest.xml",
+ static_libs: [
+ "Twelve_com.google.android.material_material-nodeps",
+ "androidx-constraintlayout_constraintlayout",
+ "androidx.activity_activity",
+ "androidx.annotation_annotation",
+ "androidx.annotation_annotation-experimental",
+ "androidx.appcompat_appcompat",
+ "androidx.cardview_cardview",
+ "androidx.coordinatorlayout_coordinatorlayout",
+ "androidx.core_core",
+ "androidx.drawerlayout_drawerlayout",
+ "androidx.dynamicanimation_dynamicanimation",
+ "androidx.fragment_fragment",
+ "androidx.lifecycle_lifecycle-runtime",
+ "androidx.recyclerview_recyclerview",
+ "androidx.resourceinspection_resourceinspection-annotation",
+ "androidx.transition_transition",
+ "androidx.vectordrawable_vectordrawable",
+ "androidx.viewpager2_viewpager2",
+ "error_prone_annotations",
+ ],
+ java_version: "1.7",
+}
+
+java_import {
+ name: "Twelve_org.checkerframework_checker-qual-nodeps",
+ jars: ["org/checkerframework/checker-qual/3.41.0/checker-qual-3.41.0.jar"],
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+}
+
+java_library_static {
+ name: "Twelve_org.checkerframework_checker-qual",
+ sdk_version: "35",
+ min_sdk_version: "14",
+ apex_available: [
+ "//apex_available:platform",
+ "//apex_available:anyapex",
+ ],
+ static_libs: [
+ "Twelve_org.checkerframework_checker-qual-nodeps",
+ ],
+ java_version: "1.7",
+}
diff --git a/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..96c184b
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.common.ktx" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/media3-common-ktx-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/media3-common-ktx-1.5.0-alpha01.aar
new file mode 100644
index 0000000..e5304bc
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/media3-common-ktx-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/media3-common-ktx-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/media3-common-ktx-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common-ktx/1.5.0-alpha01/media3-common-ktx-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-common/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..ca09cc8
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2020 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.common" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-common/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-common/1.5.0-alpha01/media3-common-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/media3-common-1.5.0-alpha01.aar
new file mode 100644
index 0000000..1b3c3e0
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/media3-common-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-common/1.5.0-alpha01/media3-common-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/media3-common-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-common/1.5.0-alpha01/media3-common-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-container/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..d252191
--- /dev/null
+++ b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2023 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.container" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-container/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-container/1.5.0-alpha01/media3-container-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/media3-container-1.5.0-alpha01.aar
new file mode 100644
index 0000000..bb30510
--- /dev/null
+++ b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/media3-container-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-container/1.5.0-alpha01/media3-container-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/media3-container-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-container/1.5.0-alpha01/media3-container-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-database/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..5cf86e2
--- /dev/null
+++ b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.database" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-database/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-database/1.5.0-alpha01/media3-database-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/media3-database-1.5.0-alpha01.aar
new file mode 100644
index 0000000..43a3bab
--- /dev/null
+++ b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/media3-database-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-database/1.5.0-alpha01/media3-database-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/media3-database-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-database/1.5.0-alpha01/media3-database-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..c1b8feb
--- /dev/null
+++ b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.datasource" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/media3-datasource-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/media3-datasource-1.5.0-alpha01.aar
new file mode 100644
index 0000000..3d20dc8
--- /dev/null
+++ b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/media3-datasource-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/media3-datasource-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/media3-datasource-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-datasource/1.5.0-alpha01/media3-datasource-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..95440ae
--- /dev/null
+++ b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.decoder" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/media3-decoder-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/media3-decoder-1.5.0-alpha01.aar
new file mode 100644
index 0000000..19231ee
--- /dev/null
+++ b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/media3-decoder-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/media3-decoder-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/media3-decoder-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-decoder/1.5.0-alpha01/media3-decoder-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..bffcabf
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2022 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.decoder.midi" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/media3-exoplayer-midi-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/media3-exoplayer-midi-1.5.0-alpha01.aar
new file mode 100644
index 0000000..a0cd48c
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/media3-exoplayer-midi-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/media3-exoplayer-midi-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/media3-exoplayer-midi-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer-midi/1.5.0-alpha01/media3-exoplayer-midi-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..4d9f3da
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.exoplayer" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/media3-exoplayer-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/media3-exoplayer-1.5.0-alpha01.aar
new file mode 100644
index 0000000..d25914f
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/media3-exoplayer-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/media3-exoplayer-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/media3-exoplayer-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-exoplayer/1.5.0-alpha01/media3-exoplayer-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..eb3143f
--- /dev/null
+++ b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2020 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.extractor" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/media3-extractor-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/media3-extractor-1.5.0-alpha01.aar
new file mode 100644
index 0000000..14fcaa5
--- /dev/null
+++ b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/media3-extractor-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/media3-extractor-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/media3-extractor-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-extractor/1.5.0-alpha01/media3-extractor-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-session/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..b7b9a43
--- /dev/null
+++ b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2018 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.session" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-session/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-session/1.5.0-alpha01/media3-session-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/media3-session-1.5.0-alpha01.aar
new file mode 100644
index 0000000..bb21e20
--- /dev/null
+++ b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/media3-session-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-session/1.5.0-alpha01/media3-session-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/media3-session-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-session/1.5.0-alpha01/media3-session-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/AndroidManifest.xml b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/AndroidManifest.xml
new file mode 100644
index 0000000..03a22a1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.media3.ui" >
+
+ <uses-sdk android:minSdkVersion="21" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/AndroidManifest.xml.license b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/AndroidManifest.xml.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/media3-ui-1.5.0-alpha01.aar b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/media3-ui-1.5.0-alpha01.aar
new file mode 100644
index 0000000..5ae01cf
--- /dev/null
+++ b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/media3-ui-1.5.0-alpha01.aar
Binary files differ
diff --git a/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/media3-ui-1.5.0-alpha01.aar.license b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/media3-ui-1.5.0-alpha01.aar.license
new file mode 100644
index 0000000..ffce7d1
--- /dev/null
+++ b/app/libs/androidx/media3/media3-ui/1.5.0-alpha01/media3-ui-1.5.0-alpha01.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/com/github/philburk/jsyn/40a41092cbab558d7d410ec43d93bb1e4121e86a/jsyn-40a41092cbab558d7d410ec43d93bb1e4121e86a.jar b/app/libs/com/github/philburk/jsyn/40a41092cbab558d7d410ec43d93bb1e4121e86a/jsyn-40a41092cbab558d7d410ec43d93bb1e4121e86a.jar
new file mode 100644
index 0000000..9e6df2a
--- /dev/null
+++ b/app/libs/com/github/philburk/jsyn/40a41092cbab558d7d410ec43d93bb1e4121e86a/jsyn-40a41092cbab558d7d410ec43d93bb1e4121e86a.jar
Binary files differ
diff --git a/app/libs/com/google/android/material/material/1.12.0/AndroidManifest.xml b/app/libs/com/google/android/material/material/1.12.0/AndroidManifest.xml
new file mode 100644
index 0000000..9a39b6f
--- /dev/null
+++ b/app/libs/com/google/android/material/material/1.12.0/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2015 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.google.android.material" >
+
+ <uses-sdk android:minSdkVersion="19" />
+
+ <application />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/com/google/android/material/material/1.12.0/AndroidManifest.xml.license b/app/libs/com/google/android/material/material/1.12.0/AndroidManifest.xml.license
new file mode 100644
index 0000000..db465ee
--- /dev/null
+++ b/app/libs/com/google/android/material/material/1.12.0/AndroidManifest.xml.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2015-2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/com/google/android/material/material/1.12.0/material-1.12.0.aar b/app/libs/com/google/android/material/material/1.12.0/material-1.12.0.aar
new file mode 100644
index 0000000..676d354
--- /dev/null
+++ b/app/libs/com/google/android/material/material/1.12.0/material-1.12.0.aar
Binary files differ
diff --git a/app/libs/com/google/android/material/material/1.12.0/material-1.12.0.aar.license b/app/libs/com/google/android/material/material/1.12.0/material-1.12.0.aar.license
new file mode 100644
index 0000000..db465ee
--- /dev/null
+++ b/app/libs/com/google/android/material/material/1.12.0/material-1.12.0.aar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2015-2024 The Android Open Source Project
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/app/libs/org/checkerframework/checker-qual/3.41.0/checker-qual-3.41.0.jar b/app/libs/org/checkerframework/checker-qual/3.41.0/checker-qual-3.41.0.jar
new file mode 100644
index 0000000..17a85a1
--- /dev/null
+++ b/app/libs/org/checkerframework/checker-qual/3.41.0/checker-qual-3.41.0.jar
Binary files differ
diff --git a/app/libs/org/checkerframework/checker-qual/3.41.0/checker-qual-3.41.0.jar.license b/app/libs/org/checkerframework/checker-qual/3.41.0/checker-qual-3.41.0.jar.license
new file mode 100644
index 0000000..4b47d33
--- /dev/null
+++ b/app/libs/org/checkerframework/checker-qual/3.41.0/checker-qual-3.41.0.jar.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 Michael Ernst
+SPDX-FileCopyrightText: 2024 Suzanne Millstein
+
diff --git a/app/preinstalled-packages-org.lineageos.twelve.xml b/app/preinstalled-packages-org.lineageos.twelve.xml
new file mode 100644
index 0000000..707b544
--- /dev/null
+++ b/app/preinstalled-packages-org.lineageos.twelve.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<config>
+ <install-in-user-type package="org.lineageos.twelve">
+ <install-in user-type="FULL" />
+ <install-in user-type="PROFILE" />
+ <do-not-install-in user-type="android.os.usertype.profile.CLONE" />
+ </install-in-user-type>
+</config>
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..d050ee4
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: 2024 The LineageOS Project
+# SPDX-License-Identifier: Apache-2.0
+
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f077570
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="org.lineageos.twelve">
+
+ <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission
+ android:name="android.permission.MANAGE_MEDIA"
+ tools:ignore="ProtectedPermissions" />
+ <uses-permission
+ android:name="android.permission.READ_EXTERNAL_STORAGE"
+ android:maxSdkVersion="32" />
+ <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
+ <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+
+ <application
+ android:name=".TwelveApplication"
+ android:appCategory="audio"
+ android:enableOnBackInvokedCallback="true"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.Twelve"
+ tools:targetApi="tiramisu">
+
+ <activity
+ android:name=".MainActivity"
+ android:exported="true">
+
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+
+ <category android:name="android.intent.category.LAUNCHER" />
+ <category android:name="android.intent.category.APP_MUSIC" />
+ </intent-filter>
+
+ </activity>
+
+ <service
+ android:name=".services.PlaybackService"
+ android:exported="true"
+ android:foregroundServiceType="mediaPlayback"
+ android:label="@string/app_name">
+
+ <intent-filter>
+ <action android:name="androidx.media3.session.MediaLibraryService" />
+ </intent-filter>
+
+ </service>
+
+ </application>
+
+</manifest>
diff --git a/app/src/main/java/org/lineageos/twelve/MainActivity.kt b/app/src/main/java/org/lineageos/twelve/MainActivity.kt
new file mode 100644
index 0000000..8b4d587
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/MainActivity.kt
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve
+
+import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+
+class MainActivity : AppCompatActivity(R.layout.activity_main) {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Enable edge-to-edge
+ enableEdgeToEdge()
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/TwelveApplication.kt b/app/src/main/java/org/lineageos/twelve/TwelveApplication.kt
new file mode 100644
index 0000000..8d4cf4e
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/TwelveApplication.kt
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve
+
+import android.app.Application
+import com.google.android.material.color.DynamicColors
+import org.lineageos.twelve.repositories.MediaRepository
+
+class TwelveApplication : Application() {
+ val mediaRepository by lazy { MediaRepository(this) }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // Observe dynamic colors changes
+ DynamicColors.applyToActivitiesIfAvailable(this)
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/LocalDataSource.kt b/app/src/main/java/org/lineageos/twelve/datasources/LocalDataSource.kt
new file mode 100644
index 0000000..9c25c52
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/LocalDataSource.kt
@@ -0,0 +1,383 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources
+
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import android.util.Size
+import androidx.core.database.getStringOrNull
+import androidx.core.os.bundleOf
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import org.lineageos.twelve.ext.mapEachRow
+import org.lineageos.twelve.ext.queryFlow
+import org.lineageos.twelve.models.Album
+import org.lineageos.twelve.models.Artist
+import org.lineageos.twelve.models.ArtistWorks
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.Genre
+import org.lineageos.twelve.models.Playlist
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.query.Query
+import org.lineageos.twelve.query.eq
+import org.lineageos.twelve.query.`in`
+import org.lineageos.twelve.query.like
+
+/**
+ * [MediaStore.Audio] backed data source.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class LocalDataSource(context: Context) : MediaDataSource {
+ private val contentResolver = context.contentResolver
+
+ private val albumsUri = MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
+ private val artistsUri = MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI
+ private val genresUri = MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI
+ private val audiosUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+
+ private val mapAlbum = { it: Cursor, indexCache: Array<Int> ->
+ var i = 0
+
+ val albumId = it.getLong(indexCache[i++])
+ val album = it.getString(indexCache[i++])
+ val artistId = it.getLong(indexCache[i++])
+ val lastYear = it.getInt(indexCache[i++])
+
+ val uri = ContentUris.withAppendedId(albumsUri, albumId)
+ val artistUri = ContentUris.withAppendedId(artistsUri, artistId)
+
+ val thumbnail = runCatching {
+ contentResolver.loadThumbnail(
+ uri, Size(512, 512), null
+ )
+ }.getOrNull()
+
+ Album(
+ uri,
+ album,
+ artistUri,
+ lastYear.takeIf { it != 0 },
+ thumbnail,
+ )
+ }
+
+ private val mapArtist = { it: Cursor, indexCache: Array<Int> ->
+ var i = 0
+
+ val artistId = it.getLong(indexCache[i++])
+ val artist = it.getString(indexCache[i++])
+
+ val uri = ContentUris.withAppendedId(artistsUri, artistId)
+
+ val thumbnail = runCatching {
+ contentResolver.loadThumbnail(
+ uri, Size(512, 512), null
+ )
+ }.getOrNull()
+
+ Artist(
+ uri,
+ artist,
+ thumbnail,
+ )
+ }
+
+ private val mapGenre = { it: Cursor, indexCache: Array<Int> ->
+ var i = 0
+
+ val genreId = it.getLong(indexCache[i++])
+ val name = it.getStringOrNull(indexCache[i++])
+
+ val uri = ContentUris.withAppendedId(genresUri, genreId)
+
+ Genre(
+ uri,
+ name,
+ )
+ }
+
+ private val mapAudio = { it: Cursor, indexCache: Array<Int> ->
+ var i = 0
+
+ val audioId = it.getLong(indexCache[i++])
+ val mimeType = it.getString(indexCache[i++])
+ val title = it.getString(indexCache[i++])
+ val isMusic = it.getInt(indexCache[i++]) != 0
+ val isPodcast = it.getInt(indexCache[i++]) != 0
+ val isAudiobook = it.getInt(indexCache[i++]) != 0
+ val duration = it.getInt(indexCache[i++])
+ val artistId = it.getLong(indexCache[i++])
+ val albumId = it.getLong(indexCache[i++])
+ val track = it.getInt(indexCache[i++])
+ val genreId = it.getLong(indexCache[i++])
+ val year = it.getInt(indexCache[i++])
+
+ val isRecording = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ it.getInt(indexCache[i++]) != 0
+ } else {
+ false
+ }
+
+ val uri = ContentUris.withAppendedId(audiosUri, audioId)
+ val artistUri = ContentUris.withAppendedId(artistsUri, artistId)
+ val albumUri = ContentUris.withAppendedId(albumsUri, albumId)
+ val genreUri = ContentUris.withAppendedId(genresUri, genreId)
+
+ val audioType = when {
+ isMusic -> Audio.Type.MUSIC
+ isPodcast -> Audio.Type.PODCAST
+ isAudiobook -> Audio.Type.AUDIOBOOK
+ isRecording -> Audio.Type.RECORDING
+ else -> Audio.Type.MUSIC
+ }
+
+ Audio(
+ uri,
+ mimeType,
+ title,
+ audioType,
+ duration,
+ artistUri,
+ albumUri,
+ track,
+ genreUri,
+ year,
+ )
+ }
+
+ override fun albums() = contentResolver.queryFlow(
+ albumsUri,
+ albumsProjection,
+ ).mapEachRow(albumsProjection, mapAlbum).map {
+ RequestStatus.Success(it)
+ }
+
+ override fun artists() = contentResolver.queryFlow(
+ artistsUri,
+ artistsProjection,
+ ).mapEachRow(artistsProjection, mapArtist).map {
+ RequestStatus.Success(it)
+ }
+
+ override fun genres() = contentResolver.queryFlow(
+ genresUri,
+ genresProjection,
+ ).mapEachRow(genresProjection, mapGenre).map {
+ RequestStatus.Success(it)
+ }
+
+ // Playlists are deprecated
+ override fun playlists() = flowOf(
+ RequestStatus.Success(listOf<Playlist>())
+ )
+
+ override fun search(query: String) = combine(
+ contentResolver.queryFlow(
+ albumsUri,
+ albumsProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AlbumColumns.ALBUM like Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(query),
+ )
+ ).mapEachRow(albumsProjection, mapAlbum),
+ contentResolver.queryFlow(
+ artistsUri,
+ artistsProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.ArtistColumns.ARTIST like Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(query),
+ )
+ ).mapEachRow(artistsProjection, mapArtist),
+ contentResolver.queryFlow(
+ audiosUri,
+ audiosProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns.TITLE like Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(query),
+ )
+ ).mapEachRow(audiosProjection, mapAudio),
+ contentResolver.queryFlow(
+ genresUri,
+ genresProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.GenresColumns.NAME like Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(query),
+ )
+ ).mapEachRow(genresProjection, mapGenre),
+ ) { albums, artists, audios, genres ->
+ albums + artists + audios + genres
+ }.map { RequestStatus.Success(it) }
+
+ override fun album(albumUri: Uri) = combine(
+ contentResolver.queryFlow(
+ albumsUri,
+ albumsProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns._ID eq Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(
+ ContentUris.parseId(albumUri).toString(),
+ ),
+ )
+ ).mapEachRow(albumsProjection, mapAlbum),
+ contentResolver.queryFlow(
+ audiosUri,
+ audiosProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns.ALBUM_ID eq Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(
+ ContentUris.parseId(albumUri).toString(),
+ ),
+ )
+ ).mapEachRow(audiosProjection, mapAudio)
+ ) { albums, audios ->
+ albums.firstOrNull()?.let {
+ RequestStatus.Success(Pair(it, audios))
+ } ?: RequestStatus.Error(RequestStatus.Error.Type.NOT_FOUND)
+ }
+
+ override fun artist(artistUri: Uri) = combine(
+ contentResolver.queryFlow(
+ artistsUri,
+ artistsProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns._ID eq Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(
+ ContentUris.parseId(artistUri).toString(),
+ ),
+ )
+ ).mapEachRow(artistsProjection, mapArtist),
+ contentResolver.queryFlow(
+ audiosUri,
+ albumsOfArtistProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns.ARTIST_ID eq Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(
+ ContentUris.parseId(artistUri).toString(),
+ ),
+ ContentResolver.QUERY_ARG_SQL_GROUP_BY to MediaStore.Audio.AudioColumns.ALBUM_ID,
+ )
+ ).mapEachRow(albumsOfArtistProjection) { it, indexCache ->
+ // albumId
+ it.getLong(indexCache[0])
+ }.flatMapLatest { albumIds ->
+ contentResolver.queryFlow(
+ albumsUri,
+ albumsProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns._ID `in` List(albumIds.size) {
+ Query.ARG
+ }).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to
+ albumIds
+ .map { it.toString() }
+ .toTypedArray(),
+ )
+ ).mapEachRow(albumsProjection, mapAlbum)
+ }
+ ) { artists, audios ->
+ artists.firstOrNull()?.let {
+ val artistWorks = ArtistWorks(
+ audios,
+ listOf(),
+ )
+
+ RequestStatus.Success(Pair(it, artistWorks))
+ } ?: RequestStatus.Error(RequestStatus.Error.Type.NOT_FOUND)
+ }
+
+ override fun genre(genreUri: Uri) = combine(
+ contentResolver.queryFlow(
+ genresUri,
+ genresProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns._ID eq Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(
+ ContentUris.parseId(genreUri).toString(),
+ ),
+ )
+ ).mapEachRow(genresProjection, mapGenre),
+ contentResolver.queryFlow(
+ audiosUri,
+ audiosProjection,
+ bundleOf(
+ ContentResolver.QUERY_ARG_SQL_SELECTION to
+ (MediaStore.Audio.AudioColumns.GENRE_ID eq Query.ARG).build(),
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(
+ ContentUris.parseId(genreUri).toString(),
+ ),
+ )
+ ).mapEachRow(audiosProjection, mapAudio)
+ ) { genres, audios ->
+ genres.firstOrNull()?.let {
+ RequestStatus.Success(Pair(it, audios))
+ } ?: RequestStatus.Error(RequestStatus.Error.Type.NOT_FOUND)
+ }
+
+ // Playlists are deprecated, we shouldn't reach this point
+ override fun playlist(playlistUri: Uri) = flowOf(
+ RequestStatus.Error<Pair<Playlist, List<Audio>>>(RequestStatus.Error.Type.NOT_FOUND)
+ )
+
+ companion object {
+ private val albumsProjection = arrayOf(
+ MediaStore.Audio.AudioColumns._ID,
+ MediaStore.Audio.AlbumColumns.ALBUM,
+ MediaStore.Audio.AlbumColumns.ARTIST_ID,
+ MediaStore.Audio.AlbumColumns.LAST_YEAR,
+ )
+
+ private val artistsProjection = arrayOf(
+ MediaStore.Audio.AudioColumns._ID,
+ MediaStore.Audio.ArtistColumns.ARTIST,
+ )
+
+ private val genresProjection = arrayOf(
+ MediaStore.Audio.AudioColumns._ID,
+ MediaStore.Audio.GenresColumns.NAME,
+ )
+
+ private val audiosProjection = mutableListOf(
+ MediaStore.Audio.AudioColumns._ID,
+ MediaStore.Audio.AudioColumns.MIME_TYPE,
+ MediaStore.Audio.AudioColumns.TITLE,
+ MediaStore.Audio.AudioColumns.IS_MUSIC,
+ MediaStore.Audio.AudioColumns.IS_PODCAST,
+ MediaStore.Audio.AudioColumns.IS_AUDIOBOOK,
+ MediaStore.Audio.AudioColumns.DURATION,
+ MediaStore.Audio.AudioColumns.ARTIST_ID,
+ MediaStore.Audio.AudioColumns.ALBUM_ID,
+ MediaStore.Audio.AudioColumns.TRACK,
+ MediaStore.Audio.AudioColumns.GENRE_ID,
+ MediaStore.Audio.AudioColumns.YEAR,
+ ).apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ add(MediaStore.Audio.AudioColumns.IS_RECORDING)
+ }
+ }.toTypedArray()
+
+ private val albumsOfArtistProjection = arrayOf(
+ MediaStore.Audio.AudioColumns.ALBUM_ID,
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/MediaDataSource.kt b/app/src/main/java/org/lineageos/twelve/datasources/MediaDataSource.kt
new file mode 100644
index 0000000..e8ed8fd
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/MediaDataSource.kt
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources
+
+import android.net.Uri
+import kotlinx.coroutines.flow.Flow
+import org.lineageos.twelve.models.Album
+import org.lineageos.twelve.models.Artist
+import org.lineageos.twelve.models.ArtistWorks
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.Genre
+import org.lineageos.twelve.models.Playlist
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.models.UniqueItem
+
+/**
+ * A data source for media.
+ */
+interface MediaDataSource {
+ /**
+ * Get all the albums. All albums must have at least one audio associated with them.
+ */
+ fun albums(): Flow<RequestStatus<List<Album>>>
+
+ /**
+ * Get all the artists. All artists must have at least one audio associated with them.
+ */
+ fun artists(): Flow<RequestStatus<List<Artist>>>
+
+ /**
+ * Get all the genres. All genres must have at least one audio associated with them.
+ */
+ fun genres(): Flow<RequestStatus<List<Genre>>>
+
+ /**
+ * Get all the playlists. A playlist can be empty.
+ */
+ fun playlists(): Flow<RequestStatus<List<Playlist>>>
+
+ /**
+ * Start a search for the given query.
+ * Only the following items can be returned: [Album], [Artist], [Audio], [Genre], [Playlist].
+ */
+ fun search(query: String): Flow<RequestStatus<List<UniqueItem<*>>>>
+
+ /**
+ * Get the album information and all the tracks of the given album.
+ */
+ fun album(albumUri: Uri): Flow<RequestStatus<Pair<Album, List<Audio>>>>
+
+ /**
+ * Get the artist information and all the works associated with them.
+ */
+ fun artist(artistUri: Uri): Flow<RequestStatus<Pair<Artist, ArtistWorks>>>
+
+ /**
+ * Get the genre information and all the tracks of the given genre.
+ */
+ fun genre(genreUri: Uri): Flow<RequestStatus<Pair<Genre, List<Audio>>>>
+
+ /**
+ * Get the playlist information and all the tracks of the given playlist.
+ */
+ fun playlist(playlistUri: Uri): Flow<RequestStatus<Pair<Playlist, List<Audio>>>>
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ext/AndroidViewModel.kt b/app/src/main/java/org/lineageos/twelve/ext/AndroidViewModel.kt
new file mode 100644
index 0000000..157a573
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/AndroidViewModel.kt
@@ -0,0 +1,13 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.app.Application
+import android.content.Context
+import androidx.lifecycle.AndroidViewModel
+
+val AndroidViewModel.applicationContext: Context
+ get() = getApplication<Application>().applicationContext
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Array.kt b/app/src/main/java/org/lineageos/twelve/ext/Array.kt
new file mode 100644
index 0000000..9f3896f
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Array.kt
@@ -0,0 +1,32 @@
+/*
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+/**
+ * Get the next element in the array relative to the [current] element.
+ *
+ * If the element is the last in the array or it's not present in the array
+ * it will return the first element.
+ * If the array is empty, null will be returned.
+ *
+ * @param current The element to use as cursor
+ *
+ * @return [T] Either the next element, the first element or null
+ */
+fun <T> Array<T>.next(current: T) = getOrElse(indexOf(current) + 1) { firstOrNull() }
+
+/**
+ * Get the previous element in the array relative to the [current] element.
+ *
+ * If the element is the first in the array or it's not present in the array
+ * it will return the last element.
+ * If the array is empty, null will be returned.
+ *
+ * @param current The element to use as cursor
+ *
+ * @return [T] Either the previous element, the last element or null
+ */
+fun <T> Array<T>.previous(current: T) = getOrElse(indexOf(current) - 1) { lastOrNull() }
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Bundle.kt b/app/src/main/java/org/lineageos/twelve/ext/Bundle.kt
new file mode 100644
index 0000000..7f72030
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Bundle.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import java.io.Serializable
+import kotlin.reflect.KClass
+import kotlin.reflect.safeCast
+
+fun <T : Parcelable> Bundle.getParcelable(key: String?, clazz: KClass<T>) =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelable(key, clazz.java)
+ } else {
+ @Suppress("DEPRECATION")
+ getParcelable(key)
+ }
+
+inline fun <reified T : Parcelable> Bundle.getParcelableArray(
+ key: String?, clazz: KClass<T>
+): Array<T>? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableArray(key, clazz.java)
+ } else {
+ @Suppress("DEPRECATION")
+ getParcelableArray(key)?.let { parcelableArray ->
+ parcelableArray.mapNotNull { parcelable ->
+ T::class.safeCast(parcelable)
+ }.toTypedArray().takeIf { it.size == parcelableArray.size }
+ }
+ }
+
+inline fun <reified T : Serializable> Bundle.getSerializable(key: String?, clazz: KClass<T>) =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getSerializable(key, clazz.java)
+ } else {
+ @Suppress("DEPRECATION")
+ T::class.safeCast(getSerializable(key))
+ }
diff --git a/app/src/main/java/org/lineageos/twelve/ext/ContentResolver.kt b/app/src/main/java/org/lineageos/twelve/ext/ContentResolver.kt
new file mode 100644
index 0000000..dd49955
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/ContentResolver.kt
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.content.ContentResolver
+import android.database.ContentObserver
+import android.net.Uri
+import android.os.Bundle
+import android.os.CancellationSignal
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+fun ContentResolver.queryFlow(
+ uri: Uri,
+ projection: Array<String>? = null,
+ queryArgs: Bundle? = Bundle(),
+) = callbackFlow {
+ // Each query will have its own cancellationSignal.
+ // Before running any new query the old cancellationSignal must be cancelled
+ // to ensure the currently running query gets interrupted so that we don't
+ // send data across the channel if we know we received a newer set of data.
+ var cancellationSignal = CancellationSignal()
+ // ContentObserver.onChange can be called concurrently so make sure
+ // access to the cancellationSignal is synchronized.
+ val mutex = Mutex()
+
+ val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
+ override fun onChange(selfChange: Boolean) {
+ launch(Dispatchers.IO) {
+ mutex.withLock {
+ cancellationSignal.cancel()
+ cancellationSignal = CancellationSignal()
+ }
+ runCatching {
+ trySend(query(uri, projection, queryArgs, cancellationSignal))
+ }
+ }
+ }
+ }
+
+ registerContentObserver(uri, true, observer)
+
+ // The first set of values must always be generated and cannot (shouldn't) be cancelled.
+ launch(Dispatchers.IO) {
+ runCatching {
+ trySend(
+ query(uri, projection, queryArgs, null)
+ )
+ }.onFailure {
+ Log.e("ContentResolver", "Failed to query $uri", it)
+ }
+ }
+
+ awaitClose {
+ // Stop receiving content changes.
+ unregisterContentObserver(observer)
+ // Cancel any possibly running query.
+ cancellationSignal.cancel()
+ }
+}.conflate()
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Context.kt b/app/src/main/java/org/lineageos/twelve/ext/Context.kt
new file mode 100644
index 0000000..0d60cb9
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Context.kt
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+
+fun Context.permissionGranted(permission: String) =
+ ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+
+fun Context.permissionsGranted(permissions: Array<String>) = permissions.all {
+ permissionGranted(it)
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Cursor.kt b/app/src/main/java/org/lineageos/twelve/ext/Cursor.kt
new file mode 100644
index 0000000..be33cb4
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Cursor.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.database.Cursor
+
+fun <T> Cursor?.mapEachRow(
+ projection: Array<String>? = null,
+ mapping: (Cursor, Array<Int>) -> T,
+) = this?.use { cursor ->
+ if (!cursor.moveToFirst()) {
+ return@use emptyList<T>()
+ }
+
+ val indexCache = projection?.map { column ->
+ cursor.getColumnIndexOrThrow(column)
+ }?.toTypedArray() ?: arrayOf()
+
+ val data = mutableListOf<T>()
+ do {
+ data.add(mapping(cursor, indexCache))
+ } while (cursor.moveToNext())
+
+ data.toList()
+} ?: emptyList()
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Enum.kt b/app/src/main/java/org/lineageos/twelve/ext/Enum.kt
new file mode 100644
index 0000000..dadf380
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Enum.kt
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+/**
+ * Get the previous value.
+ */
+internal inline fun <reified E : Enum<E>> E.previous() = enumValues<E>().previous(
+ this
+) ?: throw Exception("No enum values")
+
+/**
+ * Get the next value.
+ */
+internal inline fun <reified E : Enum<E>> E.next() = enumValues<E>().next(
+ this
+) ?: throw Exception("No enum values")
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Flow.kt b/app/src/main/java/org/lineageos/twelve/ext/Flow.kt
new file mode 100644
index 0000000..43de181
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Flow.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.database.Cursor
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+fun <T> Flow<Cursor?>.mapEachRow(
+ projection: Array<String>,
+ mapping: (Cursor, Array<Int>) -> T,
+) = map { it.mapEachRow(projection, mapping) }
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Fragment.kt b/app/src/main/java/org/lineageos/twelve/ext/Fragment.kt
new file mode 100644
index 0000000..ba775b5
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Fragment.kt
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.view.View
+import androidx.annotation.IdRes
+import androidx.fragment.app.Fragment
+import kotlin.properties.ReadOnlyProperty
+
+inline fun <reified T : View?> getViewProperty(@IdRes viewId: Int) =
+ ReadOnlyProperty<Fragment, T> { thisRef, _ ->
+ thisRef.requireView().findViewById<T>(viewId)
+ }
diff --git a/app/src/main/java/org/lineageos/twelve/ext/InputMethodManager.kt b/app/src/main/java/org/lineageos/twelve/ext/InputMethodManager.kt
new file mode 100644
index 0000000..75df7d7
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/InputMethodManager.kt
@@ -0,0 +1,79 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+
+private const val SHOW_REQUEST_TIMEOUT = 1000
+
+private fun InputMethodManager.scheduleShowSoftInput(
+ view: View,
+ flags: Int,
+ runnable: Runnable,
+ showRequestTime: Long,
+) {
+ if (!view.hasFocus()
+ || (showRequestTime + SHOW_REQUEST_TIMEOUT) <= System.currentTimeMillis()
+ ) {
+ return
+ }
+
+ if (showSoftInput(view, flags)) {
+ return
+ } else {
+ view.removeCallbacks(runnable)
+ view.postDelayed(runnable, 50)
+ }
+}
+
+private fun InputMethodManager.scheduleHideSoftInput(
+ view: View,
+ flags: Int,
+ runnable: Runnable,
+ showRequestTime: Long,
+) {
+ if ((showRequestTime + SHOW_REQUEST_TIMEOUT) <= System.currentTimeMillis()) {
+ return
+ }
+
+ if (hideSoftInputFromWindow(view.windowToken, flags)) {
+ return
+ } else {
+ view.removeCallbacks(runnable)
+ view.postDelayed(runnable, 50)
+ }
+}
+
+/**
+ * @see InputMethodManager.showSoftInput
+ */
+fun InputMethodManager.scheduleShowSoftInput(view: View, flags: Int) {
+ val currentTimeMillis = System.currentTimeMillis()
+
+ val runnable = object : Runnable {
+ override fun run() {
+ scheduleShowSoftInput(view, flags, this, currentTimeMillis)
+ }
+ }
+
+ runnable.run()
+}
+
+/**
+ * @see InputMethodManager.hideSoftInputFromWindow
+ */
+fun InputMethodManager.scheduleHideSoftInput(view: View, flags: Int) {
+ val currentTimeMillis = System.currentTimeMillis()
+
+ val runnable = object : Runnable {
+ override fun run() {
+ scheduleHideSoftInput(view, flags, this, currentTimeMillis)
+ }
+ }
+
+ runnable.run()
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ext/LinearProgressIndicator.kt b/app/src/main/java/org/lineageos/twelve/ext/LinearProgressIndicator.kt
new file mode 100644
index 0000000..a3452ff
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/LinearProgressIndicator.kt
@@ -0,0 +1,35 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import com.google.android.material.progressindicator.LinearProgressIndicator
+import org.lineageos.twelve.models.RequestStatus
+
+/**
+ * @see LinearProgressIndicator.setProgressCompat
+ */
+fun <T : Any> LinearProgressIndicator.setProgressCompat(
+ status: RequestStatus<T>?, animated: Boolean
+) {
+ when (status) {
+ is RequestStatus.Loading -> {
+ status.progress?.also {
+ setProgressCompat(it, animated)
+ } ?: run {
+ if (!isIndeterminate) {
+ hide()
+ isIndeterminate = true
+ }
+ }
+
+ show()
+ }
+
+ else -> {
+ hide()
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ext/List.kt b/app/src/main/java/org/lineageos/twelve/ext/List.kt
new file mode 100644
index 0000000..baa8de5
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/List.kt
@@ -0,0 +1,32 @@
+/*
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+/**
+ * Get the next element in the list relative to the [current] element.
+ *
+ * If the element is the last in the list or it's not present in the list
+ * it will return the first element.
+ * If the list is empty, null will be returned.
+ *
+ * @param current The element to use as cursor
+ *
+ * @return [E] Either the next element, the first element or null
+ */
+fun <E> List<E>.next(current: E) = getOrElse(indexOf(current) + 1) { firstOrNull() }
+
+/**
+ * Get the previous element in the list relative to the [current] element.
+ *
+ * If the element is the first in the list or it's not present in the list
+ * it will return the last element.
+ * If the list is empty, null will be returned.
+ *
+ * @param current The element to use as cursor
+ *
+ * @return [E] Either the previous element, the last element or null
+ */
+fun <E> List<E>.previous(current: E) = getOrElse(indexOf(current) - 1) { lastOrNull() }
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Parcelable.kt b/app/src/main/java/org/lineageos/twelve/ext/Parcelable.kt
new file mode 100644
index 0000000..bd6c415
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Parcelable.kt
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.os.Build
+import android.os.Parcel
+import android.os.Parcelable
+import kotlin.reflect.KClass
+
+fun <T : Parcelable> Parcel.readParcelable(clazz: KClass<T>) =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ readParcelable(clazz.java.classLoader, clazz.java)
+ } else {
+ @Suppress("DEPRECATION")
+ readParcelable(clazz.java.classLoader)
+ }
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Player.kt b/app/src/main/java/org/lineageos/twelve/ext/Player.kt
new file mode 100644
index 0000000..9da948d
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Player.kt
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import androidx.media3.common.C
+import androidx.media3.common.Player
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import org.lineageos.twelve.models.PlaybackStatus
+import org.lineageos.twelve.models.RepeatMode
+
+fun Player.playbackStatusFlow() = callbackFlow {
+ val updatePlaybackStatus = {
+ val duration = duration.takeIf { it != C.TIME_UNSET }
+
+ val playbackStatus = PlaybackStatus(
+ currentMediaItem,
+ mediaMetadata,
+ duration,
+ currentPosition.takeIf { duration != null },
+ isPlaying,
+ shuffleModeEnabled,
+ typedRepeatMode,
+ )
+
+ trySend(playbackStatus)
+ }
+
+ val listener = object : Player.Listener {
+ override fun onEvents(player: Player, events: Player.Events) {
+ super.onEvents(player, events)
+
+ updatePlaybackStatus()
+ }
+ }
+
+ addListener(listener)
+ updatePlaybackStatus()
+
+ awaitClose {
+ removeListener(listener)
+ }
+}
+
+var Player.typedRepeatMode: RepeatMode
+ get() = when (repeatMode) {
+ Player.REPEAT_MODE_OFF -> RepeatMode.NONE
+ Player.REPEAT_MODE_ONE -> RepeatMode.ONE
+ Player.REPEAT_MODE_ALL -> RepeatMode.ALL
+ else -> throw Exception("Unknown repeat mode")
+ }
+ set(value) {
+ repeatMode = when (value) {
+ RepeatMode.NONE -> Player.REPEAT_MODE_OFF
+ RepeatMode.ONE -> Player.REPEAT_MODE_ONE
+ RepeatMode.ALL -> Player.REPEAT_MODE_ALL
+ }
+ }
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/ActivityFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/ActivityFragment.kt
new file mode 100644
index 0000000..ab49cc5
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/ActivityFragment.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.fragment.app.Fragment
+import org.lineageos.twelve.R
+import org.lineageos.twelve.utils.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+
+/**
+ * User activity, notifications and recommendations.
+ */
+class ActivityFragment : Fragment(R.layout.fragment_activity) {
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ // Do nothing
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/AlbumFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/AlbumFragment.kt
new file mode 100644
index 0000000..5dff1e0
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/AlbumFragment.kt
@@ -0,0 +1,184 @@
+/*
+ * 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 androidx.core.os.bundleOf
+import androidx.core.view.isVisible
+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.AppBarLayout
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.progressindicator.LinearProgressIndicator
+import com.google.android.material.shape.MaterialShapeDrawable
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.getParcelable
+import org.lineageos.twelve.ext.getViewProperty
+import org.lineageos.twelve.ext.setProgressCompat
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter
+import org.lineageos.twelve.ui.recyclerview.UniqueItemDiffCallback
+import org.lineageos.twelve.ui.views.ListItem
+import org.lineageos.twelve.utils.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.utils.TimestampFormatter
+import org.lineageos.twelve.viewmodels.AlbumViewModel
+
+/**
+ * Single music album viewer.
+ */
+class AlbumFragment : Fragment(R.layout.fragment_album) {
+ // View models
+ private val viewModel by viewModels<AlbumViewModel>()
+
+ // Views
+ private val appBarLayout by getViewProperty<AppBarLayout>(R.id.appBarLayout)
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val noElementsLinearLayout by getViewProperty<LinearLayout>(R.id.noElementsLinearLayout)
+ private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+ private val thumbnailImageView by getViewProperty<ImageView>(R.id.thumbnailImageView)
+ private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
+
+ // Recyclerview
+ private val adapter by lazy {
+ object : SimpleListAdapter<Audio, ListItem>(
+ UniqueItemDiffCallback(),
+ ListItem::class.java,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setLeadingIconImage(R.drawable.ic_music_note)
+ view.setOnClickListener {
+ item?.let {
+ viewModel.playAudio(it)
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Audio) {
+ view.headlineText = item.title
+ view.supportingText = item.artistUri.toString()
+ view.trailingSupportingText = TimestampFormatter.formatTimestampMillis(
+ item.durationMs
+ )
+ }
+ }
+ }
+
+ // Arguments
+ private val albumUri: Uri
+ get() = requireArguments().getParcelable(ARG_ALBUM_URI, Uri::class)!!
+
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ loadData()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ appBarLayout.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context)
+ toolbar.setupWithNavController(findNavController())
+
+ recyclerView.adapter = adapter
+
+ viewModel.loadAlbum(albumUri)
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+
+ override fun onDestroyView() {
+ recyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+
+ private fun loadData() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.album.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ null -> {
+ adapter.submitList(listOf())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = false
+ }
+
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ val (album, audios) = it.data
+
+ toolbar.title = album.title
+
+ launch {
+ thumbnailImageView.setImageBitmap(album.thumbnail)
+ }
+
+ adapter.submitList(audios)
+
+ val isEmpty = audios.isEmpty()
+ recyclerView.isVisible = !isEmpty
+ noElementsLinearLayout.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Error loading album, error: ${it.type}")
+
+ toolbar.title = ""
+
+ adapter.submitList(listOf())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = true
+
+ if (it.type == RequestStatus.Error.Type.NOT_FOUND) {
+ // Get out of here
+ findNavController().navigateUp()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val LOG_TAG = AlbumFragment::class.simpleName!!
+
+ private const val ARG_ALBUM_URI = "album_uri"
+
+ /**
+ * Create a [Bundle] to use as the arguments for this fragment.
+ * @param albumUri The URI of the album to display
+ */
+ fun createBundle(
+ albumUri: Uri,
+ ) = bundleOf(
+ ARG_ALBUM_URI to albumUri,
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/AlbumsFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/AlbumsFragment.kt
new file mode 100644
index 0000000..ee4d2e0
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/AlbumsFragment.kt
@@ -0,0 +1,136 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.LinearLayout
+import androidx.core.view.isVisible
+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.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+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.ext.getViewProperty
+import org.lineageos.twelve.ext.setProgressCompat
+import org.lineageos.twelve.models.Album
+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.ListItem
+import org.lineageos.twelve.utils.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.viewmodels.AlbumsViewModel
+
+/**
+ * View all music albums.
+ */
+class AlbumsFragment : Fragment(R.layout.fragment_albums) {
+ // View models
+ private val viewModel by viewModels<AlbumsViewModel>()
+
+ // Views
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val noElementsLinearLayout by getViewProperty<LinearLayout>(R.id.noElementsLinearLayout)
+ private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+
+ // Recyclerview
+ private val adapter by lazy {
+ object : SimpleListAdapter<Album, ListItem>(
+ UniqueItemDiffCallback(),
+ ListItem::class.java,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setOnClickListener {
+ item?.let {
+ findNavController().navigate(
+ R.id.action_mainFragment_to_fragment_album,
+ AlbumFragment.createBundle(it.uri)
+ )
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Album) {
+ view.headlineText = item.title
+ view.supportingText = item.uri.toString()
+ item.thumbnail?.let {
+ view.setLeadingIconImage(it)
+ } ?: run {
+ view.setLeadingIconImage(R.drawable.ic_album)
+ }
+ }
+ }
+ }
+
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ loadData()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ recyclerView.adapter = adapter
+ recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+
+ override fun onDestroyView() {
+ recyclerView.adapter = null
+ recyclerView.layoutManager = null
+
+ super.onDestroyView()
+ }
+
+ private fun loadData() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.albums.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ adapter.submitList(it.data)
+
+ val isEmpty = it.data.isEmpty()
+ recyclerView.isVisible = !isEmpty
+ noElementsLinearLayout.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Failed to load albums, error: ${it.type}")
+
+ adapter.submitList(emptyList())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val LOG_TAG = AlbumsFragment::class.simpleName!!
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/ArtistFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/ArtistFragment.kt
new file mode 100644
index 0000000..7ff00a9
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/ArtistFragment.kt
@@ -0,0 +1,178 @@
+/*
+ * 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.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.core.os.bundleOf
+import androidx.core.view.isVisible
+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.AppBarLayout
+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.ext.getParcelable
+import org.lineageos.twelve.ext.getViewProperty
+import org.lineageos.twelve.ext.setProgressCompat
+import org.lineageos.twelve.models.Album
+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.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.viewmodels.ArtistViewModel
+
+/**
+ * Single artist viewer.
+ */
+class ArtistFragment : Fragment(R.layout.fragment_artist) {
+ // View models
+ private val viewModel by viewModels<ArtistViewModel>()
+
+ // Views
+ private val albumsLinearLayout by getViewProperty<LinearLayout>(R.id.albumsLinearLayout)
+ private val albumsRecyclerView by getViewProperty<RecyclerView>(R.id.albumsRecyclerView)
+ private val appBarLayout by getViewProperty<AppBarLayout>(R.id.appBarLayout)
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val noElementsLinearLayout by getViewProperty<LinearLayout>(R.id.noElementsLinearLayout)
+ private val thumbnailImageView by getViewProperty<ImageView>(R.id.thumbnailImageView)
+ private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
+
+ // Recyclerview
+ private val albumsAdapter by lazy {
+ object : SimpleListAdapter<Album, HorizontalListItem>(
+ UniqueItemDiffCallback(),
+ HorizontalListItem::class.java,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setOnClickListener {
+ item?.let {
+ findNavController().navigate(
+ R.id.action_artistFragment_to_fragment_album,
+ AlbumFragment.createBundle(it.uri)
+ )
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Album) {
+ item.thumbnail?.let {
+ view.setThumbnailImage(it)
+ } ?: view.setThumbnailImage(R.drawable.ic_album)
+
+ view.headlineText = item.title
+ view.supportingText = item.year?.toString()
+ }
+ }
+ }
+
+ // Arguments
+ private val artistUri: Uri
+ get() = requireArguments().getParcelable(ARG_ARTIST_URI, Uri::class)!!
+
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ loadData()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ toolbar.setupWithNavController(findNavController())
+
+ albumsRecyclerView.adapter = albumsAdapter
+
+ viewModel.loadAlbum(artistUri)
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+
+ override fun onDestroyView() {
+ albumsRecyclerView.adapter = null
+ albumsRecyclerView.layoutManager = null
+
+ super.onDestroyView()
+ }
+
+ private fun loadData() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.artist.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ null -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ val (artist, artistWorks) = it.data
+
+ toolbar.title = artist.name
+
+ launch {
+ thumbnailImageView.setImageBitmap(artist.thumbnail)
+ }
+
+ albumsAdapter.submitList(artistWorks.albums)
+
+ val isAlbumsEmpty = artistWorks.albums.isEmpty()
+ albumsLinearLayout.isVisible = !isAlbumsEmpty
+
+ val isPlaylistsEmpty = artistWorks.playlists.isEmpty()
+
+ val isEmpty = listOf(
+ isAlbumsEmpty,
+ isPlaylistsEmpty,
+ ).all { isEmpty -> isEmpty }
+ noElementsLinearLayout.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ if (it.type == RequestStatus.Error.Type.NOT_FOUND) {
+ // Get out of here
+ findNavController().navigateUp()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val ARG_ARTIST_URI = "artist_uri"
+
+ /**
+ * Create a [Bundle] to use as the arguments for this fragment.
+ * @param artistUri The URI of the artist to display
+ */
+ fun createBundle(
+ artistUri: Uri,
+ ) = bundleOf(
+ ARG_ARTIST_URI to artistUri,
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/ArtistsFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/ArtistsFragment.kt
new file mode 100644
index 0000000..2ef7bf7
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/ArtistsFragment.kt
@@ -0,0 +1,129 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.LinearLayout
+import androidx.core.view.isVisible
+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.recyclerview.widget.RecyclerView
+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.ext.getViewProperty
+import org.lineageos.twelve.ext.setProgressCompat
+import org.lineageos.twelve.models.Artist
+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.ListItem
+import org.lineageos.twelve.utils.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.viewmodels.ArtistsViewModel
+
+/**
+ * View all music artists.
+ */
+class ArtistsFragment : Fragment(R.layout.fragment_artists) {
+ // View models
+ private val viewModel by viewModels<ArtistsViewModel>()
+
+ // Views
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val noElementsLinearLayout by getViewProperty<LinearLayout>(R.id.noElementsLinearLayout)
+ private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+
+ // Recyclerview
+ private val adapter by lazy {
+ object : SimpleListAdapter<Artist, ListItem>(
+ UniqueItemDiffCallback(),
+ ListItem::class.java,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setLeadingIconImage(R.drawable.ic_person)
+ view.setOnClickListener {
+ item?.let {
+ findNavController().navigate(
+ R.id.action_mainFragment_to_fragment_artist,
+ ArtistFragment.createBundle(it.uri)
+ )
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Artist) {
+ view.headlineText = item.name
+ view.supportingText = item.uri.toString()
+ }
+ }
+ }
+
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ loadData()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ recyclerView.adapter = adapter
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+
+ override fun onDestroyView() {
+ recyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+
+ private fun loadData() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.artists.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ adapter.submitList(it.data)
+
+ val isEmpty = it.data.isEmpty()
+ recyclerView.isVisible = !isEmpty
+ noElementsLinearLayout.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Failed to load artists, error: ${it.type}")
+
+ adapter.submitList(emptyList())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val LOG_TAG = ArtistsFragment::class.simpleName!!
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/GenresFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/GenresFragment.kt
new file mode 100644
index 0000000..6570106
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/GenresFragment.kt
@@ -0,0 +1,127 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.LinearLayout
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.RecyclerView
+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.ext.getViewProperty
+import org.lineageos.twelve.ext.setProgressCompat
+import org.lineageos.twelve.models.Genre
+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.ListItem
+import org.lineageos.twelve.utils.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.viewmodels.GenresViewModel
+
+/**
+ * View all music genres.
+ */
+class GenresFragment : Fragment(R.layout.fragment_genres) {
+ // View models
+ private val viewModel by viewModels<GenresViewModel>()
+
+ // Views
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val noElementsLinearLayout by getViewProperty<LinearLayout>(R.id.noElementsLinearLayout)
+ private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+
+ // Recyclerview
+ private val adapter by lazy {
+ object : SimpleListAdapter<Genre, ListItem>(
+ UniqueItemDiffCallback(),
+ ListItem::class.java,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setLeadingIconImage(R.drawable.ic_genres)
+ view.setOnClickListener {
+ // TODO: Open genre fragment
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Genre) {
+ item.name?.let {
+ view.headlineText = it
+ } ?: run {
+ view.headlineText = getString(R.string.genre_unknown)
+ }
+ view.supportingText = item.uri.toString()
+ }
+ }
+ }
+
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ loadData()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ recyclerView.adapter = adapter
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+
+ override fun onDestroyView() {
+ recyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+
+ private fun loadData() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.genres.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ adapter.submitList(it.data)
+
+ val isEmpty = it.data.isEmpty()
+ recyclerView.isVisible = !isEmpty
+ noElementsLinearLayout.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Failed to load genres, error: ${it.type}")
+
+ adapter.submitList(emptyList())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val LOG_TAG = GenresFragment::class.simpleName!!
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/LibraryFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/LibraryFragment.kt
new file mode 100644
index 0000000..aa0bb23
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/LibraryFragment.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.fragment.app.Fragment
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.getViewProperty
+
+/**
+ * Music library.
+ */
+class LibraryFragment : Fragment(R.layout.fragment_library) {
+ // Views
+ private val tabLayout by getViewProperty<TabLayout>(R.id.tabLayout)
+ private val viewPager2 by getViewProperty<ViewPager2>(R.id.viewPager2)
+
+ // ViewPager2
+ private enum class Menus(
+ @StringRes val titleStringResId: Int,
+ @DrawableRes val iconDrawableResId: Int,
+ val fragment: () -> Fragment,
+ ) {
+ ALBUMS(
+ R.string.library_fragment_menu_albums,
+ R.drawable.ic_album,
+ { AlbumsFragment() },
+ ),
+ ARTISTS(
+ R.string.library_fragment_menu_artists,
+ R.drawable.ic_person,
+ { ArtistsFragment() },
+ ),
+ GENRES(
+ R.string.library_fragment_menu_genres,
+ R.drawable.ic_genres,
+ { GenresFragment() },
+ ),
+ PLAYLISTS(
+ R.string.library_fragment_menu_playlists,
+ R.drawable.ic_playlist_play,
+ { PlaylistsFragment() },
+ ),
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewPager2.adapter = object : FragmentStateAdapter(this) {
+ override fun getItemCount() = Menus.entries.size
+ override fun createFragment(position: Int) = Menus.entries[position].fragment()
+ }
+
+ TabLayoutMediator(tabLayout, viewPager2) { tab, position ->
+ val menu = Menus.entries[position]
+
+ tab.setText(menu.titleStringResId)
+ tab.setContentDescription(menu.titleStringResId)
+ tab.setIcon(menu.iconDrawableResId)
+ }.attach()
+ }
+
+ override fun onDestroyView() {
+ viewPager2.adapter = null
+
+ super.onDestroyView()
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/MainFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/MainFragment.kt
new file mode 100644
index 0000000..d5dfbfe
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/MainFragment.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.ui.setupWithNavController
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.getViewProperty
+
+/**
+ * The home page.
+ */
+class MainFragment : Fragment(R.layout.fragment_main) {
+ // Views
+ private val bottomNavigationView by getViewProperty<BottomNavigationView>(R.id.bottomNavigationView)
+ private val nowPlayingFloatingActionButton by getViewProperty<FloatingActionButton>(R.id.nowPlayingFloatingActionButton)
+ private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
+ private val viewPager2 by getViewProperty<ViewPager2>(R.id.viewPager2)
+
+ // ViewPager2
+ private val onPageChangeCallback by lazy {
+ object : ViewPager2.OnPageChangeCallback() {
+ override fun onPageSelected(position: Int) {
+ super.onPageSelected(position)
+
+ bottomNavigationView.menu.getItem(position).isChecked = true
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ toolbar.setupWithNavController(findNavController())
+
+ viewPager2.isUserInputEnabled = false
+ viewPager2.adapter = object : FragmentStateAdapter(this) {
+ override fun getItemCount() = fragments.size
+ override fun createFragment(position: Int) = fragments[position]()
+ }
+ viewPager2.registerOnPageChangeCallback(onPageChangeCallback)
+
+ bottomNavigationView.setOnItemSelectedListener { item ->
+ when (item.itemId) {
+ R.id.activityFragment -> {
+ viewPager2.currentItem = 0
+ true
+ }
+
+ R.id.searchFragment -> {
+ viewPager2.currentItem = 1
+ true
+ }
+
+ R.id.libraryFragment -> {
+ viewPager2.currentItem = 2
+ true
+ }
+
+ else -> false
+ }
+ }
+
+ nowPlayingFloatingActionButton.setOnClickListener {
+ findNavController().navigate(R.id.action_mainFragment_to_fragment_now_playing)
+ }
+ }
+
+ companion object {
+ // Keep in sync with the BottomNavigationView menu
+ private val fragments = arrayOf(
+ { ActivityFragment() },
+ { SearchFragment() },
+ { LibraryFragment() },
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
new file mode 100644
index 0000000..60beb9d
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
@@ -0,0 +1,215 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.media.audiofx.AudioEffect
+import android.os.Bundle
+import android.view.View
+import android.widget.ImageButton
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.view.isVisible
+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 com.google.android.material.card.MaterialCardView
+import com.google.android.material.slider.Slider
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.getViewProperty
+import org.lineageos.twelve.models.RepeatMode
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.utils.TimestampFormatter
+import org.lineageos.twelve.viewmodels.NowPlayingViewModel
+
+/**
+ * Now playing fragment.
+ */
+class NowPlayingFragment : Fragment(R.layout.fragment_now_playing) {
+ // View models
+ private val viewModel by viewModels<NowPlayingViewModel>()
+
+ // Views
+ private val albumArtImageView by getViewProperty<ImageView>(R.id.albumArtImageView)
+ private val albumTitleTextView by getViewProperty<TextView>(R.id.albumTitleTextView)
+ private val audioTitleTextView by getViewProperty<TextView>(R.id.audioTitleTextView)
+ private val artistNameTextView by getViewProperty<TextView>(R.id.artistNameTextView)
+ private val castImageButton by getViewProperty<ImageButton>(R.id.castImageButton)
+ private val currentTimestampTextView by getViewProperty<TextView>(R.id.currentTimestampTextView)
+ private val durationTimestampTextView by getViewProperty<TextView>(R.id.durationTimestampTextView)
+ private val equalizerImageButton by getViewProperty<ImageButton>(R.id.equalizerImageButton)
+ private val fileTypeMaterialCardView by getViewProperty<MaterialCardView>(R.id.fileTypeMaterialCardView)
+ private val fileTypeTextView by getViewProperty<TextView>(R.id.fileTypeTextView)
+ private val hideImageButton by getViewProperty<ImageButton>(R.id.hideImageButton)
+ private val moreImageButton by getViewProperty<ImageButton>(R.id.moreImageButton)
+ private val nextTrackImageButton by getViewProperty<ImageButton>(R.id.nextTrackImageButton)
+ private val playPauseImageButton by getViewProperty<ImageButton>(R.id.playPauseImageButton)
+ private val playlistNameTextView by getViewProperty<TextView>(R.id.playlistNameTextView)
+ private val previousTrackImageButton by getViewProperty<ImageButton>(R.id.previousTrackImageButton)
+ private val progressSlider by getViewProperty<Slider>(R.id.progressSlider)
+ private val repeatImageButton by getViewProperty<ImageButton>(R.id.repeatImageButton)
+ private val repeatMarkerImageButton by getViewProperty<ImageButton>(R.id.repeatMarkerImageButton)
+ private val shuffleImageButton by getViewProperty<ImageButton>(R.id.shuffleImageButton)
+ private val shuffleMarkerImageButton by getViewProperty<ImageButton>(R.id.shuffleMarkerImageButton)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // Top bar
+ hideImageButton.setOnClickListener {
+ findNavController().navigateUp()
+ }
+
+ // Media controls
+ progressSlider.valueFrom = 0f
+ progressSlider.setLabelFormatter {
+ TimestampFormatter.formatTimestampSecs(it)
+ }
+
+ previousTrackImageButton.setOnClickListener {
+ viewModel.seekToPrevious()
+ }
+
+ playPauseImageButton.setOnClickListener {
+ viewModel.togglePlayPause()
+ }
+
+ nextTrackImageButton.setOnClickListener {
+ viewModel.seekToNext()
+ }
+
+ // Bottom bar buttons
+ shuffleImageButton.setOnClickListener {
+ viewModel.toggleShuffleMode()
+ }
+
+ repeatImageButton.setOnClickListener {
+ viewModel.toggleRepeatMode()
+ }
+
+ equalizerImageButton.setOnClickListener {
+ val activity = requireActivity()
+
+ // Open system equalizer
+ val intent = Intent.createChooser(
+ Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
+ putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName)
+ putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
+ },
+ null
+ )
+
+ activity.startActivity(intent)
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.playbackStatus.collectLatest {
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ val playbackStatus = it.data
+
+ playbackStatus.mediaItem?.localConfiguration?.mimeType
+ ?.takeIf { mimeType -> mimeType.contains('/') }
+ ?.substringAfterLast('/')
+ ?.also {
+ fileTypeTextView.text = it
+ fileTypeMaterialCardView.isVisible = true
+ } ?: run {
+ fileTypeMaterialCardView.isVisible = false
+ }
+
+ playbackStatus.mediaMetadata.artworkData?.also { artworkData ->
+ BitmapFactory.decodeByteArray(
+ artworkData, 0, artworkData.size
+ )?.let { bitmap ->
+ albumArtImageView.setImageBitmap(bitmap)
+ }
+ } ?: playbackStatus.mediaMetadata.artworkUri?.also { artworkUri ->
+ albumArtImageView.setImageURI(artworkUri)
+ } ?: albumArtImageView.setImageResource(R.drawable.ic_music_note)
+
+ val audioTitle = playbackStatus.mediaMetadata.displayTitle
+ ?: playbackStatus.mediaMetadata.title
+ audioTitle?.let { title ->
+ audioTitleTextView.text = title
+ audioTitleTextView.isVisible = true
+ } ?: run {
+ audioTitleTextView.isVisible = false
+ }
+
+ playbackStatus.mediaMetadata.artist?.let { artist ->
+ artistNameTextView.text = artist
+ artistNameTextView.isVisible = true
+ } ?: run {
+ artistNameTextView.isVisible = false
+ }
+
+ playbackStatus.mediaMetadata.albumTitle?.let { albumTitle ->
+ albumTitleTextView.text = albumTitle
+ albumTitleTextView.isVisible = true
+ } ?: run {
+ albumTitleTextView.isVisible = false
+ }
+
+ val currentPositionSecs =
+ playbackStatus.currentPositionMs?.let { currentPositionMs ->
+ currentPositionMs / 1000
+ } ?: 0L
+ val durationSecs = playbackStatus.durationMs?.let { durationMs ->
+ durationMs / 1000
+ } ?: 0L
+
+ progressSlider.valueTo = durationSecs.toFloat().takeIf { it > 0 } ?: 1f
+ progressSlider.value = currentPositionSecs.toFloat()
+
+ currentTimestampTextView.text = TimestampFormatter.formatTimestampSecs(
+ currentPositionSecs
+ )
+ durationTimestampTextView.text = TimestampFormatter.formatTimestampSecs(
+ durationSecs
+ )
+
+ playPauseImageButton.setImageResource(
+ when (playbackStatus.isPlaying) {
+ true -> R.drawable.ic_pause
+ false -> R.drawable.ic_play_arrow
+ }
+ )
+
+ shuffleMarkerImageButton.isVisible = playbackStatus.shuffleModeEnabled
+
+ repeatImageButton.setImageResource(
+ when (playbackStatus.repeatMode) {
+ RepeatMode.NONE,
+ RepeatMode.ALL -> R.drawable.ic_repeat
+
+ RepeatMode.ONE -> R.drawable.ic_repeat_one
+ }
+ )
+ repeatMarkerImageButton.isVisible =
+ playbackStatus.repeatMode != RepeatMode.NONE
+ }
+
+ is RequestStatus.Error -> throw Exception(
+ "Error while getting playback status"
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/PlaylistsFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/PlaylistsFragment.kt
new file mode 100644
index 0000000..eecaff3
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/PlaylistsFragment.kt
@@ -0,0 +1,121 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.LinearLayout
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.RecyclerView
+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.ext.getViewProperty
+import org.lineageos.twelve.ext.setProgressCompat
+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.ListItem
+import org.lineageos.twelve.utils.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.viewmodels.PlaylistsViewModel
+
+/**
+ * View all music playlists.
+ */
+class PlaylistsFragment : Fragment(R.layout.fragment_playlists) {
+ // View models
+ private val viewModel by viewModels<PlaylistsViewModel>()
+
+ // Views
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val noElementsLinearLayout by getViewProperty<LinearLayout>(R.id.noElementsLinearLayout)
+ private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+
+ // Recyclerview
+ private val adapter = object : SimpleListAdapter<Playlist, ListItem>(
+ UniqueItemDiffCallback(),
+ ListItem::class.java,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setLeadingIconImage(R.drawable.ic_playlist_play)
+ view.setOnClickListener {
+ // TODO: Open playlist fragment
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Playlist) {
+ view.headlineText = item.name
+ view.supportingText = item.uri.toString()
+ }
+ }
+
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ loadData()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ recyclerView.adapter = adapter
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+
+ override fun onDestroyView() {
+ recyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+
+ private fun loadData() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.playlists.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ adapter.submitList(it.data)
+
+ val isEmpty = it.data.isEmpty()
+ recyclerView.isVisible = !isEmpty
+ noElementsLinearLayout.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Failed to load playlists, error: ${it.type}")
+
+ adapter.submitList(emptyList())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val LOG_TAG = PlaylistsFragment::class.simpleName!!
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/SearchFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/SearchFragment.kt
new file mode 100644
index 0000000..b9b1346
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/SearchFragment.kt
@@ -0,0 +1,231 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import android.widget.LinearLayout
+import androidx.core.view.isVisible
+import androidx.core.widget.addTextChangedListener
+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.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.progressindicator.LinearProgressIndicator
+import com.google.android.material.search.SearchBar
+import com.google.android.material.search.SearchView
+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.scheduleHideSoftInput
+import org.lineageos.twelve.ext.setProgressCompat
+import org.lineageos.twelve.models.Album
+import org.lineageos.twelve.models.Artist
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.Genre
+import org.lineageos.twelve.models.Playlist
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.models.UniqueItem
+import org.lineageos.twelve.models.UniqueItem.Companion.areContentsTheSame
+import org.lineageos.twelve.models.UniqueItem.Companion.areItemsTheSame
+import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter
+import org.lineageos.twelve.ui.views.ListItem
+import org.lineageos.twelve.utils.PermissionsGatedCallback
+import org.lineageos.twelve.utils.PermissionsUtils
+import org.lineageos.twelve.viewmodels.SearchViewModel
+
+/**
+ * Search across all contents.
+ */
+class SearchFragment : Fragment(R.layout.fragment_search) {
+ // View models
+ private val viewModel by viewModels<SearchViewModel>()
+
+ // Views
+ private val linearProgressIndicator by getViewProperty<LinearProgressIndicator>(R.id.linearProgressIndicator)
+ private val noElementsLinearLayout by getViewProperty<LinearLayout>(R.id.noElementsLinearLayout)
+ private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+ private val searchBar by getViewProperty<SearchBar>(R.id.searchBar)
+ private val searchView by getViewProperty<SearchView>(R.id.searchView)
+
+ // System services
+ private val inputMethodManager: InputMethodManager
+ get() = requireContext().getSystemService(InputMethodManager::class.java)
+
+ // Recyclerview
+ private val adapter by lazy {
+ object : SimpleListAdapter<UniqueItem<*>, ListItem>(diffCallback, ListItem::class.java) {
+ override fun ViewHolder.onPrepareView() {
+ view.setOnClickListener {
+ item?.let {
+ when (it) {
+ is Album -> findNavController().navigate(
+ R.id.action_mainFragment_to_fragment_album,
+ AlbumFragment.createBundle(it.uri)
+ )
+
+ is Artist -> findNavController().navigate(
+ R.id.action_mainFragment_to_fragment_artist,
+ ArtistFragment.createBundle(it.uri)
+ )
+
+ is Audio -> viewModel.playAudio(it)
+ }
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: UniqueItem<*>) {
+ when (item) {
+ is Album -> {
+ item.thumbnail?.also {
+ view.setTrailingIconImage(it)
+ } ?: view.setTrailingIconImage(R.drawable.ic_album)
+ view.headlineText = item.title
+ view.supportingText = item.uri.toString()
+ }
+
+ is Artist -> {
+ view.setTrailingIconImage(R.drawable.ic_person)
+ view.headlineText = item.name
+ view.supportingText = item.uri.toString()
+ }
+
+ is Audio -> {
+ view.setTrailingIconImage(R.drawable.ic_music_note)
+ view.headlineText = item.title
+ view.supportingText = item.uri.toString()
+ }
+
+ is Genre -> {
+ view.setTrailingIconImage(R.drawable.ic_genres)
+ view.headlineText = item.name
+ view.supportingText = item.uri.toString()
+ }
+
+ is Playlist -> {
+ view.setTrailingIconImage(R.drawable.ic_playlist_play)
+ view.headlineText = item.name
+ view.supportingText = item.uri.toString()
+ }
+
+ else -> throw Exception("Invalid item type")
+ }
+ }
+ }
+ }
+
+ // Permissions
+ private val permissionsGatedCallback = PermissionsGatedCallback(
+ this, PermissionsUtils.mainPermissions
+ ) {
+ loadData()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ recyclerView.adapter = adapter
+
+ // This library sucks.
+ @Suppress("RestrictedApi")
+ searchView.setStatusBarSpacerEnabled(false)
+
+ searchView.editText.addTextChangedListener { text ->
+ viewModel.setSearchQuery(text.toString())
+ }
+ searchView.editText.setOnEditorActionListener { _, _, _ ->
+ inputMethodManager.scheduleHideSoftInput(searchView.editText, 0)
+ searchView.editText.clearFocus()
+ true
+ }
+
+ permissionsGatedCallback.runAfterPermissionsCheck()
+ }
+
+ override fun onDestroyView() {
+ recyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+
+ private fun loadData() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.searchResults.collectLatest {
+ linearProgressIndicator.setProgressCompat(it, true)
+
+ when (it) {
+ null -> {
+ adapter.submitList(listOf())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = false
+ }
+
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ adapter.submitList(it.data)
+
+ val isEmpty = it.data.isEmpty()
+ recyclerView.isVisible = !isEmpty
+ noElementsLinearLayout.isVisible = isEmpty
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Failed to load search results, error: ${it.type}")
+
+ adapter.submitList(listOf())
+
+ recyclerView.isVisible = false
+ noElementsLinearLayout.isVisible = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val LOG_TAG = SearchFragment::class.simpleName!!
+
+ private val diffCallback = object : DiffUtil.ItemCallback<UniqueItem<*>>() {
+ override fun areItemsTheSame(
+ oldItem: UniqueItem<*>,
+ newItem: UniqueItem<*>
+ ) = when (oldItem) {
+ is Album -> oldItem.areItemsTheSame(newItem)
+ is Artist -> oldItem.areItemsTheSame(newItem)
+ is Audio -> oldItem.areItemsTheSame(newItem)
+ is Genre -> oldItem.areItemsTheSame(newItem)
+ is Playlist -> oldItem.areItemsTheSame(newItem)
+ else -> throw Exception("Invalid item type")
+ }
+
+ override fun areContentsTheSame(
+ oldItem: UniqueItem<*>,
+ newItem: UniqueItem<*>
+ ) = when (oldItem) {
+ is Album -> oldItem.areContentsTheSame(newItem)
+ is Artist -> oldItem.areContentsTheSame(newItem)
+ is Audio -> oldItem.areContentsTheSame(newItem)
+ is Genre -> oldItem.areContentsTheSame(newItem)
+ is Playlist -> oldItem.areContentsTheSame(newItem)
+ else -> throw Exception("Invalid item type")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/Album.kt b/app/src/main/java/org/lineageos/twelve/models/Album.kt
new file mode 100644
index 0000000..f497c36
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/Album.kt
@@ -0,0 +1,36 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import android.graphics.Bitmap
+import android.net.Uri
+
+/**
+ * An album.
+ *
+ * @param uri The URI of the album
+ * @param title The title of the album
+ * @param artistUri The URI of the artist
+ * @param year The year of the album
+ * @param thumbnail The album's thumbnail
+ */
+data class Album(
+ val uri: Uri,
+ val title: String,
+ val artistUri: Uri,
+ val year: Int?,
+ val thumbnail: Bitmap?,
+) : UniqueItem<Album> {
+ override fun areItemsTheSame(other: Album) = this.uri == other.uri
+
+ override fun areContentsTheSame(other: Album) = compareValuesBy(
+ this, other,
+ Album::title,
+ Album::artistUri,
+ Album::year,
+ { it.thumbnail?.sameAs(other.thumbnail) ?: (other.thumbnail == null) },
+ ) == 0
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/Artist.kt b/app/src/main/java/org/lineageos/twelve/models/Artist.kt
new file mode 100644
index 0000000..99470dc
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/Artist.kt
@@ -0,0 +1,30 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import android.graphics.Bitmap
+import android.net.Uri
+
+/**
+ * An artist.
+ *
+ * @param uri The URI of the artist
+ * @param name The name of the artist
+ * @param thumbnail The artist's thumbnail
+ */
+data class Artist(
+ val uri: Uri,
+ val name: String,
+ val thumbnail: Bitmap?,
+) : UniqueItem<Artist> {
+ override fun areItemsTheSame(other: Artist) = this.uri == other.uri
+
+ override fun areContentsTheSame(other: Artist) = compareValuesBy(
+ this, other,
+ Artist::name,
+ { it.thumbnail?.sameAs(other.thumbnail) ?: (other.thumbnail == null) },
+ ) == 0
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/ArtistWorks.kt b/app/src/main/java/org/lineageos/twelve/models/ArtistWorks.kt
new file mode 100644
index 0000000..0fb9f52
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/ArtistWorks.kt
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+/**
+ * Whatever an artist has worked on.
+ *
+ * @param albums Albums on which the artist appears
+ * @param playlists Playlists on which the artist appears
+ */
+data class ArtistWorks(
+ val albums: List<Album>,
+ val playlists: List<Playlist>,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/models/Audio.kt b/app/src/main/java/org/lineageos/twelve/models/Audio.kt
new file mode 100644
index 0000000..dd3c383
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/Audio.kt
@@ -0,0 +1,72 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import android.net.Uri
+
+/**
+ * An audio.
+ *
+ * @param uri The URI of the audio
+ * @param mimeType The MIME type of the audio
+ * @param title The title of the audio
+ * @param type The type of the audio
+ * @param durationMs The duration of the audio in milliseconds
+ * @param artistUri The URI of the artist of the audio
+ * @param albumUri The URI of the album of the audio
+ * @param albumTrack The track number of the audio in the album
+ * @param genreUri The URI of the genre of the audio
+ * @param year The year of release of the audio
+ */
+data class Audio(
+ val uri: Uri,
+ val mimeType: String,
+ val title: String,
+ val type: Type,
+ val durationMs: Int,
+ val artistUri: Uri,
+ val albumUri: Uri,
+ val albumTrack: Int,
+ val genreUri: Uri,
+ val year: Int,
+) : UniqueItem<Audio> {
+ enum class Type {
+ /**
+ * Music.
+ */
+ MUSIC,
+
+ /**
+ * Podcast.
+ */
+ PODCAST,
+
+ /**
+ * Audiobook.
+ */
+ AUDIOBOOK,
+
+ /**
+ * Recording.
+ */
+ RECORDING,
+ }
+
+ override fun areItemsTheSame(other: Audio) = this.uri == other.uri
+
+ override fun areContentsTheSame(other: Audio) = compareValuesBy(
+ this, other,
+ Audio::mimeType,
+ Audio::title,
+ Audio::type,
+ Audio::durationMs,
+ Audio::artistUri,
+ Audio::albumUri,
+ Audio::albumTrack,
+ Audio::genreUri,
+ Audio::year,
+ ) == 0
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/Genre.kt b/app/src/main/java/org/lineageos/twelve/models/Genre.kt
new file mode 100644
index 0000000..e521c97
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/Genre.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import android.net.Uri
+
+/**
+ * A music genre.
+ * TODO: Maybe make it an enum class and follow https://en.wikipedia.org/wiki/List_of_ID3v1_genres
+ *
+ * @param uri The URI of the genre
+ * @param name The name of the genre. Can be null
+ */
+data class Genre(
+ val uri: Uri,
+ val name: String?,
+) : UniqueItem<Genre> {
+ override fun areItemsTheSame(other: Genre) = this.uri == other.uri
+
+ override fun areContentsTheSame(other: Genre) = compareValuesBy(
+ other,
+ this,
+ Genre::name,
+ ) == 0
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/PlaybackStatus.kt b/app/src/main/java/org/lineageos/twelve/models/PlaybackStatus.kt
new file mode 100644
index 0000000..e1b3a6f
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/PlaybackStatus.kt
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+
+/**
+ * Playback status reported by the service.
+ */
+data class PlaybackStatus(
+ val mediaItem: MediaItem?,
+ val mediaMetadata: MediaMetadata,
+ val durationMs: Long?,
+ val currentPositionMs: Long?,
+ val isPlaying: Boolean,
+ val shuffleModeEnabled: Boolean,
+ val repeatMode: RepeatMode,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/models/Playlist.kt b/app/src/main/java/org/lineageos/twelve/models/Playlist.kt
new file mode 100644
index 0000000..e2061e2
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/Playlist.kt
@@ -0,0 +1,27 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import android.net.Uri
+
+/**
+ * A user-defined playlist.
+ *
+ * @param uri The URI of the playlist
+ * @param name The name of the playlist
+ */
+data class Playlist(
+ val uri: Uri,
+ val name: String,
+) : UniqueItem<Playlist> {
+ override fun areItemsTheSame(other: Playlist) = uri == other.uri
+
+ override fun areContentsTheSame(other: Playlist) = compareValuesBy(
+ this,
+ other,
+ Playlist::name,
+ ) == 0
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/RepeatMode.kt b/app/src/main/java/org/lineageos/twelve/models/RepeatMode.kt
new file mode 100644
index 0000000..2ed6739
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/RepeatMode.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+/**
+ * Playback repeat mode.
+ */
+enum class RepeatMode {
+ NONE,
+ ALL,
+ ONE,
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/RequestStatus.kt b/app/src/main/java/org/lineageos/twelve/models/RequestStatus.kt
new file mode 100644
index 0000000..16b9b4b
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/RequestStatus.kt
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+/**
+ * Request status for flows.
+ */
+sealed class RequestStatus<T : Any> {
+ /**
+ * Result is not ready yet.
+ *
+ * @param progress An optional percentage of the request progress
+ */
+ class Loading<T : Any>(
+ @androidx.annotation.IntRange(from = 0, to = 100) val progress: Int? = null
+ ) : RequestStatus<T>()
+
+ /**
+ * The result is ready.
+ *
+ * @param data The obtained data
+ */
+ class Success<T : Any>(val data: T) : RequestStatus<T>()
+
+ /**
+ * The request failed.
+ *
+ * @param type The error type
+ */
+ class Error<T : Any>(val type: Type) : RequestStatus<T>() {
+ enum class Type {
+ /**
+ * The item was not found.
+ */
+ NOT_FOUND,
+
+ /**
+ * I/O error, can also be network.
+ */
+ IO,
+
+ /**
+ * Authentication error.
+ */
+ AUTHENTICATION_REQUIRED,
+
+ /**
+ * Invalid credentials.
+ */
+ INVALID_CREDENTIALS,
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/UniqueItem.kt b/app/src/main/java/org/lineageos/twelve/models/UniqueItem.kt
new file mode 100644
index 0000000..426b604
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/UniqueItem.kt
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import kotlin.reflect.safeCast
+
+/**
+ * An item that can be uniquely identified.
+ */
+interface UniqueItem<T> {
+ /**
+ * Return whether this item is the same as the other.
+ */
+ fun areItemsTheSame(other: T): Boolean
+
+ /**
+ * Return whether this item has the same content as the other.
+ * This is called only when [areItemsTheSame] returns true.
+ */
+ fun areContentsTheSame(other: T): Boolean
+
+ companion object {
+ /**
+ * @see areItemsTheSame
+ */
+ inline fun <reified T : Any> UniqueItem<T>.areItemsTheSame(other: Any?): Boolean =
+ T::class.safeCast(other)?.let { areItemsTheSame(it) } ?: false
+
+ /**
+ * @see areContentsTheSame
+ */
+ inline fun <reified T : Any> UniqueItem<T>.areContentsTheSame(other: Any?): Boolean =
+ T::class.safeCast(other)?.let { areContentsTheSame(it) } ?: false
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/query/Query.kt b/app/src/main/java/org/lineageos/twelve/query/Query.kt
new file mode 100644
index 0000000..86b3ef0
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/query/Query.kt
@@ -0,0 +1,47 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.query
+
+typealias Column = String
+
+sealed interface Node {
+ fun build(): String = when (this) {
+ is And -> "(${lhs.build()}) AND (${rhs.build()})"
+ is Eq -> "${lhs.build()} = ${rhs.build()}"
+ is In<*> -> "$value IN (${values.joinToString(", ")})"
+ is Like -> "${lhs.build()} LIKE ${rhs.build()}"
+ is Literal<*> -> "$`val`"
+ is Or -> "(${lhs.build()}) OR (${rhs.build()})"
+ }
+}
+
+private class And(val lhs: Node, val rhs: Node) : Node
+private class Eq(val lhs: Node, val rhs: Node) : Node
+private class In<T>(val value: T, val values: Collection<T>) : Node
+private class Like(val lhs: Node, val rhs: Node) : Node
+private class Literal<T>(val `val`: T) : Node
+private class Or(val lhs: Node, val rhs: Node) : Node
+
+class Query(val root: Node) {
+ fun build() = root.build()
+
+ companion object {
+ const val ARG = "?"
+ }
+}
+
+infix fun Query.and(other: Query) = Query(And(this.root, other.root))
+infix fun Query.eq(other: Query) = Query(Eq(this.root, other.root))
+infix fun Query.like(other: Query) = Query(Like(this.root, other.root))
+infix fun Query.or(other: Query) = Query(Or(this.root, other.root))
+
+infix fun <T> Column.eq(other: T) = Query(Literal(this)) eq Query(Literal(other))
+infix fun <T> Column.`in`(values: Collection<T>) = Query(In(this, values))
+infix fun <T> Column.like(other: T) = Query(Literal(this)) like Query(Literal(other))
+
+fun Iterable<Query>.join(
+ func: Query.(other: Query) -> Query,
+) = reduceOrNull(func)
diff --git a/app/src/main/java/org/lineageos/twelve/repositories/MediaRepository.kt b/app/src/main/java/org/lineageos/twelve/repositories/MediaRepository.kt
new file mode 100644
index 0000000..b22a1f1
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/repositories/MediaRepository.kt
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.repositories
+
+import android.content.Context
+import android.net.Uri
+import kotlinx.coroutines.flow.Flow
+import org.lineageos.twelve.datasources.LocalDataSource
+import org.lineageos.twelve.datasources.MediaDataSource
+import org.lineageos.twelve.models.Album
+import org.lineageos.twelve.models.Artist
+import org.lineageos.twelve.models.ArtistWorks
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.Genre
+import org.lineageos.twelve.models.Playlist
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.models.UniqueItem
+
+class MediaRepository(context: Context) {
+ private val localDataSource = LocalDataSource(context)
+
+ /**
+ * @see MediaDataSource.albums
+ */
+ fun albums(): Flow<RequestStatus<List<Album>>> = localDataSource.albums()
+
+ /**
+ * @see MediaDataSource.artists
+ */
+ fun artists(): Flow<RequestStatus<List<Artist>>> = localDataSource.artists()
+
+ /**
+ * @see MediaDataSource.genres
+ */
+ fun genres(): Flow<RequestStatus<List<Genre>>> = localDataSource.genres()
+
+ /**
+ * @see MediaDataSource.playlists
+ */
+ fun playlists(): Flow<RequestStatus<List<Playlist>>> = localDataSource.playlists()
+
+ /**
+ * @see MediaDataSource.search
+ */
+ fun search(query: String): Flow<RequestStatus<List<UniqueItem<*>>>> =
+ localDataSource.search(query)
+
+ /**
+ * @see MediaDataSource.album
+ */
+ fun album(albumUri: Uri): Flow<RequestStatus<Pair<Album, List<Audio>>>> =
+ localDataSource.album(albumUri)
+
+ /**
+ * @see MediaDataSource.artist
+ */
+ fun artist(artistUri: Uri): Flow<RequestStatus<Pair<Artist, ArtistWorks>>> =
+ localDataSource.artist(artistUri)
+}
diff --git a/app/src/main/java/org/lineageos/twelve/services/PlaybackService.kt b/app/src/main/java/org/lineageos/twelve/services/PlaybackService.kt
new file mode 100644
index 0000000..9caf7f0
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/services/PlaybackService.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.services
+
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaLibraryService
+import androidx.media3.session.MediaSession
+
+class PlaybackService : MediaLibraryService() {
+ private var player: ExoPlayer? = null
+ private var mediaLibrarySession: MediaLibrarySession? = null
+
+ private val mediaLibrarySessionCallback = object : MediaLibrarySession.Callback {
+ // TODO
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ val exoPlayer = ExoPlayer.Builder(this).build()
+
+ player = exoPlayer
+
+ mediaLibrarySession = MediaLibrarySession.Builder(
+ this, exoPlayer, mediaLibrarySessionCallback
+ ).build()
+ }
+
+ override fun onDestroy() {
+ player?.release()
+ player = null
+
+ mediaLibrarySession?.release()
+ mediaLibrarySession = null
+
+ super.onDestroy()
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaLibrarySession
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ui/recyclerview/SimpleListAdapter.kt b/app/src/main/java/org/lineageos/twelve/ui/recyclerview/SimpleListAdapter.kt
new file mode 100644
index 0000000..8d13095
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ui/recyclerview/SimpleListAdapter.kt
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ui.recyclerview
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * A very basic ListAdapter that holds only one type of item.
+ * @param diffCallback A [DiffUtil.ItemCallback] provided by the derived class
+ * @param viewClass The [Class] of the [View], needed due to the restriction of reified on classes.
+ * This will be inflated using the constructor that takes just a [Context].
+ */
+abstract class SimpleListAdapter<T, V : View>(
+ diffCallback: DiffUtil.ItemCallback<T>,
+ private val viewClass: Class<V>,
+) : ListAdapter<T, SimpleListAdapter<T, V>.ViewHolder>(diffCallback) {
+ abstract fun ViewHolder.onBindView(item: T)
+
+ open fun ViewHolder.onPrepareView() {}
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
+ viewClass.getConstructor(Context::class.java).newInstance(parent.context)
+ )
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ inner class ViewHolder(val view: V) : RecyclerView.ViewHolder(view) {
+ var item: T? = null
+
+ init {
+ onPrepareView()
+ }
+
+ fun bind(item: T) {
+ this.item = item
+
+ onBindView(item)
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ui/recyclerview/UniqueItemDiffCallback.kt b/app/src/main/java/org/lineageos/twelve/ui/recyclerview/UniqueItemDiffCallback.kt
new file mode 100644
index 0000000..8e5d9f6
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ui/recyclerview/UniqueItemDiffCallback.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ui.recyclerview
+
+import androidx.recyclerview.widget.DiffUtil
+import org.lineageos.twelve.models.UniqueItem
+
+class UniqueItemDiffCallback<T : UniqueItem<T>> : DiffUtil.ItemCallback<T>() {
+ override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem.areItemsTheSame(newItem)
+
+ override fun areContentsTheSame(oldItem: T, newItem: T) = oldItem.areContentsTheSame(newItem)
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ui/views/HorizontalListItem.kt b/app/src/main/java/org/lineageos/twelve/ui/views/HorizontalListItem.kt
new file mode 100644
index 0000000..096e378
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ui/views/HorizontalListItem.kt
@@ -0,0 +1,80 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ui.views
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.Icon
+import android.util.AttributeSet
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.core.view.isVisible
+import org.lineageos.twelve.R
+
+/**
+ * Simple list item view to be used with horizontal RecyclerView adapters.
+ */
+class HorizontalListItem @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null
+) : FrameLayout(context, attrs) {
+ private val thumbnailImageView by lazy { findViewById<ImageView>(R.id.thumbnailImageView) }
+ private val headlineTextView by lazy { findViewById<TextView>(R.id.headlineTextView) }
+ private val supportingTextView by lazy { findViewById<TextView>(R.id.supportingTextView) }
+
+ var thumbnailImage: Drawable?
+ get() = thumbnailImageView.drawable
+ set(value) {
+ thumbnailImageView.setImageDrawable(value)
+ }
+
+ var headlineText: CharSequence?
+ get() = headlineTextView.text
+ set(value) {
+ headlineTextView.setTextAndUpdateVisibility(value)
+ }
+
+ var supportingText: CharSequence?
+ get() = supportingTextView.text
+ set(value) {
+ supportingTextView.setTextAndUpdateVisibility(value)
+ }
+
+ init {
+ inflate(context, R.layout.horizontal_list_item, this)
+ }
+
+ fun setThumbnailImage(bm: Bitmap) = thumbnailImageView.setImageBitmap(bm)
+ fun setThumbnailImage(icon: Icon) = thumbnailImageView.setImageIcon(icon)
+ fun setThumbnailImage(@DrawableRes resId: Int) = thumbnailImageView.setImageResource(resId)
+
+ fun setHeadlineText(@StringRes resId: Int) = headlineTextView.setTextAndUpdateVisibility(resId)
+ fun setHeadlineText(@StringRes resId: Int, vararg formatArgs: Any) =
+ headlineTextView.setTextAndUpdateVisibility(resId, *formatArgs)
+
+ fun setSupportingText(@StringRes resId: Int) =
+ supportingTextView.setTextAndUpdateVisibility(resId)
+
+ fun setSupportingText(@StringRes resId: Int, vararg formatArgs: Any) =
+ supportingTextView.setTextAndUpdateVisibility(resId, *formatArgs)
+
+ // TextView utils
+
+ private fun TextView.setTextAndUpdateVisibility(text: CharSequence?) {
+ this.text = text.also {
+ isVisible = it != null
+ }
+ }
+
+ private fun TextView.setTextAndUpdateVisibility(@StringRes resId: Int) =
+ setTextAndUpdateVisibility(resources.getText(resId))
+
+ private fun TextView.setTextAndUpdateVisibility(@StringRes resId: Int, vararg formatArgs: Any) =
+ setTextAndUpdateVisibility(resources.getString(resId, *formatArgs))
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ui/views/ListItem.kt b/app/src/main/java/org/lineageos/twelve/ui/views/ListItem.kt
new file mode 100644
index 0000000..200871c
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ui/views/ListItem.kt
@@ -0,0 +1,182 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ui.views
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.Icon
+import android.net.Uri
+import android.util.AttributeSet
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.core.view.isVisible
+import org.lineageos.twelve.R
+
+/**
+ * A poor man's Material Design 3 ListItem implementation.
+ * @see <a href="https://m3.material.io/components/lists/overview">Material Design 3 docs</a>
+ */
+class ListItem @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null
+) : FrameLayout(context, attrs) {
+ private val headlineTextView by lazy { findViewById<TextView>(R.id.headlineTextView) }
+ private val leadingIconImageView by lazy { findViewById<ImageView>(R.id.leadingIconImageView) }
+ private val supportingTextView by lazy { findViewById<TextView>(R.id.supportingTextView) }
+ private val trailingIconImageView by lazy { findViewById<ImageView>(R.id.trailingIconImageView) }
+ private val trailingSupportingTextView by lazy { findViewById<TextView>(R.id.trailingSupportingTextView) }
+
+ var leadingIconImage: Drawable?
+ get() = leadingIconImageView.drawable
+ set(value) {
+ leadingIconImageView.setImageAndUpdateVisibility(value)
+ }
+
+ var leadingIconTint: ColorStateList? = null
+ set(value) {
+ field = value
+ leadingIconImageView.updateTint(value)
+ }
+
+ var headlineText: CharSequence?
+ get() = headlineTextView.text
+ set(value) {
+ headlineTextView.setTextAndUpdateVisibility(value)
+ }
+
+ var supportingText: CharSequence?
+ get() = supportingTextView.text
+ set(value) {
+ supportingTextView.setTextAndUpdateVisibility(value)
+ }
+
+ var trailingIconImage: Drawable?
+ get() = trailingIconImageView.drawable
+ set(value) {
+ trailingIconImageView.setImageAndUpdateVisibility(value)
+ }
+
+ var trailingIconTint: ColorStateList? = null
+ set(value) {
+ field = value
+ trailingIconImageView.updateTint(value)
+ }
+
+ var trailingSupportingText: CharSequence?
+ get() = trailingSupportingTextView.text
+ set(value) {
+ trailingSupportingTextView.setTextAndUpdateVisibility(value)
+ }
+
+ init {
+ inflate(context, R.layout.list_item, this)
+
+ context.obtainStyledAttributes(attrs, R.styleable.ListItem, 0, 0).apply {
+ try {
+ leadingIconTint = getColorStateList(R.styleable.ListItem_leadingIconTint)
+ leadingIconImage = getDrawable(R.styleable.ListItem_leadingIconImage)
+
+ headlineText = getString(R.styleable.ListItem_headlineText)
+
+ supportingText = getString(R.styleable.ListItem_supportingText)
+
+ trailingIconTint = getColorStateList(R.styleable.ListItem_trailingIconTint)
+ trailingIconImage = getDrawable(R.styleable.ListItem_trailingIconImage)
+
+ trailingSupportingText = getString(R.styleable.ListItem_trailingSupportingText)
+ } finally {
+ recycle()
+ }
+ }
+ }
+
+ fun setHeadlineText(@StringRes resId: Int) = headlineTextView.setTextAndUpdateVisibility(resId)
+ fun setHeadlineText(@StringRes resId: Int, vararg formatArgs: Any) =
+ headlineTextView.setTextAndUpdateVisibility(resId, *formatArgs)
+
+ fun setLeadingIconImage(bm: Bitmap) = leadingIconImageView.setImageAndUpdateVisibility(bm)
+ fun setLeadingIconImage(icon: Icon) = leadingIconImageView.setImageAndUpdateVisibility(icon)
+ fun setLeadingIconImage(@DrawableRes resId: Int) =
+ leadingIconImageView.setImageAndUpdateVisibility(resId, leadingIconTint)
+
+ fun setLeadingIconImage(uri: Uri) = leadingIconImageView.setImageAndUpdateVisibility(uri)
+
+ fun setSupportingText(@StringRes resId: Int) =
+ supportingTextView.setTextAndUpdateVisibility(resId)
+
+ fun setSupportingText(@StringRes resId: Int, vararg formatArgs: Any) =
+ supportingTextView.setTextAndUpdateVisibility(resId, *formatArgs)
+
+ fun setTrailingIconImage(bm: Bitmap) = trailingIconImageView.setImageAndUpdateVisibility(bm)
+ fun setTrailingIconImage(icon: Icon) = trailingIconImageView.setImageAndUpdateVisibility(icon)
+ fun setTrailingIconImage(@DrawableRes resId: Int) =
+ trailingIconImageView.setImageAndUpdateVisibility(resId, trailingIconTint)
+
+ fun setTrailingIconImage(uri: Uri) = trailingIconImageView.setImageAndUpdateVisibility(uri)
+
+ fun setTrailingSupportingText(@StringRes resId: Int) =
+ trailingSupportingTextView.setTextAndUpdateVisibility(resId)
+
+ fun setTrailingSupportingText(@StringRes resId: Int, vararg formatArgs: Any) =
+ trailingSupportingTextView.setTextAndUpdateVisibility(resId, *formatArgs)
+
+ // ImageView utils
+
+ private fun ImageView.setImageAndUpdateVisibility(bm: Bitmap) {
+ setImageBitmap(bm)
+ updateTint(null)
+ isVisible = true
+ }
+
+ private fun ImageView.setImageAndUpdateVisibility(drawable: Drawable?) {
+ setImageDrawable(drawable)
+ updateTint(null)
+ isVisible = drawable != null
+ }
+
+ private fun ImageView.setImageAndUpdateVisibility(icon: Icon) {
+ setImageIcon(icon)
+ updateTint(null)
+ isVisible = true
+ }
+
+ private fun ImageView.setImageAndUpdateVisibility(
+ @DrawableRes resId: Int,
+ tint: ColorStateList?,
+ ) {
+ setImageResource(resId)
+ updateTint(tint)
+ isVisible = true
+ }
+
+ private fun ImageView.setImageAndUpdateVisibility(uri: Uri) {
+ setImageURI(uri)
+ updateTint(null)
+ isVisible = true
+ }
+
+ private fun ImageView.updateTint(tint: ColorStateList?) {
+ imageTintList = tint
+ }
+
+ // TextView utils
+
+ private fun TextView.setTextAndUpdateVisibility(text: CharSequence?) {
+ this.text = text.also {
+ isVisible = it != null
+ }
+ }
+
+ private fun TextView.setTextAndUpdateVisibility(@StringRes resId: Int) =
+ setTextAndUpdateVisibility(resources.getText(resId))
+
+ private fun TextView.setTextAndUpdateVisibility(@StringRes resId: Int, vararg formatArgs: Any) =
+ setTextAndUpdateVisibility(resources.getString(resId, *formatArgs))
+}
diff --git a/app/src/main/java/org/lineageos/twelve/utils/PermissionsGatedCallback.kt b/app/src/main/java/org/lineageos/twelve/utils/PermissionsGatedCallback.kt
new file mode 100644
index 0000000..d9ea4e8
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/utils/PermissionsGatedCallback.kt
@@ -0,0 +1,74 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.utils
+
+import android.app.Activity
+import android.content.Context
+import android.widget.Toast
+import androidx.activity.result.ActivityResultCaller
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.permissionsGranted
+
+/**
+ * A class that checks main app permissions before starting the callback.
+ */
+class PermissionsGatedCallback private constructor(
+ caller: ActivityResultCaller,
+ private val getContext: () -> Context,
+ private val getActivity: () -> Activity,
+ private val permissions: Array<String>,
+ private val callback: () -> Unit,
+) {
+ constructor(
+ fragment: Fragment,
+ permissions: Array<String>,
+ callback: () -> Unit,
+ ) : this(
+ fragment,
+ { fragment.requireContext() },
+ { fragment.requireActivity() },
+ permissions,
+ callback,
+ )
+
+ constructor(
+ activity: AppCompatActivity,
+ permissions: Array<String>,
+ callback: () -> Unit,
+ ) : this(
+ activity,
+ { activity },
+ { activity },
+ permissions,
+ callback,
+ )
+
+ private val activityResultLauncher = caller.registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) {
+ val context = getContext()
+
+ if (it.isNotEmpty()) {
+ if (!context.permissionsGranted(permissions)) {
+ Toast.makeText(
+ context,
+ R.string.app_permissions_toast,
+ Toast.LENGTH_SHORT
+ ).show()
+ getActivity().finish()
+ } else {
+ callback()
+ }
+ }
+ }
+
+ fun runAfterPermissionsCheck() {
+ activityResultLauncher.launch(permissions)
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/utils/PermissionsUtils.kt b/app/src/main/java/org/lineageos/twelve/utils/PermissionsUtils.kt
new file mode 100644
index 0000000..6399784
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/utils/PermissionsUtils.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.utils
+
+import android.Manifest
+import android.os.Build
+
+/**
+ * App's permissions utils.
+ */
+object PermissionsUtils {
+ /**
+ * Permissions required to run the app
+ */
+ val mainPermissions = mutableListOf<String>().apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add(Manifest.permission.READ_MEDIA_AUDIO)
+ add(Manifest.permission.READ_MEDIA_IMAGES)
+ } else {
+ add(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+
+ add(Manifest.permission.ACCESS_MEDIA_LOCATION)
+ }.toTypedArray()
+}
diff --git a/app/src/main/java/org/lineageos/twelve/utils/TimestampFormatter.kt b/app/src/main/java/org/lineageos/twelve/utils/TimestampFormatter.kt
new file mode 100644
index 0000000..c61f68c
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/utils/TimestampFormatter.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.utils
+
+import java.util.Locale
+
+object TimestampFormatter {
+ fun formatTimestampSecs(timestampSecs: Long): String {
+ val minutes = timestampSecs / 60
+ val seconds = timestampSecs % 60
+ return String.format(Locale.ROOT, "%02d:%02d", minutes, seconds)
+ }
+
+ fun formatTimestampSecs(
+ timestampSecs: Number
+ ) = formatTimestampSecs(timestampSecs.toLong())
+
+ fun formatTimestampMillis(
+ timestampMillis: Long
+ ) = formatTimestampSecs(timestampMillis / 1000)
+
+ fun formatTimestampMillis(
+ timestampMillis: Number
+ ) = formatTimestampMillis(timestampMillis.toLong())
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumViewModel.kt
new file mode 100644
index 0000000..083de2b
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumViewModel.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+
+class AlbumViewModel(application: Application) : TwelveViewModel(application) {
+ private val albumUri = MutableStateFlow<Uri?>(null)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val album = albumUri.flatMapLatest {
+ it?.let {
+ mediaRepository.album(it)
+ } ?: flowOf(null)
+ }
+
+ fun loadAlbum(albumUri: Uri) {
+ this.albumUri.value = albumUri
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumsViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumsViewModel.kt
new file mode 100644
index 0000000..c976c8c
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/AlbumsViewModel.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.flow.SharingStarted
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import org.lineageos.twelve.models.RequestStatus
+
+class AlbumsViewModel(application: Application) : TwelveViewModel(application) {
+ val albums = mediaRepository.albums()
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ RequestStatus.Loading()
+ )
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/ArtistViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/ArtistViewModel.kt
new file mode 100644
index 0000000..f78d22e
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/ArtistViewModel.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+
+class ArtistViewModel(application: Application) : TwelveViewModel(application) {
+ private val artistUri = MutableStateFlow<Uri?>(null)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val artist = artistUri.flatMapLatest {
+ it?.let {
+ mediaRepository.artist(it)
+ } ?: flowOf(null)
+ }
+
+ fun loadAlbum(artistUri: Uri) {
+ this.artistUri.value = artistUri
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/ArtistsViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/ArtistsViewModel.kt
new file mode 100644
index 0000000..fbdcef7
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/ArtistsViewModel.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.flow.SharingStarted
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import org.lineageos.twelve.models.RequestStatus
+
+class ArtistsViewModel(application: Application) : TwelveViewModel(application) {
+ val artists = mediaRepository.artists()
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ RequestStatus.Loading()
+ )
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/GenresViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/GenresViewModel.kt
new file mode 100644
index 0000000..9ab7a1a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/GenresViewModel.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.flow.SharingStarted
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import org.lineageos.twelve.models.RequestStatus
+
+class GenresViewModel(application: Application) : TwelveViewModel(application) {
+ val genres = mediaRepository.genres()
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ RequestStatus.Loading()
+ )
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/NowPlayingViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/NowPlayingViewModel.kt
new file mode 100644
index 0000000..45b6809
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/NowPlayingViewModel.kt
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import org.lineageos.twelve.ext.next
+import org.lineageos.twelve.ext.typedRepeatMode
+
+class NowPlayingViewModel(application: Application) : TwelveViewModel(application) {
+ fun togglePlayPause() {
+ mediaController.value?.let {
+ if (it.isPlaying) {
+ it.pause()
+ } else {
+ it.play()
+ }
+ }
+ }
+
+ fun seekToPrevious() {
+ mediaController.value?.seekToPrevious()
+ }
+
+ fun seekToNext() {
+ mediaController.value?.seekToNext()
+ }
+
+ fun toggleShuffleMode() {
+ mediaController.value?.apply {
+ setShuffleModeEnabled(!shuffleModeEnabled)
+ }
+ }
+
+ fun toggleRepeatMode() {
+ mediaController.value?.apply {
+ typedRepeatMode = typedRepeatMode.next()
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/PlaylistsViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/PlaylistsViewModel.kt
new file mode 100644
index 0000000..a3a2b95
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/PlaylistsViewModel.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.flow.SharingStarted
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import org.lineageos.twelve.models.RequestStatus
+
+class PlaylistsViewModel(application: Application) : TwelveViewModel(application) {
+ val playlists = mediaRepository.playlists()
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ RequestStatus.Loading()
+ )
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/SearchViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/SearchViewModel.kt
new file mode 100644
index 0000000..744f655
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/SearchViewModel.kt
@@ -0,0 +1,34 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.mapLatest
+
+class SearchViewModel(application: Application) : TwelveViewModel(application) {
+ private val searchQuery = MutableStateFlow<String?>(null)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val searchResults = searchQuery
+ .mapLatest {
+ delay(500)
+ it
+ }
+ .flatMapLatest { query ->
+ query?.trim()?.takeIf { it.isNotEmpty() }?.let {
+ mediaRepository.search("%${it}%")
+ } ?: flowOf(null)
+ }
+
+ fun setSearchQuery(query: String?) {
+ searchQuery.value = query
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/TwelveViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/TwelveViewModel.kt
new file mode 100644
index 0000000..1361d01
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/TwelveViewModel.kt
@@ -0,0 +1,95 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import android.content.ComponentName
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.common.MediaItem
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.guava.await
+import org.lineageos.twelve.TwelveApplication
+import org.lineageos.twelve.ext.applicationContext
+import org.lineageos.twelve.ext.playbackStatusFlow
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.services.PlaybackService
+
+/**
+ * Base view model for all app view models.
+ * Here we keep the shared stuff every fragment could use, like access to the repository and
+ * the media controller to interact with the playback service.
+ */
+abstract class TwelveViewModel(application: Application) : AndroidViewModel(application) {
+ protected val mediaRepository = getApplication<TwelveApplication>().mediaRepository
+
+ final override fun <T : Application> getApplication() = super.getApplication<T>()
+
+ private val sessionToken by lazy {
+ SessionToken(
+ applicationContext,
+ ComponentName(applicationContext, PlaybackService::class.java)
+ )
+ }
+
+ private val mediaControllerFlow = channelFlow {
+ val mediaController = MediaController.Builder(applicationContext, sessionToken)
+ .buildAsync()
+ .await()
+
+ trySend(mediaController)
+
+ awaitClose {
+ mediaController.release()
+ }
+ }
+
+ protected val mediaController = mediaControllerFlow
+ .flowOn(Dispatchers.Main)
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = null
+ )
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val playbackStatus = mediaController.filterNotNull()
+ .flatMapLatest { it.playbackStatusFlow() }
+ .mapLatest {
+ RequestStatus.Success(it)
+ }
+ .flowOn(Dispatchers.Main)
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = RequestStatus.Loading()
+ )
+
+ fun playAudio(audio: Audio) {
+ mediaController.value?.apply {
+ setMediaItem(
+ MediaItem.Builder()
+ .setUri(audio.uri)
+ .setMimeType(audio.mimeType)
+ .build()
+ )
+ prepare()
+ play()
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_album.xml b/app/src/main/res/drawable/ic_album.xml
new file mode 100644
index 0000000..b217657
--- /dev/null
+++ b/app/src/main/res/drawable/ic_album.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="M480,660Q555,660 607.5,607.5Q660,555 660,480Q660,405 607.5,352.5Q555,300 480,300Q405,300 352.5,352.5Q300,405 300,480Q300,555 352.5,607.5Q405,660 480,660ZM480,520Q463,520 451.5,508.5Q440,497 440,480Q440,463 451.5,451.5Q463,440 480,440Q497,440 508.5,451.5Q520,463 520,480Q520,497 508.5,508.5Q497,520 480,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_cast.xml b/app/src/main/res/drawable/ic_cast.xml
new file mode 100644
index 0000000..2fb119f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cast.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="M480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480ZM800,800L600,800Q600,780 598.5,760Q597,740 594,720L800,720Q800,720 800,720Q800,720 800,720L800,240Q800,240 800,240Q800,240 800,240L160,240Q160,240 160,240Q160,240 160,240L160,286Q140,283 120,281.5Q100,280 80,280L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800ZM80,800L80,680Q130,680 165,715Q200,750 200,800L80,800ZM280,800Q280,717 221.5,658.5Q163,600 80,600L80,520Q197,520 278.5,601.5Q360,683 360,800L280,800ZM440,800Q440,725 411.5,659.5Q383,594 334.5,545.5Q286,497 220.5,468.5Q155,440 80,440L80,360Q171,360 251,394.5Q331,429 391,489Q451,549 485.5,629Q520,709 520,800L440,800Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_favorite.xml b/app/src/main/res/drawable/ic_favorite.xml
new file mode 100644
index 0000000..9e624e0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_favorite.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="M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_genres.xml b/app/src/main/res/drawable/ic_genres.xml
new file mode 100644
index 0000000..60f1830
--- /dev/null
+++ b/app/src/main/res/drawable/ic_genres.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="M400,720Q450,720 485,685Q520,650 520,600L520,320L640,320L640,240L460,240L460,496Q446,488 431,484Q416,480 400,480Q350,480 315,515Q280,550 280,600Q280,650 315,685Q350,720 400,720ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_graphic_eq.xml b/app/src/main/res/drawable/ic_graphic_eq.xml
new file mode 100644
index 0000000..f520b06
--- /dev/null
+++ b/app/src/main/res/drawable/ic_graphic_eq.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="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml
new file mode 100644
index 0000000..61271f7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home.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="M240,760L360,760L360,520L600,520L600,760L720,760L720,400L480,220L240,400L240,760ZM160,840L160,360L480,120L800,360L800,840L520,840L520,600L440,600L440,840L160,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_down.xml b/app/src/main/res/drawable/ic_keyboard_arrow_down.xml
new file mode 100644
index 0000000..908ae57
--- /dev/null
+++ b/app/src/main/res/drawable/ic_keyboard_arrow_down.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="M480,616L240,376L296,320L480,504L664,320L720,376L480,616Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..7cf5d7e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2022 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path
+ android:fillColor="#ffffff"
+ android:pathData="M0,0h108v108h-108z" />
+ <path
+ android:fillColor="#A92D63"
+ android:fillType="evenOdd"
+ android:pathData="M45,44V55.515C43.57,54.558 41.85,54 40,54C35.029,54 31,58.029 31,63C31,67.971 35.029,72 40,72C44.971,72 49,67.971 49,63V48C49,45.791 50.791,44 53,44H67C69.209,44 71,45.791 71,48V55.515C69.57,54.558 67.85,54 66,54C61.029,54 57,58.029 57,63C57,67.971 61.029,72 66,72C70.971,72 75,67.971 75,63V44C75,39.582 71.418,36 67,36H53C48.582,36 45,39.582 45,44ZM71,63C71,60.239 68.761,58 66,58C63.239,58 61,60.239 61,63C61,65.761 63.239,68 66,68C68.761,68 71,65.761 71,63ZM40,68C42.761,68 45,65.761 45,63C45,60.239 42.761,58 40,58C37.239,58 35,60.239 35,63C35,65.761 37.239,68 40,68Z" />
+ <path
+ android:fillColor="#521E35"
+ android:fillType="evenOdd"
+ android:pathData="M40,68C42.761,68 45,65.761 45,63C45,60.239 42.761,58 40,58C37.239,58 35,60.239 35,63C35,65.761 37.239,68 40,68ZM66,68C68.761,68 71,65.761 71,63C71,60.239 68.761,58 66,58C63.239,58 61,60.239 61,63C61,65.761 63.239,68 66,68Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..9b0a358
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2022 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:aapt="http://schemas.android.com/aapt"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <group>
+ <clip-path android:pathData="M0,0h108v108h-108z" />
+ <path
+ android:fillColor="#F73487"
+ android:fillType="evenOdd"
+ android:pathData="M40,72C44.971,72 49,67.971 49,63C49,58.029 44.971,54 40,54C35.029,54 31,58.029 31,63C31,67.971 35.029,72 40,72ZM66,72C70.971,72 75,67.971 75,63C75,58.029 70.971,54 66,54C61.029,54 57,58.029 57,63C57,67.971 61.029,72 66,72Z" />
+ <path
+ android:fillAlpha="0.6"
+ android:pathData="M28.54,28.54m-72,0a72,72 0,1 1,144 0a72,72 0,1 1,-144 0">
+ <aapt:attr name="android:fillColor">
+ <gradient
+ android:centerX="28.54"
+ android:centerY="28.54"
+ android:gradientRadius="72"
+ android:type="radial">
+ <item
+ android:color="#19FFFFFF"
+ android:offset="0" />
+ <item
+ android:color="#00FFFFFF"
+ android:offset="1" />
+ </gradient>
+ </aapt:attr>
+ </path>
+ </group>
+</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000..6f0f089
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2022 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path
+ android:fillColor="#ffffffff"
+ android:fillType="evenOdd"
+ android:pathData="M45,44V55.515C43.57,54.558 41.85,54 40,54C35.029,54 31,58.029 31,63C31,67.971 35.029,72 40,72C44.971,72 49,67.971 49,63V48C49,45.791 50.791,44 53,44H67C69.209,44 71,45.791 71,48V55.515C69.57,54.558 67.85,54 66,54C61.029,54 57,58.029 57,63C57,67.971 61.029,72 66,72C70.971,72 75,67.971 75,63V44C75,39.582 71.418,36 67,36H53C48.582,36 45,39.582 45,44Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_library_music.xml b/app/src/main/res/drawable/ic_library_music.xml
new file mode 100644
index 0000000..8abc7a5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_library_music.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="M500,600Q542,600 571,571Q600,542 600,500L600,280L720,280L720,200L560,200L560,420Q547,410 532,405Q517,400 500,400Q458,400 429,429Q400,458 400,500Q400,542 429,571Q458,600 500,600ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml
new file mode 100644
index 0000000..f6f9e7e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_more_vert.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="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/drawable/ic_music_note.xml b/app/src/main/res/drawable/ic_music_note.xml
new file mode 100644
index 0000000..0d98a72
--- /dev/null
+++ b/app/src/main/res/drawable/ic_music_note.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="M400,840Q334,840 287,793Q240,746 240,680Q240,614 287,567Q334,520 400,520Q423,520 442.5,525.5Q462,531 480,542L480,120L720,120L720,280L560,280L560,680Q560,746 513,793Q466,840 400,840Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml
new file mode 100644
index 0000000..2569009
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pause.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="M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml
new file mode 100644
index 0000000..f3e82b2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_person.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="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,800L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,800L160,800ZM240,720L720,720L720,688Q720,677 714.5,668Q709,659 700,654Q646,627 591,613.5Q536,600 480,600Q424,600 369,613.5Q314,627 260,654Q251,659 245.5,668Q240,677 240,688L240,720ZM480,400Q513,400 536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400ZM480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM480,720L480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720L480,720Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml
new file mode 100644
index 0000000..f2ccc12
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play_arrow.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="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_playlist_play.xml b/app/src/main/res/drawable/ic_playlist_play.xml
new file mode 100644
index 0000000..0f65210
--- /dev/null
+++ b/app/src/main/res/drawable/ic_playlist_play.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="M120,640L120,560L440,560L440,640L120,640ZM120,480L120,400L600,400L600,480L120,480ZM120,320L120,240L600,240L600,320L120,320ZM640,840L640,520L880,680L640,840Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_repeat.xml b/app/src/main/res/drawable/ic_repeat.xml
new file mode 100644
index 0000000..1fa7b8b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_repeat.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="M280,880L120,720L280,560L336,618L274,680L680,680L680,520L760,520L760,760L274,760L336,822L280,880ZM200,440L200,200L686,200L624,138L680,80L840,240L680,400L624,342L686,280L280,280L280,440L200,440Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml
new file mode 100644
index 0000000..d3ea1ba
--- /dev/null
+++ b/app/src/main/res/drawable/ic_repeat_one.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="M460,600L460,420L400,420L400,360L520,360L520,600L460,600ZM280,880L120,720L280,560L336,618L274,680L680,680L680,520L760,520L760,760L274,760L336,822L280,880ZM200,440L200,200L686,200L624,138L680,80L840,240L680,400L624,342L686,280L280,280L280,440L200,440Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml
new file mode 100644
index 0000000..654af99
--- /dev/null
+++ b/app/src/main/res/drawable/ic_search.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="M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_search_off.xml b/app/src/main/res/drawable/ic_search_off.xml
new file mode 100644
index 0000000..452335b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_search_off.xml
@@ -0,0 +1,16 @@
+<?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="M280,880Q197,880 138.5,821.5Q80,763 80,680Q80,597 138.5,538.5Q197,480 280,480Q363,480 421.5,538.5Q480,597 480,680Q480,763 421.5,821.5Q363,880 280,880ZM824,840L568,584Q568,584 568,584Q568,584 568,584Q556,571 542.5,557.5Q529,544 516,532Q554,508 577,468Q600,428 600,380Q600,305 547.5,252.5Q495,200 420,200Q345,200 292.5,252.5Q240,305 240,380Q240,386 240.5,391.5Q241,397 242,403Q224,405 202.5,411Q181,417 164,425Q162,414 161,403Q160,392 160,380Q160,271 235.5,195.5Q311,120 420,120Q529,120 604.5,195.5Q680,271 680,380Q680,423 666.5,461.5Q653,500 629,532L880,784L824,840ZM209,779L280,708L350,779L379,751L308,680L379,609L351,581L280,652L209,581L181,609L252,680L181,751L209,779Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml
new file mode 100644
index 0000000..6376431
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shuffle.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="M560,800L560,720L664,720L537,593L594,536L720,662L720,560L800,560L800,800L560,800ZM216,800L160,744L664,240L560,240L560,160L800,160L800,400L720,400L720,296L216,800ZM367,423L160,216L216,160L423,367L367,423Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml
new file mode 100644
index 0000000..0f85eed
--- /dev/null
+++ b/app/src/main/res/drawable/ic_skip_next.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="M660,720L660,240L740,240L740,720L660,720ZM220,720L220,240L580,480L220,720ZM300,480L300,480L300,480ZM300,570L436,480L300,390L300,570Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml
new file mode 100644
index 0000000..536e25d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_skip_previous.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="M220,720L220,240L300,240L300,720L220,720ZM740,720L380,480L740,240L740,720ZM660,480L660,480L660,480ZM660,570L660,390L524,480L660,570Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/now_playing_marker.xml b/app/src/main/res/drawable/now_playing_marker.xml
new file mode 100644
index 0000000..2b9a926
--- /dev/null
+++ b/app/src/main/res/drawable/now_playing_marker.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="8dp"
+ android:height="4dp"
+ android:viewportWidth="8"
+ android:viewportHeight="4">
+ <path
+ android:fillColor="#ffffff"
+ android:pathData="M4,4m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
+</vector>
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..443f9e8
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.fragment.app.FragmentContainerView 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/navHostFragment"
+ android:name="androidx.navigation.fragment.NavHostFragment"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:defaultNavHost="true"
+ app:navGraph="@navigation/fragment_main"
+ tools:context=".MainActivity" />
diff --git a/app/src/main/res/layout/fragment_activity.xml b/app/src/main/res/layout/fragment_activity.xml
new file mode 100644
index 0000000..8a67922
--- /dev/null
+++ b/app/src/main/res/layout/fragment_activity.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_album.xml b/app/src/main/res/layout/fragment_album.xml
new file mode 100644
index 0000000..e8870cb
--- /dev/null
+++ b/app/src/main/res/layout/fragment_album.xml
@@ -0,0 +1,97 @@
+<?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">
+
+ <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="236dp"
+ app:expandedTitleMarginBottom="28dp"
+ app:expandedTitleTextAppearance="?attr/textAppearanceHeadlineMedium"
+ app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
+
+ <ImageView
+ android:id="@+id/thumbnailImageView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
+ android:scaleType="centerCrop" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
+ android:background="#88000000" />
+
+ <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" />
+
+ </com.google.android.material.appbar.CollapsingToolbarLayout>
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/nestedScrollView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ <LinearLayout
+ android:id="@+id/noElementsLinearLayout"
+ 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_music_note" />
+
+ <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+ <TextView
+ style="@style/Theme.Twelve.NoElements.TextView"
+ android:text="@string/no_audios" />
+
+ </LinearLayout>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true" />
+
+ </FrameLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/fragment_albums.xml b/app/src/main/res/layout/fragment_albums.xml
new file mode 100644
index 0000000..8561669
--- /dev/null
+++ b/app/src/main/res/layout/fragment_albums.xml
@@ -0,0 +1,40 @@
+<?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"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone" />
+
+ <LinearLayout
+ android:id="@+id/noElementsLinearLayout"
+ style="@style/Theme.Twelve.NoElements.LinearLayout"
+ android:layout_gravity="center">
+
+ <ImageView
+ style="@style/Theme.Twelve.NoElements.ImageView"
+ android:contentDescription="@string/no_albums"
+ android:src="@drawable/ic_album" />
+
+ <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+ <TextView
+ style="@style/Theme.Twelve.NoElements.TextView"
+ android:text="@string/no_albums" />
+
+ </LinearLayout>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true" />
+
+</FrameLayout>
diff --git a/app/src/main/res/layout/fragment_artist.xml b/app/src/main/res/layout/fragment_artist.xml
new file mode 100644
index 0000000..bbe3be4
--- /dev/null
+++ b/app/src/main/res/layout/fragment_artist.xml
@@ -0,0 +1,118 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <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="236dp"
+ app:expandedTitleMarginBottom="28dp"
+ app:expandedTitleTextAppearance="?attr/textAppearanceHeadlineMedium"
+ app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
+
+ <ImageView
+ android:id="@+id/thumbnailImageView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
+ android:scaleType="centerCrop" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
+ android:background="#88000000" />
+
+ <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" />
+
+ </com.google.android.material.appbar.CollapsingToolbarLayout>
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/nestedScrollView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <LinearLayout
+ android:id="@+id/albumsLinearLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="16dp"
+ android:orientation="vertical"
+ 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/albums_header"
+ android:textAppearance="?attr/textAppearanceTitleLarge" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/albumsRecyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ app:spanCount="1"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/noElementsLinearLayout"
+ style="@style/Theme.Twelve.NoElements.LinearLayout"
+ android:layout_gravity="center">
+
+ <ImageView
+ style="@style/Theme.Twelve.NoElements.ImageView"
+ android:contentDescription="@string/no_albums"
+ android:src="@drawable/ic_album" />
+
+ <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+ <TextView
+ style="@style/Theme.Twelve.NoElements.TextView"
+ android:text="@string/no_albums" />
+
+ </LinearLayout>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true" />
+
+ </FrameLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/fragment_artists.xml b/app/src/main/res/layout/fragment_artists.xml
new file mode 100644
index 0000000..03e4ce9
--- /dev/null
+++ b/app/src/main/res/layout/fragment_artists.xml
@@ -0,0 +1,43 @@
+<?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"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ <LinearLayout
+ android:id="@+id/noElementsLinearLayout"
+ style="@style/Theme.Twelve.NoElements.LinearLayout"
+ android:layout_gravity="center">
+
+ <ImageView
+ style="@style/Theme.Twelve.NoElements.ImageView"
+ android:contentDescription="@string/no_artists"
+ android:src="@drawable/ic_person" />
+
+ <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+ <TextView
+ style="@style/Theme.Twelve.NoElements.TextView"
+ android:text="@string/no_artists" />
+
+ </LinearLayout>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true" />
+
+</FrameLayout>
diff --git a/app/src/main/res/layout/fragment_genres.xml b/app/src/main/res/layout/fragment_genres.xml
new file mode 100644
index 0000000..3566c69
--- /dev/null
+++ b/app/src/main/res/layout/fragment_genres.xml
@@ -0,0 +1,43 @@
+<?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"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ <LinearLayout
+ android:id="@+id/noElementsLinearLayout"
+ 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>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true" />
+
+</FrameLayout>
diff --git a/app/src/main/res/layout/fragment_library.xml b/app/src/main/res/layout/fragment_library.xml
new file mode 100644
index 0000000..f50324c
--- /dev/null
+++ b/app/src/main/res/layout/fragment_library.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.constraintlayout.widget.ConstraintLayout 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">
+
+ <com.google.android.material.tabs.TabLayout
+ android:id="@+id/tabLayout"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <androidx.viewpager2.widget.ViewPager2
+ android:id="@+id/viewPager2"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/tabLayout" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
new file mode 100644
index 0000000..67c14d4
--- /dev/null
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.constraintlayout.widget.ConstraintLayout 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">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appBarLayout"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <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.viewpager2.widget.ViewPager2
+ android:id="@+id/viewPager2"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/appBarLayout" />
+
+ <com.google.android.material.bottomnavigation.BottomNavigationView
+ android:id="@+id/bottomNavigationView"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:menu="@menu/bottom_navigation_view_fragment_main" />
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/nowPlayingFloatingActionButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="16dp"
+ android:layout_marginBottom="16dp"
+ android:src="@drawable/ic_music_note"
+ app:layout_constraintBottom_toBottomOf="@+id/viewPager2"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_now_playing.xml b/app/src/main/res/layout/fragment_now_playing.xml
new file mode 100644
index 0000000..d58b536
--- /dev/null
+++ b/app/src/main/res/layout/fragment_now_playing.xml
@@ -0,0 +1,316 @@
+<?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: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:orientation="vertical">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="64dp"
+ android:orientation="horizontal"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="20dp">
+
+ <ImageButton
+ android:id="@+id/hideImageButton"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:src="@drawable/ic_keyboard_arrow_down"
+ app:tint="?attr/colorOnSurface" />
+
+ <TextView
+ android:id="@+id/playlistNameTextView"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_marginHorizontal="16dp"
+ android:layout_weight="1"
+ android:textAlignment="center"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ tools:text="Eyes shut, mouth still" />
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/fileTypeMaterialCardView"
+ style="@style/Widget.Material3.CardView.Filled"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:visibility="gone"
+ app:cardBackgroundColor="?attr/colorPrimaryContainer"
+ app:cardCornerRadius="4dp"
+ app:contentPaddingLeft="4dp"
+ app:contentPaddingRight="4dp"
+ tools:visibility="visible">
+
+ <TextView
+ android:id="@+id/fileTypeTextView"
+ android:layout_width="wrap_content"
+ android:layout_height="24dp"
+ android:gravity="center"
+ android:textAlignment="gravity"
+ android:textAllCaps="true"
+ android:textAppearance="?attr/textAppearanceLabelMedium"
+ android:textColor="?attr/colorOnPrimaryContainer"
+ tools:text="flac" />
+
+ </com.google.android.material.card.MaterialCardView>
+
+ </LinearLayout>
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.core.widget.NestedScrollView
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginHorizontal="40dp"
+ android:gravity="center_horizontal"
+ android:orientation="vertical">
+
+ <com.google.android.material.card.MaterialCardView
+ style="@style/Widget.Material3.CardView.Filled"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="16dp"
+ app:cardCornerRadius="16dp">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <ImageView
+ android:id="@+id/albumArtImageView"
+ android:layout_width="match_parent"
+ 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>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="20dp"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/audioTitleTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
+ android:textColor="?attr/colorOnSurface"
+ tools:text="Tokyo Story" />
+
+ <TextView
+ android:id="@+id/artistNameTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceLabelLarge"
+ android:textColor="?attr/colorOnSurface"
+ tools:text="Ryuichi Sakamoto" />
+
+ <TextView
+ android:id="@+id/albumTitleTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceLabelLarge"
+ android:textColor="?attr/colorOnSurface"
+ tools:text="Sweet Revenge" />
+
+ </LinearLayout>
+
+ <com.google.android.material.slider.Slider
+ android:id="@+id/progressSlider"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:gravity="center"
+ android:orientation="horizontal">
+
+ <TextView
+ android:id="@+id/currentTimestampTextView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceLabelMedium"
+ android:textColor="?attr/colorOnSurface"
+ tools:text="0:13" />
+
+ <Space
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1" />
+
+ <TextView
+ android:id="@+id/durationTimestampTextView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceLabelMedium"
+ android:textColor="?attr/colorOnSurface"
+ tools:text="1:16" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ android:gravity="center"
+ android:orientation="horizontal">
+
+ <ImageButton
+ android:id="@+id/previousTrackImageButton"
+ android:layout_width="72dp"
+ android:layout_height="72dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="16dp"
+ android:scaleType="fitXY"
+ android:src="@drawable/ic_skip_previous"
+ app:tint="?attr/colorOnSurface" />
+
+ <ImageButton
+ android:id="@+id/playPauseImageButton"
+ android:layout_width="72dp"
+ android:layout_height="72dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="16dp"
+ android:scaleType="fitXY"
+ android:src="@drawable/ic_play_arrow"
+ app:tint="?attr/colorOnSurface" />
+
+ <ImageButton
+ android:id="@+id/nextTrackImageButton"
+ android:layout_width="72dp"
+ android:layout_height="72dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="16dp"
+ android:scaleType="fitXY"
+ android:src="@drawable/ic_skip_next"
+ app:tint="?attr/colorOnSurface" />
+
+ </LinearLayout>
+
+ <com.google.android.material.card.MaterialCardView
+ style="@style/Widget.Material3.CardView.Filled"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:cardBackgroundColor="?attr/colorSecondaryContainer"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+
+ <FrameLayout
+ android:layout_width="48dp"
+ android:layout_height="48dp">
+
+ <ImageButton
+ android:id="@+id/shuffleMarkerImageButton"
+ android:layout_width="8dp"
+ android:layout_height="4dp"
+ android:layout_gravity="bottom|center_horizontal"
+ android:importantForAccessibility="no"
+ android:src="@drawable/now_playing_marker"
+ android:visibility="gone"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+ <ImageButton
+ android:id="@+id/shuffleImageButton"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="12dp"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_shuffle"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+ </FrameLayout>
+
+ <FrameLayout
+ android:layout_width="48dp"
+ android:layout_height="48dp">
+
+ <ImageButton
+ android:id="@+id/repeatMarkerImageButton"
+ android:layout_width="8dp"
+ android:layout_height="4dp"
+ android:layout_gravity="bottom|center_horizontal"
+ android:importantForAccessibility="no"
+ android:src="@drawable/now_playing_marker"
+ android:visibility="gone"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+ <ImageButton
+ android:id="@+id/repeatImageButton"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="12dp"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_repeat"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+ </FrameLayout>
+
+ <ImageButton
+ android:id="@+id/equalizerImageButton"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="12dp"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_graphic_eq"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+ <ImageButton
+ android:id="@+id/castImageButton"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="12dp"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_cast"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+ <ImageButton
+ android:id="@+id/moreImageButton"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:padding="12dp"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_more_vert"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</LinearLayout>
diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml
new file mode 100644
index 0000000..205879f
--- /dev/null
+++ b/app/src/main/res/layout/fragment_playlists.xml
@@ -0,0 +1,43 @@
+<?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"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ <LinearLayout
+ android:id="@+id/noElementsLinearLayout"
+ style="@style/Theme.Twelve.NoElements.LinearLayout"
+ android:layout_gravity="center">
+
+ <ImageView
+ style="@style/Theme.Twelve.NoElements.ImageView"
+ android:contentDescription="@string/no_playlists"
+ android:src="@drawable/ic_playlist_play" />
+
+ <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+ <TextView
+ style="@style/Theme.Twelve.NoElements.TextView"
+ android:text="@string/no_playlists" />
+
+ </LinearLayout>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true" />
+
+</FrameLayout>
diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml
new file mode 100644
index 0000000..b75dcdd
--- /dev/null
+++ b/app/src/main/res/layout/fragment_search.xml
@@ -0,0 +1,62 @@
+<?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">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appBarLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <com.google.android.material.search.SearchBar
+ android:id="@+id/searchBar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <com.google.android.material.search.SearchView
+ android:id="@+id/searchView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layout_anchor="@+id/searchBar">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ <LinearLayout
+ android:id="@+id/noElementsLinearLayout"
+ style="@style/Theme.Twelve.NoElements.LinearLayout"
+ android:layout_gravity="center">
+
+ <ImageView
+ style="@style/Theme.Twelve.NoElements.ImageView"
+ android:contentDescription="@string/no_results"
+ android:src="@drawable/ic_search_off" />
+
+ <Space style="@style/Theme.Twelve.NoElements.Space" />
+
+ <TextView
+ style="@style/Theme.Twelve.NoElements.TextView"
+ android:text="@string/no_results" />
+
+ </LinearLayout>
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/linearProgressIndicator"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="true" />
+
+ </com.google.android.material.search.SearchView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/horizontal_list_item.xml b/app/src/main/res/layout/horizontal_list_item.xml
new file mode 100644
index 0000000..d73e662
--- /dev/null
+++ b/app/src/main/res/layout/horizontal_list_item.xml
@@ -0,0 +1,45 @@
+<?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:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="128dp"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:orientation="vertical">
+
+ <androidx.cardview.widget.CardView
+ style="@style/Widget.Material3.CardView.Filled"
+ android:layout_width="128dp"
+ android:layout_height="128dp"
+ android:layout_marginBottom="8dp"
+ app:cardCornerRadius="2dp">
+
+ <ImageView
+ android:id="@+id/thumbnailImageView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+
+ </androidx.cardview.widget.CardView>
+
+ <TextView
+ android:id="@+id/headlineTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:hyphenationFrequency="normal"
+ android:maxLines="1"
+ android:textAppearance="?attr/textAppearanceBodyLarge" />
+
+ <TextView
+ android:id="@+id/supportingTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:hyphenationFrequency="normal"
+ android:maxLines="1"
+ android:textAppearance="?attr/textAppearanceBodyMedium" />
+
+</LinearLayout>
diff --git a/app/src/main/res/layout/list_item.xml b/app/src/main/res/layout/list_item.xml
new file mode 100644
index 0000000..70eb846
--- /dev/null
+++ b/app/src/main/res/layout/list_item.xml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2023 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<LinearLayout 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:backgroundTint="?attr/colorSurface"
+ android:foreground="?attr/selectableItemBackground"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="12dp">
+
+ <ImageView
+ android:id="@+id/leadingIconImageView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="16dp"
+ android:visibility="gone"
+ app:tint="?attr/colorOnSurfaceVariant"
+ tools:src="@android:drawable/star_on"
+ tools:visibility="visible" />
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/headlineTextView"
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:hyphenationFrequency="normal"
+ android:maxLines="1"
+ android:textColor="?attr/colorOnSurface"
+ tools:text="Headline" />
+
+ <TextView
+ android:id="@+id/supportingTextView"
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:hyphenationFrequency="normal"
+ android:maxLines="2"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ tools:text="Supporting" />
+
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/trailingSupportingTextView"
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:ellipsize="end"
+ android:hyphenationFrequency="normal"
+ android:maxLines="1"
+ android:textAlignment="viewEnd"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:visibility="gone"
+ tools:text="Trailing"
+ tools:visibility="visible" />
+
+ <ImageView
+ android:id="@+id/trailingIconImageView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:visibility="gone"
+ app:tint="?attr/colorOnSurfaceVariant"
+ tools:src="@android:drawable/star_on"
+ tools:visibility="visible" />
+
+</LinearLayout>
diff --git a/app/src/main/res/menu/bottom_navigation_view_fragment_main.xml b/app/src/main/res/menu/bottom_navigation_view_fragment_main.xml
new file mode 100644
index 0000000..e7de53b
--- /dev/null
+++ b/app/src/main/res/menu/bottom_navigation_view_fragment_main.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <item
+ android:id="@+id/activityFragment"
+ android:icon="@drawable/ic_home"
+ android:title="@string/main_fragment_section_activity"
+ app:tint="?attr/colorOnSurface" />
+
+ <item
+ android:id="@+id/searchFragment"
+ android:icon="@drawable/ic_search"
+ android:title="@string/main_fragment_section_search"
+ app:tint="?attr/colorOnSurface" />
+
+ <item
+ android:id="@+id/libraryFragment"
+ android:icon="@drawable/ic_library_music"
+ android:title="@string/main_fragment_section_library"
+ app:tint="?attr/colorOnSurface" />
+
+</menu>
diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml
new file mode 100644
index 0000000..aa76129
--- /dev/null
+++ b/app/src/main/res/mipmap/ic_launcher.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2022 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@drawable/ic_launcher_background" />
+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
+ <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
+</adaptive-icon>
diff --git a/app/src/main/res/navigation/fragment_album.xml b/app/src/main/res/navigation/fragment_album.xml
new file mode 100644
index 0000000..361c60c
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_album.xml
@@ -0,0 +1,17 @@
+<?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_album"
+ app:startDestination="@id/albumFragment">
+
+ <fragment
+ android:id="@+id/albumFragment"
+ android:name="org.lineageos.twelve.fragments.AlbumFragment"
+ tools:layout="@layout/fragment_album" />
+
+</navigation>
diff --git a/app/src/main/res/navigation/fragment_artist.xml b/app/src/main/res/navigation/fragment_artist.xml
new file mode 100644
index 0000000..3711a7a
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_artist.xml
@@ -0,0 +1,29 @@
+<?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_artist"
+ app:startDestination="@id/artistFragment">
+
+ <include app:graph="@navigation/fragment_album" />
+
+ <fragment
+ android:id="@+id/artistFragment"
+ android:name="org.lineageos.twelve.fragments.ArtistFragment"
+ tools:layout="@layout/fragment_artist">
+
+ <action
+ android:id="@+id/action_artistFragment_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" />
+
+ </fragment>
+
+</navigation>
diff --git a/app/src/main/res/navigation/fragment_main.xml b/app/src/main/res/navigation/fragment_main.xml
new file mode 100644
index 0000000..4b05875
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_main.xml
@@ -0,0 +1,48 @@
+<?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_main"
+ app:startDestination="@id/mainFragment">
+
+ <include app:graph="@navigation/fragment_album" />
+ <include app:graph="@navigation/fragment_artist" />
+ <include app:graph="@navigation/fragment_now_playing" />
+
+ <fragment
+ android:id="@+id/mainFragment"
+ android:name="org.lineageos.twelve.fragments.MainFragment"
+ android:label="@string/app_name"
+ tools:layout="@layout/fragment_main">
+
+ <action
+ android:id="@+id/action_mainFragment_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_mainFragment_to_fragment_artist"
+ app:destination="@+id/fragment_artist"
+ 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"
+ 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_now_playing.xml b/app/src/main/res/navigation/fragment_now_playing.xml
new file mode 100644
index 0000000..d24d683
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_now_playing.xml
@@ -0,0 +1,17 @@
+<?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_now_playing"
+ app:startDestination="@id/nowPlayingFragment">
+
+ <fragment
+ android:id="@+id/nowPlayingFragment"
+ android:name="org.lineageos.twelve.fragments.NowPlayingFragment"
+ tools:layout="@layout/fragment_now_playing" />
+
+</navigation>
diff --git a/app/src/main/res/values/attrs_ListItem.xml b/app/src/main/res/values/attrs_ListItem.xml
new file mode 100644
index 0000000..e221766
--- /dev/null
+++ b/app/src/main/res/values/attrs_ListItem.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2023 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<resources>
+ <declare-styleable name="ListItem">
+ <attr name="headlineText" format="string" />
+ <attr name="leadingIconImage" format="reference" />
+ <attr name="supportingText" format="string" />
+ <attr name="trailingIconImage" format="reference" />
+ <attr name="trailingSupportingText" format="string" />
+ <attr name="leadingIconTint" format="color" />
+ <attr name="trailingIconTint" format="color" />
+ </declare-styleable>
+</resources>
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f2eeaa4
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<resources>
+
+</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..bf38036
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<resources>
+ <string name="app_name">Music</string>
+
+ <!-- Toast messages -->
+ <string name="app_permissions_toast">Permissions not granted by the user.</string>
+
+ <!-- Request status error types -->
+ <string name="request_status_error_type_unknown">Unknown</string>
+ <string name="request_status_error_type_not_found">Not found</string>
+ <string name="request_status_error_type_io">I/O error</string>
+ <string name="request_status_error_type_authentication_required">Authentication required</string>
+ <string name="request_status_error_type_invalid_credentials">Invalid credentials</string>
+
+ <!-- Main fragment sections -->
+ <string name="main_fragment_section_activity">Activity</string>
+ <string name="main_fragment_section_search">Search</string>
+ <string name="main_fragment_section_library">Library</string>
+
+ <!-- Library fragment menus -->
+ <string name="library_fragment_menu_albums">Albums</string>
+ <string name="library_fragment_menu_artists">Artists</string>
+ <string name="library_fragment_menu_genres">Genres</string>
+ <string name="library_fragment_menu_playlists">Playlists</string>
+
+ <!-- No elements -->
+ <string name="no_albums">No albums</string>
+ <string name="no_artists">No artists</string>
+ <string name="no_genres">No genres</string>
+ <string name="no_playlists">No playlists</string>
+ <string name="no_audios">No audios</string>
+ <string name="no_results">No results</string>
+
+ <!-- Genres -->
+ <string name="genre_unknown">Unknown</string>
+
+ <!-- Artist fragment -->
+ <string name="albums_header">Albums</string>
+</resources>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..27af04e
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<resources>
+ <!-- Application theme -->
+ <style name="Theme.Twelve" parent="Theme.Material3.DayNight.NoActionBar" />
+
+ <!-- No elements styles -->
+ <style name="Theme.Twelve.NoElements" />
+
+ <style name="Theme.Twelve.NoElements.LinearLayout">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:gravity">center</item>
+ <item name="android:orientation">vertical</item>
+ <item name="android:visibility">gone</item>
+ </style>
+
+ <style name="Theme.Twelve.NoElements.ImageView">
+ <item name="android:layout_width">72dp</item>
+ <item name="android:layout_height">72dp</item>
+ <item name="tint">?attr/colorOnSurface</item>
+ </style>
+
+ <style name="Theme.Twelve.NoElements.Space">
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">8dp</item>
+ </style>
+
+ <style name="Theme.Twelve.NoElements.TextView" parent="Widget.AppCompat.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textAppearance">@style/TextAppearance.Material3.BodyLarge</item>
+ <item name="android:textColor">?attr/colorOnSurface</item>
+ </style>
+</resources>
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..a4c11fc
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,10 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..7095388
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,26 @@
+# SPDX-FileCopyrightText: 2024 The LineageOS Project
+# SPDX-License-Identifier: Apache-2.0
+
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..a44936a
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,43 @@
+#
+# SPDX-FileCopyrightText: 2024 The LineageOS Project
+# SPDX-License-Identifier: Apache-2.0
+#
+
+[versions]
+agp = "8.6.1"
+kotlin = "1.9.0"
+activity = "1.9.2"
+appcompat = "1.7.0"
+constraintlayout = "2.1.4"
+core = "1.13.1"
+fragment = "1.8.3"
+kotlinx-coroutines = "1.8.1"
+lifecycle = "2.8.5"
+material = "1.12.0"
+media3 = "1.5.0-alpha01"
+navigation = "2.8.0"
+recyclerview = "1.3.2"
+viewpager2 = "1.1.0"
+
+[libraries]
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core" }
+androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
+androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
+androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "media3" }
+androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
+androidx-media3-exoplayer-midi = { group = "androidx.media3", name = "media3-exoplayer-midi", version.ref = "media3" }
+androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
+androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
+androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
+androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
+androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
+androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
+kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinx-coroutines" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..9355b41
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..a11937b
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven("https://jitpack.io")
+ }
+}
+
+rootProject.name = "Twelve"
+include(":app")