all: switch to spotless
Switch to the spotless linter with ktfmt used as a backend instead of ktlint. This switch was done for two reasons: 1. ktfmt is more thorough than ktlint 2. License headers can be added more effectively with spotless than the default Android Studio behavior. Dump all of the changes now so I don't have to deal with it over a long period of time. I don't care.
This commit is contained in:
parent
627ab97948
commit
33da09a08a
99 changed files with 2829 additions and 3094 deletions
|
@ -2,6 +2,9 @@
|
|||
|
||||
## dev [v2.2.3, v2.3.0, or v3.0.0]
|
||||
|
||||
#### Dev/Meta
|
||||
- Switched to spotless and ktfmt instead of ktlint
|
||||
|
||||
## v2.2.2
|
||||
#### What's New
|
||||
- New spanish translations and metadata [courtesy of n-berenice]
|
||||
|
|
17
app/NOTICE
Normal file
17
app/NOTICE
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright (c) $today.year Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
|
@ -2,6 +2,7 @@ apply plugin: "com.android.application"
|
|||
apply plugin: "kotlin-android"
|
||||
apply plugin: "kotlin-kapt"
|
||||
apply plugin: "androidx.navigation.safeargs.kotlin"
|
||||
apply plugin: "com.diffplug.spotless"
|
||||
|
||||
android {
|
||||
compileSdkVersion 32
|
||||
|
@ -45,12 +46,8 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
ktlint
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
preDebugBuild.dependsOn ktlintFormat
|
||||
preDebugBuild.dependsOn spotlessApply
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -104,24 +101,13 @@ dependencies {
|
|||
|
||||
// Material
|
||||
implementation "com.google.android.material:material:1.6.0-alpha03"
|
||||
|
||||
// --- DEBUG ---
|
||||
|
||||
// Lint
|
||||
ktlint "com.pinterest:ktlint:0.44.0"
|
||||
}
|
||||
|
||||
task ktlint(type: JavaExec, group: "verification") {
|
||||
description = "Check Kotlin code style."
|
||||
mainClass.set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.ktlint
|
||||
args "src/**/*.kt"
|
||||
}
|
||||
check.dependsOn ktlint
|
||||
spotless {
|
||||
kotlin {
|
||||
target "src/**/*.kt"
|
||||
|
||||
task ktlintFormat(type: JavaExec, group: "formatting") {
|
||||
description = "Fix Kotlin code style deviations."
|
||||
mainClass.set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.ktlint
|
||||
args "-F", "src/**/*.kt"
|
||||
ktfmt('0.30').dropboxStyle()
|
||||
licenseHeaderFile("NOTICE")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AuxioApp.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -31,10 +30,12 @@ import org.oxycblt.auxio.settings.SettingsManager
|
|||
|
||||
/**
|
||||
* TODO: Plan for a general UI rework
|
||||
* ```
|
||||
* - Refactor fragment class
|
||||
* - Remove databinding and dedup layouts
|
||||
* - Rework RecyclerView management and item dragging
|
||||
* - Rework sealed classes to minimize whens and maximize overrides
|
||||
* ```
|
||||
*/
|
||||
@Suppress("UNUSED")
|
||||
class AuxioApp : Application(), ImageLoaderFactory {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MainActivity.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -39,10 +38,8 @@ import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
|
|||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* The single [AppCompatActivity] for Auxio.
|
||||
* TODO: Add a new view for crashes with a stack trace
|
||||
* TODO: Custom language support
|
||||
* TODO: Rework menus [perhaps add multi-select]
|
||||
* The single [AppCompatActivity] for Auxio. TODO: Add a new view for crashes with a stack trace
|
||||
* TODO: Custom language support TODO: Rework menus [perhaps add multi-select]
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val playbackModel: PlaybackViewModel by viewModels()
|
||||
|
@ -52,9 +49,8 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
setupTheme()
|
||||
|
||||
val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
|
||||
this, R.layout.activity_main
|
||||
)
|
||||
val binding =
|
||||
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
|
||||
|
||||
applyEdgeToEdgeWindow(binding)
|
||||
|
||||
|
@ -82,9 +78,7 @@ class MainActivity : AppCompatActivity() {
|
|||
if (action == Intent.ACTION_VIEW && !isConsumed) {
|
||||
// Mark the intent as used so this does not fire again
|
||||
intent.putExtra(KEY_INTENT_USED, true)
|
||||
intent.data?.let { fileUri ->
|
||||
playbackModel.playWithUri(fileUri, this)
|
||||
}
|
||||
intent.data?.let { fileUri -> playbackModel.playWithUri(fileUri, this) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -129,12 +123,10 @@ class MainActivity : AppCompatActivity() {
|
|||
WindowInsets.Builder()
|
||||
.setInsets(
|
||||
WindowInsets.Type.systemBars(),
|
||||
insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars())
|
||||
)
|
||||
insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()))
|
||||
.setInsets(
|
||||
WindowInsets.Type.systemGestures(),
|
||||
insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemGestures())
|
||||
)
|
||||
insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemGestures()))
|
||||
.build()
|
||||
.applyLeftRightInsets(binding)
|
||||
}
|
||||
|
@ -144,12 +136,10 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
@Suppress("DEPRECATION")
|
||||
binding.root.apply {
|
||||
systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
systemUiVisibility =
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
|
||||
setOnApplyWindowInsetsListener { _, insets ->
|
||||
insets.applyLeftRightInsets(binding)
|
||||
}
|
||||
setOnApplyWindowInsetsListener { _, insets -> insets.applyLeftRightInsets(binding) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -157,10 +147,7 @@ class MainActivity : AppCompatActivity() {
|
|||
private fun WindowInsets.applyLeftRightInsets(binding: ViewBinding): WindowInsets {
|
||||
val bars = systemBarInsetsCompat
|
||||
|
||||
binding.root.updatePadding(
|
||||
left = bars.left,
|
||||
right = bars.right
|
||||
)
|
||||
binding.root.updatePadding(left = bars.left, right = bars.right)
|
||||
|
||||
return replaceSystemBarInsetsCompat(0, bars.top, 0, bars.bottom)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MainFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -39,10 +38,10 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A wrapper around the home fragment that shows the playback fragment and controls
|
||||
* the more high-level navigation features.
|
||||
* @author OxygenCobalt
|
||||
* TODO: Add a new view with a stack trace whenever the music loading process fails.
|
||||
* A wrapper around the home fragment that shows the playback fragment and controls the more
|
||||
* high-level navigation features.
|
||||
* @author OxygenCobalt TODO: Add a new view with a stack trace whenever the music loading process
|
||||
* fails.
|
||||
*/
|
||||
class MainFragment : Fragment() {
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
@ -58,9 +57,8 @@ class MainFragment : Fragment() {
|
|||
val binding = FragmentMainBinding.inflate(inflater)
|
||||
|
||||
// Build the permission launcher here as you can only do it in onCreateView/onCreate
|
||||
val permLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) {
|
||||
val permLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
musicModel.reloadMusic(requireContext())
|
||||
}
|
||||
|
||||
|
@ -68,12 +66,9 @@ class MainFragment : Fragment() {
|
|||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
Callback(binding).also {
|
||||
callback = it
|
||||
}
|
||||
)
|
||||
requireActivity()
|
||||
.onBackPressedDispatcher
|
||||
.addCallback(viewLifecycleOwner, Callback(binding).also { callback = it })
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
// Auxio's layout completely breaks down when it's window is resized too small,
|
||||
|
@ -110,15 +105,15 @@ class MainFragment : Fragment() {
|
|||
is MusicStore.Response.Err -> {
|
||||
logW("Received Error")
|
||||
|
||||
val errorRes = when (response.kind) {
|
||||
val errorRes =
|
||||
when (response.kind) {
|
||||
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music
|
||||
MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms
|
||||
MusicStore.ErrorKind.FAILED -> R.string.err_load_failed
|
||||
}
|
||||
|
||||
val snackbar = Snackbar.make(
|
||||
binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE
|
||||
)
|
||||
val snackbar =
|
||||
Snackbar.make(binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE)
|
||||
|
||||
when (response.kind) {
|
||||
MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> {
|
||||
|
@ -126,7 +121,6 @@ class MainFragment : Fragment() {
|
|||
musicModel.reloadMusic(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
MusicStore.ErrorKind.NO_PERMS -> {
|
||||
snackbar.setAction(R.string.lbl_grant) {
|
||||
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
|
@ -164,7 +158,8 @@ class MainFragment : Fragment() {
|
|||
if (!binding.playbackLayout.collapse()) {
|
||||
val navController = binding.exploreNavHost.findNavController()
|
||||
|
||||
if (navController.currentDestination?.id == navController.graph.startDestinationId) {
|
||||
if (navController.currentDestination?.id ==
|
||||
navController.graph.startDestinationId) {
|
||||
isEnabled = false
|
||||
requireActivity().onBackPressed()
|
||||
isEnabled = true
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Accent.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -20,9 +19,11 @@ package org.oxycblt.auxio.accent
|
|||
|
||||
import org.oxycblt.auxio.R
|
||||
|
||||
val ACCENT_COUNT: Int get() = ACCENT_NAMES.size
|
||||
val ACCENT_COUNT: Int
|
||||
get() = ACCENT_NAMES.size
|
||||
|
||||
private val ACCENT_NAMES = arrayOf(
|
||||
private val ACCENT_NAMES =
|
||||
arrayOf(
|
||||
R.string.clr_red,
|
||||
R.string.clr_pink,
|
||||
R.string.clr_purple,
|
||||
|
@ -39,9 +40,10 @@ private val ACCENT_NAMES = arrayOf(
|
|||
R.string.clr_orange,
|
||||
R.string.clr_brown,
|
||||
R.string.clr_grey,
|
||||
)
|
||||
)
|
||||
|
||||
private val ACCENT_THEMES = arrayOf(
|
||||
private val ACCENT_THEMES =
|
||||
arrayOf(
|
||||
R.style.Theme_Auxio_Red,
|
||||
R.style.Theme_Auxio_Pink,
|
||||
R.style.Theme_Auxio_Purple,
|
||||
|
@ -58,9 +60,10 @@ private val ACCENT_THEMES = arrayOf(
|
|||
R.style.Theme_Auxio_Orange,
|
||||
R.style.Theme_Auxio_Brown,
|
||||
R.style.Theme_Auxio_Grey,
|
||||
)
|
||||
)
|
||||
|
||||
private val ACCENT_BLACK_THEMES = arrayOf(
|
||||
private val ACCENT_BLACK_THEMES =
|
||||
arrayOf(
|
||||
R.style.Theme_Auxio_Black_Red,
|
||||
R.style.Theme_Auxio_Black_Pink,
|
||||
R.style.Theme_Auxio_Black_Purple,
|
||||
|
@ -77,9 +80,10 @@ private val ACCENT_BLACK_THEMES = arrayOf(
|
|||
R.style.Theme_Auxio_Black_Orange,
|
||||
R.style.Theme_Auxio_Black_Brown,
|
||||
R.style.Theme_Auxio_Black_Grey,
|
||||
)
|
||||
)
|
||||
|
||||
private val ACCENT_PRIMARY_COLORS = arrayOf(
|
||||
private val ACCENT_PRIMARY_COLORS =
|
||||
arrayOf(
|
||||
R.color.red_primary,
|
||||
R.color.pink_primary,
|
||||
R.color.purple_primary,
|
||||
|
@ -96,12 +100,12 @@ private val ACCENT_PRIMARY_COLORS = arrayOf(
|
|||
R.color.orange_primary,
|
||||
R.color.brown_primary,
|
||||
R.color.grey_primary,
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* The data object for an accent. In the UI this is known as a "Color Scheme."
|
||||
* This can be nominally used to gleam some attributes about a given color scheme, but this
|
||||
* is not recommended. Attributes are the better option in nearly all cases.
|
||||
* The data object for an accent. In the UI this is known as a "Color Scheme." This can be nominally
|
||||
* used to gleam some attributes about a given color scheme, but this is not recommended. Attributes
|
||||
* are the better option in nearly all cases.
|
||||
*
|
||||
* @property name The name of this accent
|
||||
* @property theme The theme resource for this accent
|
||||
|
@ -110,8 +114,12 @@ private val ACCENT_PRIMARY_COLORS = arrayOf(
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
data class Accent(val index: Int) {
|
||||
val name: Int get() = ACCENT_NAMES[index]
|
||||
val theme: Int get() = ACCENT_THEMES[index]
|
||||
val blackTheme: Int get() = ACCENT_BLACK_THEMES[index]
|
||||
val primary: Int get() = ACCENT_PRIMARY_COLORS[index]
|
||||
val name: Int
|
||||
get() = ACCENT_NAMES[index]
|
||||
val theme: Int
|
||||
get() = ACCENT_THEMES[index]
|
||||
val blackTheme: Int
|
||||
get() = ACCENT_BLACK_THEMES[index]
|
||||
val primary: Int
|
||||
get() = ACCENT_PRIMARY_COLORS[index]
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AccentAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -33,10 +32,8 @@ import org.oxycblt.auxio.util.stateList
|
|||
* @author OxygenCobalt
|
||||
* @param onSelect What to do when an accent is selected.
|
||||
*/
|
||||
class AccentAdapter(
|
||||
private var curAccent: Accent,
|
||||
private val onSelect: (accent: Accent) -> Unit
|
||||
) : RecyclerView.Adapter<AccentAdapter.ViewHolder>() {
|
||||
class AccentAdapter(private var curAccent: Accent, private val onSelect: (accent: Accent) -> Unit) :
|
||||
RecyclerView.Adapter<AccentAdapter.ViewHolder>() {
|
||||
private var selectedViewHolder: ViewHolder? = null
|
||||
|
||||
override fun getItemCount(): Int = ACCENT_COUNT
|
||||
|
@ -54,9 +51,8 @@ class AccentAdapter(
|
|||
onSelect(accent)
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
private val binding: ItemAccentBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
inner class ViewHolder(private val binding: ItemAccentBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(accent: Accent) {
|
||||
setSelected(accent == curAccent)
|
||||
|
@ -77,7 +73,8 @@ class AccentAdapter(
|
|||
val context = binding.accent.context
|
||||
|
||||
binding.accent.isEnabled = !isSelected
|
||||
binding.accent.imageTintList = if (isSelected) {
|
||||
binding.accent.imageTintList =
|
||||
if (isSelected) {
|
||||
// Switch out the currently selected ViewHolder with this one.
|
||||
selectedViewHolder?.setSelected(false)
|
||||
selectedViewHolder = this
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AccentDialog.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -52,7 +51,8 @@ class AccentCustomizeDialog : LifecycleDialog() {
|
|||
// --- UI SETUP ---
|
||||
|
||||
binding.accentRecycler.apply {
|
||||
adapter = AccentAdapter(pendingAccent) { accent ->
|
||||
adapter =
|
||||
AccentAdapter(pendingAccent) { accent ->
|
||||
logD("Switching selected accent to $accent")
|
||||
pendingAccent = accent
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AutoGridLayoutManager.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -22,13 +21,13 @@ import android.content.Context
|
|||
import android.util.AttributeSet
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.util.pxOfDp
|
||||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.util.pxOfDp
|
||||
|
||||
/**
|
||||
* A sub-class of [GridLayoutManager] that automatically sets the spans so that they fit the width
|
||||
* of the RecyclerView.
|
||||
* Adapted from this StackOverflow answer: https://stackoverflow.com/a/30256880/14143986
|
||||
* of the RecyclerView. Adapted from this StackOverflow answer:
|
||||
* https://stackoverflow.com/a/30256880/14143986
|
||||
*/
|
||||
class AccentGridLayoutManager(
|
||||
context: Context,
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.coil
|
||||
|
||||
import android.content.Context
|
||||
|
@ -5,6 +22,7 @@ import android.graphics.Bitmap
|
|||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.util.Size as AndroidSize
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
|
@ -20,6 +38,8 @@ import com.google.android.exoplayer2.MediaMetadata
|
|||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
import com.google.android.exoplayer2.metadata.flac.PictureFrame
|
||||
import com.google.android.exoplayer2.metadata.id3.ApicFrame
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.buffer
|
||||
|
@ -28,22 +48,17 @@ import org.oxycblt.auxio.music.Album
|
|||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import android.util.Size as AndroidSize
|
||||
|
||||
/**
|
||||
* The base implementation for all image fetchers in Auxio.
|
||||
* @author OxygenCobalt
|
||||
* TODO: Artist images
|
||||
* @author OxygenCobalt TODO: Artist images
|
||||
*/
|
||||
abstract class BaseFetcher : Fetcher {
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
/**
|
||||
* Fetch the artwork of an [album].
|
||||
* This call respects user configuration and has proper redundancy in the case that
|
||||
* an API fails to load.
|
||||
* Fetch the artwork of an [album]. This call respects user configuration and has proper
|
||||
* redundancy in the case that an API fails to load.
|
||||
*/
|
||||
protected suspend fun fetchArt(context: Context, album: Album): InputStream? {
|
||||
if (!settingsManager.showCovers) {
|
||||
|
@ -67,9 +82,7 @@ abstract class BaseFetcher : Fetcher {
|
|||
val uri = data.albumCoverUri
|
||||
|
||||
// Eliminate any chance that this blocking call might mess up the cancellation process
|
||||
return withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)
|
||||
}
|
||||
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
|
||||
}
|
||||
|
||||
private suspend fun fetchQualityCovers(context: Context, album: Album): InputStream? {
|
||||
|
@ -115,17 +128,13 @@ abstract class BaseFetcher : Fetcher {
|
|||
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
||||
// ByteArray of the cover without any compression artifacts.
|
||||
// If its null [i.e there is no embedded cover], than just ignore it and move on
|
||||
return ext.embeddedPicture?.let { coverBytes ->
|
||||
ByteArrayInputStream(coverBytes)
|
||||
}
|
||||
return ext.embeddedPicture?.let { coverBytes -> ByteArrayInputStream(coverBytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
|
||||
val uri = album.songs[0].uri
|
||||
val future = MetadataRetriever.retrieveMetadata(
|
||||
context, MediaItem.fromUri(uri)
|
||||
)
|
||||
val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri))
|
||||
|
||||
// future.get is a blocking call that makes us spin until the future is done.
|
||||
// This is bad for a co-routine, as it prevents cancellation and by extension
|
||||
|
@ -133,7 +142,8 @@ abstract class BaseFetcher : Fetcher {
|
|||
// To fix this we wrap this around in a withContext call to make it suspend and make
|
||||
// sure that the runner can do other coroutines.
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val tracks = withContext(Dispatchers.IO) {
|
||||
val tracks =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
future.get()
|
||||
} catch (e: Exception) {
|
||||
|
@ -201,14 +211,17 @@ abstract class BaseFetcher : Fetcher {
|
|||
* Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
|
||||
* https://github.com/kabouzeid/Phonograph
|
||||
*/
|
||||
protected suspend fun createMosaic(context: Context, streams: List<InputStream>, size: Size): FetchResult? {
|
||||
protected suspend fun createMosaic(
|
||||
context: Context,
|
||||
streams: List<InputStream>,
|
||||
size: Size
|
||||
): FetchResult? {
|
||||
if (streams.size < 4) {
|
||||
return streams.firstOrNull()?.let { stream ->
|
||||
return SourceResult(
|
||||
source = ImageSource(stream.source().buffer(), context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK
|
||||
)
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -216,15 +229,11 @@ abstract class BaseFetcher : Fetcher {
|
|||
// get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a
|
||||
// 512x512 mosaic.
|
||||
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
|
||||
val mosaicFrameSize = Size(
|
||||
Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2)
|
||||
)
|
||||
val mosaicFrameSize =
|
||||
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
||||
|
||||
val mosaicBitmap = Bitmap.createBitmap(
|
||||
mosaicSize.width,
|
||||
mosaicSize.height,
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val mosaicBitmap =
|
||||
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(mosaicBitmap)
|
||||
|
||||
var x = 0
|
||||
|
@ -239,11 +248,9 @@ abstract class BaseFetcher : Fetcher {
|
|||
|
||||
// Run the bitmap through a transform to make sure it's a square of the desired
|
||||
// resolution.
|
||||
val bitmap = SquareFrameTransform.INSTANCE
|
||||
.transform(
|
||||
BitmapFactory.decodeStream(stream),
|
||||
mosaicFrameSize
|
||||
)
|
||||
val bitmap =
|
||||
SquareFrameTransform.INSTANCE.transform(
|
||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||
|
||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||
|
||||
|
@ -261,8 +268,7 @@ abstract class BaseFetcher : Fetcher {
|
|||
return DrawableResult(
|
||||
drawable = mosaicBitmap.toDrawable(context.resources),
|
||||
isSampled = true,
|
||||
dataSource = DataSource.DISK
|
||||
)
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
private fun Dimension.mosaicSize(): Int {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* CoilUtils.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -38,27 +37,19 @@ import org.oxycblt.auxio.music.Song
|
|||
|
||||
// --- BINDING ADAPTERS ---
|
||||
|
||||
/**
|
||||
* Bind the album art for a [song].
|
||||
*/
|
||||
/** Bind the album art for a [song]. */
|
||||
@BindingAdapter("albumArt")
|
||||
fun ImageView.bindAlbumArt(song: Song?) = load(song, R.drawable.ic_album)
|
||||
|
||||
/**
|
||||
* Bind the album art for an [album].
|
||||
*/
|
||||
/** Bind the album art for an [album]. */
|
||||
@BindingAdapter("albumArt")
|
||||
fun ImageView.bindAlbumArt(album: Album?) = load(album, R.drawable.ic_album)
|
||||
|
||||
/**
|
||||
* Bind the image for an [artist]
|
||||
*/
|
||||
/** Bind the image for an [artist] */
|
||||
@BindingAdapter("artistImage")
|
||||
fun ImageView.bindArtistImage(artist: Artist?) = load(artist, R.drawable.ic_artist)
|
||||
|
||||
/**
|
||||
* Bind the image for a [genre]
|
||||
*/
|
||||
/** Bind the image for a [genre] */
|
||||
@BindingAdapter("genreImage")
|
||||
fun ImageView.bindGenreImage(genre: Genre?) = load(genre, R.drawable.ic_genre)
|
||||
|
||||
|
@ -74,23 +65,14 @@ fun <T : Music> ImageView.load(music: T?, @DrawableRes error: Int) {
|
|||
|
||||
/**
|
||||
* Get a bitmap for a [song]. [onDone] will be called with the loaded bitmap, or null if loading
|
||||
* failed/shouldn't occur.
|
||||
* **This not meant for UIs, instead use the Binding Adapters.**
|
||||
* failed/shouldn't occur. **This not meant for UIs, instead use the Binding Adapters.**
|
||||
*/
|
||||
fun loadBitmap(
|
||||
context: Context,
|
||||
song: Song,
|
||||
onDone: (Bitmap?) -> Unit
|
||||
) {
|
||||
fun loadBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
|
||||
context.imageLoader.enqueue(
|
||||
ImageRequest.Builder(context)
|
||||
.data(song.album)
|
||||
.size(Size.ORIGINAL)
|
||||
.transformations(SquareFrameTransform())
|
||||
.target(
|
||||
onError = { onDone(null) },
|
||||
onSuccess = { onDone(it.toBitmap()) }
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
|
||||
.build())
|
||||
}
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.coil
|
||||
|
||||
import coil.decode.DataSource
|
||||
|
@ -9,8 +26,8 @@ import coil.transition.Transition
|
|||
import coil.transition.TransitionTarget
|
||||
|
||||
/**
|
||||
* A copy of [CrossfadeTransition.Factory] that applies a transition to error results.
|
||||
* You know. Like they used to.
|
||||
* A copy of [CrossfadeTransition.Factory] that applies a transition to error results. You know.
|
||||
* Like they used to.
|
||||
* @author Coil Team
|
||||
*/
|
||||
class CrossfadeFactory : Transition.Factory {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Fetchers.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -27,6 +26,7 @@ import coil.fetch.Fetcher
|
|||
import coil.fetch.SourceResult
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import kotlin.math.min
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.auxio.music.Album
|
||||
|
@ -34,23 +34,19 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Fetcher that returns the album art for a given [Album] or [Song], depending on the factory used.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class AlbumArtFetcher private constructor(
|
||||
private val context: Context,
|
||||
private val album: Album
|
||||
) : BaseFetcher() {
|
||||
class AlbumArtFetcher private constructor(private val context: Context, private val album: Album) :
|
||||
BaseFetcher() {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
return fetchArt(context, album)?.let { stream ->
|
||||
SourceResult(
|
||||
source = ImageSource(stream.source().buffer(), context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK
|
||||
)
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,17 +67,15 @@ class AlbumArtFetcher private constructor(
|
|||
* Fetcher that fetches the image for an [Artist]
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class ArtistImageFetcher private constructor(
|
||||
class ArtistImageFetcher
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val size: Size,
|
||||
private val artist: Artist,
|
||||
) : BaseFetcher() {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val albums = Sort.ByName(true)
|
||||
.sortAlbums(artist.albums)
|
||||
val results = albums.mapAtMost(4) { album ->
|
||||
fetchArt(context, album)
|
||||
}
|
||||
val albums = Sort.ByName(true).sortAlbums(artist.albums)
|
||||
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
|
||||
|
||||
return createMosaic(context, results, size)
|
||||
}
|
||||
|
@ -97,7 +91,8 @@ class ArtistImageFetcher private constructor(
|
|||
* Fetcher that fetches the image for a [Genre]
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class GenreImageFetcher private constructor(
|
||||
class GenreImageFetcher
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val size: Size,
|
||||
private val genre: Genre,
|
||||
|
@ -105,9 +100,7 @@ class GenreImageFetcher private constructor(
|
|||
override suspend fun fetch(): FetchResult? {
|
||||
// We don't need to sort here, as the way we
|
||||
val albums = genre.songs.groupBy { it.album }.keys
|
||||
val results = albums.mapAtMost(4) { album ->
|
||||
fetchArt(context, album)
|
||||
}
|
||||
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
|
||||
|
||||
return createMosaic(context, results, size)
|
||||
}
|
||||
|
@ -120,10 +113,13 @@ class GenreImageFetcher private constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Map at most [n] items from a collection. [transform] is called for each item that is eligible.
|
||||
* If null is returned, then that item will be skipped.
|
||||
* Map at most [n] items from a collection. [transform] is called for each item that is eligible. If
|
||||
* null is returned, then that item will be skipped.
|
||||
*/
|
||||
private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(n: Int, transform: (T) -> R?): List<R> {
|
||||
private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(
|
||||
n: Int,
|
||||
transform: (T) -> R?
|
||||
): List<R> {
|
||||
val until = min(size, n)
|
||||
val out = mutableListOf<R>()
|
||||
|
||||
|
@ -132,9 +128,7 @@ private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(n: Int, transform:
|
|||
break
|
||||
}
|
||||
|
||||
transform(item)?.let {
|
||||
out.add(it)
|
||||
}
|
||||
transform(item)?.let { out.add(it) }
|
||||
}
|
||||
|
||||
return out
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.coil
|
||||
|
||||
import coil.key.Keyer
|
||||
|
@ -5,9 +22,7 @@ import coil.request.Options
|
|||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
* A basic keyer for music data.
|
||||
*/
|
||||
/** A basic keyer for music data. */
|
||||
class MusicKeyer : Keyer<Music> {
|
||||
override fun key(data: Music, options: Options): String {
|
||||
return if (data is Song) {
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.coil
|
||||
|
||||
import android.content.Context
|
||||
|
@ -11,21 +28,21 @@ import org.oxycblt.auxio.util.getColorSafe
|
|||
import org.oxycblt.auxio.util.stateList
|
||||
|
||||
/**
|
||||
* An [AppCompatImageView] that applies the specified cornerRadius attribute if the user
|
||||
* has enabled the "Round album covers" option. We don't round album covers by default as
|
||||
* it desecrates album artwork, but if the user desires it we do have an option to enable it.
|
||||
* An [AppCompatImageView] that applies the specified cornerRadius attribute if the user has enabled
|
||||
* the "Round album covers" option. We don't round album covers by default as it desecrates album
|
||||
* artwork, but if the user desires it we do have an option to enable it.
|
||||
*/
|
||||
class RoundableImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
class RoundableImageView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
init {
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.RoundableImageView)
|
||||
val cornerRadius = styledAttrs.getDimension(R.styleable.RoundableImageView_cornerRadius, 0f)
|
||||
styledAttrs.recycle()
|
||||
|
||||
background = MaterialShapeDrawable().apply {
|
||||
background =
|
||||
MaterialShapeDrawable().apply {
|
||||
setCornerSize(cornerRadius)
|
||||
fillColor = context.getColorSafe(android.R.color.transparent).stateList
|
||||
}
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
@ -7,8 +24,8 @@ import coil.transform.Transformation
|
|||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* A transformation that performs a center crop-style transformation on an image, however unlike
|
||||
* the actual ScaleType, this isn't affected by any hacks we do with ImageView itself.
|
||||
* A transformation that performs a center crop-style transformation on an image, however unlike the
|
||||
* actual ScaleType, this isn't affected by any hacks we do with ImageView itself.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class SquareFrameTransform : Transformation {
|
||||
|
@ -29,12 +46,7 @@ class SquareFrameTransform : Transformation {
|
|||
|
||||
if (dstSize != wantedWidth || dstSize != wantedHeight) {
|
||||
// Desired size differs from the cropped size, resize the bitmap.
|
||||
return Bitmap.createScaledBitmap(
|
||||
dst,
|
||||
wantedWidth,
|
||||
wantedHeight,
|
||||
true
|
||||
)
|
||||
return Bitmap.createScaledBitmap(dst, wantedWidth, wantedHeight, true)
|
||||
}
|
||||
|
||||
return dst
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AlbumDetailFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -58,11 +57,12 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
detailModel.setAlbum(args.albumId)
|
||||
|
||||
val binding = FragmentDetailBinding.inflate(layoutInflater)
|
||||
val detailAdapter = AlbumDetailAdapter(
|
||||
playbackModel, detailModel,
|
||||
val detailAdapter =
|
||||
AlbumDetailAdapter(
|
||||
playbackModel,
|
||||
detailModel,
|
||||
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) },
|
||||
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ALBUM) }
|
||||
)
|
||||
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ALBUM) })
|
||||
|
||||
// --- UI SETUP ---
|
||||
|
||||
|
@ -75,13 +75,11 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
requireContext().showToast(R.string.lbl_queue_added)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(detailModel.curAlbum.value!!)
|
||||
requireContext().showToast(R.string.lbl_queue_added)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
@ -95,15 +93,11 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
|
||||
// -- DETAILVIEWMODEL SETUP ---
|
||||
|
||||
detailModel.albumData.observe(viewLifecycleOwner) { data ->
|
||||
detailAdapter.submitList(data)
|
||||
}
|
||||
detailModel.albumData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) }
|
||||
|
||||
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
|
||||
if (config != null) {
|
||||
showMenu(config) { id ->
|
||||
id == R.id.option_sort_asc
|
||||
}
|
||||
showMenu(config) { id -> id == R.id.option_sort_asc }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,9 +112,8 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
detailModel.finishNavToItem()
|
||||
} else {
|
||||
logD("Navigating to another album")
|
||||
findNavController().navigate(
|
||||
AlbumDetailFragmentDirections.actionShowAlbum(item.album.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -133,20 +126,17 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
detailModel.finishNavToItem()
|
||||
} else {
|
||||
logD("Navigating to another album")
|
||||
findNavController().navigate(
|
||||
AlbumDetailFragmentDirections.actionShowAlbum(item.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
// Always launch a new ArtistDetailFragment.
|
||||
is Artist -> {
|
||||
logD("Navigating to another artist")
|
||||
findNavController().navigate(
|
||||
AlbumDetailFragmentDirections.actionShowArtist(item.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id))
|
||||
}
|
||||
|
||||
null -> {}
|
||||
else -> logW("Unsupported navigation item ${item::class.java}")
|
||||
}
|
||||
|
@ -158,8 +148,7 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
updateQueueActions(song, binding)
|
||||
|
||||
if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM &&
|
||||
playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id
|
||||
) {
|
||||
playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id) {
|
||||
detailAdapter.highlightSong(song, binding.detailRecycler)
|
||||
} else {
|
||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
||||
|
@ -172,9 +161,7 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
return binding.root
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the queue actions when
|
||||
*/
|
||||
/** Updates the queue actions when */
|
||||
private fun updateQueueActions(song: Song?, binding: FragmentDetailBinding) {
|
||||
for (item in binding.detailToolbar.menu.children) {
|
||||
if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) {
|
||||
|
@ -183,9 +170,7 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to an song using its [id].
|
||||
*/
|
||||
/** Scroll to an song using its [id]. */
|
||||
private fun scrollToItem(
|
||||
id: Long,
|
||||
binding: FragmentDetailBinding,
|
||||
|
@ -198,8 +183,7 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
binding.detailRecycler.post {
|
||||
// Make sure to increment the position to make up for the detail header
|
||||
binding.detailRecycler.layoutManager?.startSmoothScroll(
|
||||
CenterSmoothScroller(requireContext(), pos)
|
||||
)
|
||||
CenterSmoothScroller(requireContext(), pos))
|
||||
|
||||
// If the recyclerview can scroll, its certain that it will have to scroll to
|
||||
// correctly center the playing item, so make sure that the Toolbar is lifted in
|
||||
|
@ -210,13 +194,11 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
}
|
||||
|
||||
/**
|
||||
* [LinearSmoothScroller] subclass that centers the item on the screen instead of
|
||||
* snapping to the top or bottom.
|
||||
* [LinearSmoothScroller] subclass that centers the item on the screen instead of snapping to
|
||||
* the top or bottom.
|
||||
*/
|
||||
private class CenterSmoothScroller(
|
||||
context: Context,
|
||||
target: Int
|
||||
) : LinearSmoothScroller(context) {
|
||||
private class CenterSmoothScroller(context: Context, target: Int) :
|
||||
LinearSmoothScroller(context) {
|
||||
init {
|
||||
targetPosition = target
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* ArtistDetailFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -53,24 +52,19 @@ class ArtistDetailFragment : DetailFragment() {
|
|||
detailModel.setArtist(args.artistId)
|
||||
|
||||
val binding = FragmentDetailBinding.inflate(layoutInflater)
|
||||
val detailAdapter = ArtistDetailAdapter(
|
||||
val detailAdapter =
|
||||
ArtistDetailAdapter(
|
||||
playbackModel,
|
||||
doOnClick = { data ->
|
||||
if (!detailModel.isNavigating) {
|
||||
detailModel.setNavigating(true)
|
||||
|
||||
findNavController().navigate(
|
||||
ArtistDetailFragmentDirections.actionShowAlbum(data.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id))
|
||||
}
|
||||
},
|
||||
doOnSongClick = { data ->
|
||||
playbackModel.playSong(data, PlaybackMode.IN_ARTIST)
|
||||
},
|
||||
doOnLongClick = { view, data ->
|
||||
newMenu(view, data, ActionMenu.FLAG_IN_ARTIST)
|
||||
}
|
||||
)
|
||||
doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) },
|
||||
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) })
|
||||
|
||||
// --- UI SETUP ---
|
||||
|
||||
|
@ -91,9 +85,7 @@ class ArtistDetailFragment : DetailFragment() {
|
|||
|
||||
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
|
||||
if (config != null) {
|
||||
showMenu(config) { id ->
|
||||
id != R.id.option_sort_artist
|
||||
}
|
||||
showMenu(config) { id -> id != R.id.option_sort_artist }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -106,26 +98,20 @@ class ArtistDetailFragment : DetailFragment() {
|
|||
detailModel.finishNavToItem()
|
||||
} else {
|
||||
logD("Navigating to another artist")
|
||||
findNavController().navigate(
|
||||
ArtistDetailFragmentDirections.actionShowArtist(item.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
is Album -> {
|
||||
logD("Navigating to another album")
|
||||
findNavController().navigate(
|
||||
ArtistDetailFragmentDirections.actionShowAlbum(item.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
}
|
||||
|
||||
is Song -> {
|
||||
logD("Navigating to another album")
|
||||
findNavController().navigate(
|
||||
ArtistDetailFragmentDirections.actionShowAlbum(item.album.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.id))
|
||||
}
|
||||
|
||||
null -> {}
|
||||
else -> logW("Unsupported navigation item ${item::class.java}")
|
||||
}
|
||||
|
@ -143,8 +129,7 @@ class ArtistDetailFragment : DetailFragment() {
|
|||
// Highlight songs if they are being played
|
||||
playbackModel.song.observe(viewLifecycleOwner) { song ->
|
||||
if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST &&
|
||||
playbackModel.parent.value?.id == detailModel.curArtist.value?.id
|
||||
) {
|
||||
playbackModel.parent.value?.id == detailModel.curArtist.value?.id) {
|
||||
detailAdapter.highlightSong(song, binding.detailRecycler)
|
||||
} else {
|
||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
|
@ -12,24 +29,23 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import java.lang.Exception
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.EdgeAppBarLayout
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logTraceOrThrow
|
||||
import java.lang.Exception
|
||||
|
||||
/**
|
||||
* An [EdgeAppBarLayout] variant that also shows the name of the toolbar whenever the detail
|
||||
* recyclerview is scrolled beyond it's first item (a.k.a the header). This is used instead of
|
||||
* CollapsingToolbarLayout since that thing is a mess with crippling bugs and state issues.
|
||||
* This just works.
|
||||
* CollapsingToolbarLayout since that thing is a mess with crippling bugs and state issues. This
|
||||
* just works.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class DetailAppBarLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : EdgeAppBarLayout(context, attrs, defStyleAttr) {
|
||||
class DetailAppBarLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
EdgeAppBarLayout(context, attrs, defStyleAttr) {
|
||||
private var mTitleView: AppCompatTextView? = null
|
||||
private var mRecycler: RecyclerView? = null
|
||||
|
||||
|
@ -50,7 +66,8 @@ class DetailAppBarLayout @JvmOverloads constructor(
|
|||
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
|
||||
|
||||
// Reflect to get the actual title view to do transformations on
|
||||
val newTitleView = try {
|
||||
val newTitleView =
|
||||
try {
|
||||
Toolbar::class.java.getDeclaredField("mTitleTextView").run {
|
||||
isAccessible = true
|
||||
get(toolbar) as AppCompatTextView
|
||||
|
@ -103,21 +120,21 @@ class DetailAppBarLayout @JvmOverloads constructor(
|
|||
|
||||
if (titleView?.alpha == to) return
|
||||
|
||||
mTitleAnimator = ValueAnimator.ofFloat(from, to).apply {
|
||||
addUpdateListener {
|
||||
titleView?.alpha = it.animatedValue as Float
|
||||
}
|
||||
mTitleAnimator =
|
||||
ValueAnimator.ofFloat(from, to).apply {
|
||||
addUpdateListener { titleView?.alpha = it.animatedValue as Float }
|
||||
|
||||
duration = resources.getInteger(R.integer.detail_app_bar_title_anim_duration).toLong()
|
||||
duration =
|
||||
resources.getInteger(R.integer.detail_app_bar_title_anim_duration).toLong()
|
||||
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
class Behavior @JvmOverloads constructor(
|
||||
context: Context? = null,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppBarLayout.Behavior(context, attrs) {
|
||||
class Behavior
|
||||
@JvmOverloads
|
||||
constructor(context: Context? = null, attrs: AttributeSet? = null) :
|
||||
AppBarLayout.Behavior(context, attrs) {
|
||||
override fun onNestedPreScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
|
@ -132,8 +149,8 @@ class DetailAppBarLayout @JvmOverloads constructor(
|
|||
val appBar = child as DetailAppBarLayout
|
||||
val recycler = appBar.findRecyclerView()
|
||||
|
||||
val showTitle = (recycler.layoutManager as LinearLayoutManager)
|
||||
.findFirstVisibleItemPosition() > 0
|
||||
val showTitle =
|
||||
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0
|
||||
|
||||
appBar.setTitleVisibility(showTitle)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* DetailFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -70,21 +69,15 @@ abstract class DetailFragment : Fragment() {
|
|||
inflateMenu(menuId)
|
||||
}
|
||||
|
||||
setNavigationOnClickListener {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
onMenuClick?.let { onClick ->
|
||||
setOnMenuItemClickListener { item ->
|
||||
onClick(item.itemId)
|
||||
}
|
||||
setOnMenuItemClickListener { item -> onClick(item.itemId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut method for recyclerview setup
|
||||
*/
|
||||
/** Shortcut method for recyclerview setup */
|
||||
protected fun setupRecycler(
|
||||
binding: FragmentDetailBinding,
|
||||
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
|
||||
|
@ -99,10 +92,14 @@ abstract class DetailFragment : Fragment() {
|
|||
|
||||
/**
|
||||
* Shortcut method for spinning up the sorting [PopupMenu]
|
||||
* @param config The initial configuration to apply to the menu. This is provided by [DetailViewModel.showMenu].
|
||||
* @param config The initial configuration to apply to the menu. This is provided by
|
||||
* [DetailViewModel.showMenu].
|
||||
* @param showItem Which menu items to keep
|
||||
*/
|
||||
protected fun showMenu(config: DetailViewModel.MenuConfig, showItem: ((Int) -> Boolean)? = null) {
|
||||
protected fun showMenu(
|
||||
config: DetailViewModel.MenuConfig,
|
||||
showItem: ((Int) -> Boolean)? = null
|
||||
) {
|
||||
logD("Launching menu [$config]")
|
||||
|
||||
PopupMenu(config.anchor.context, config.anchor).apply {
|
||||
|
@ -120,9 +117,7 @@ abstract class DetailFragment : Fragment() {
|
|||
true
|
||||
}
|
||||
|
||||
setOnDismissListener {
|
||||
detailModel.finishShowMenu(null)
|
||||
}
|
||||
setOnDismissListener { detailModel.finishShowMenu(null) }
|
||||
|
||||
if (showItem != null) {
|
||||
for (item in menu.children) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* DetailViewModel.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -47,22 +46,26 @@ class DetailViewModel : ViewModel() {
|
|||
// --- CURRENT VALUES ---
|
||||
|
||||
private val mCurGenre = MutableLiveData<Genre?>()
|
||||
val curGenre: LiveData<Genre?> get() = mCurGenre
|
||||
val curGenre: LiveData<Genre?>
|
||||
get() = mCurGenre
|
||||
|
||||
private val mGenreData = MutableLiveData(listOf<Item>())
|
||||
val genreData: LiveData<List<Item>> = mGenreData
|
||||
|
||||
private val mCurArtist = MutableLiveData<Artist?>()
|
||||
val curArtist: LiveData<Artist?> get() = mCurArtist
|
||||
val curArtist: LiveData<Artist?>
|
||||
get() = mCurArtist
|
||||
|
||||
private val mArtistData = MutableLiveData(listOf<Item>())
|
||||
val artistData: LiveData<List<Item>> = mArtistData
|
||||
|
||||
private val mCurAlbum = MutableLiveData<Album?>()
|
||||
val curAlbum: LiveData<Album?> get() = mCurAlbum
|
||||
val curAlbum: LiveData<Album?>
|
||||
get() = mCurAlbum
|
||||
|
||||
private val mAlbumData = MutableLiveData(listOf<Item>())
|
||||
val albumData: LiveData<List<Item>> get() = mAlbumData
|
||||
val albumData: LiveData<List<Item>>
|
||||
get() = mAlbumData
|
||||
|
||||
data class MenuConfig(val anchor: View, val sortMode: Sort)
|
||||
|
||||
|
@ -72,7 +75,8 @@ class DetailViewModel : ViewModel() {
|
|||
private val mNavToItem = MutableLiveData<Item?>()
|
||||
|
||||
/** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */
|
||||
val navToItem: LiveData<Item?> get() = mNavToItem
|
||||
val navToItem: LiveData<Item?>
|
||||
get() = mNavToItem
|
||||
|
||||
var isNavigating = false
|
||||
private set
|
||||
|
@ -101,10 +105,7 @@ class DetailViewModel : ViewModel() {
|
|||
refreshAlbumData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that the menu process is done with the new [Sort].
|
||||
* Pass null if there was no change.
|
||||
*/
|
||||
/** Mark that the menu process is done with the new [Sort]. Pass null if there was no change. */
|
||||
fun finishShowMenu(newMode: Sort?) {
|
||||
mShowMenu.value = null
|
||||
|
||||
|
@ -130,23 +131,17 @@ class DetailViewModel : ViewModel() {
|
|||
currentMenuContext = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to an item, whether a song/album/artist
|
||||
*/
|
||||
/** Navigate to an item, whether a song/album/artist */
|
||||
fun navToItem(item: Item) {
|
||||
mNavToItem.value = item
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that the navigation process is done.
|
||||
*/
|
||||
/** Mark that the navigation process is done. */
|
||||
fun finishNavToItem() {
|
||||
mNavToItem.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current navigation status to [isNavigating]
|
||||
*/
|
||||
/** Update the current navigation status to [isNavigating] */
|
||||
fun setNavigating(navigating: Boolean) {
|
||||
isNavigating = navigating
|
||||
}
|
||||
|
@ -165,9 +160,7 @@ class DetailViewModel : ViewModel() {
|
|||
onClick = { view ->
|
||||
currentMenuContext = DisplayMode.SHOW_GENRES
|
||||
mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort)
|
||||
}
|
||||
)
|
||||
)
|
||||
}))
|
||||
|
||||
data.addAll(settingsManager.detailGenreSort.sortGenre(curGenre.value!!))
|
||||
|
||||
|
@ -179,12 +172,7 @@ class DetailViewModel : ViewModel() {
|
|||
val artist = requireNotNull(curArtist.value)
|
||||
val data = mutableListOf<Item>(artist)
|
||||
|
||||
data.add(
|
||||
Header(
|
||||
id = -2,
|
||||
string = R.string.lbl_albums
|
||||
)
|
||||
)
|
||||
data.add(Header(id = -2, string = R.string.lbl_albums))
|
||||
|
||||
data.addAll(Sort.ByYear(false).sortAlbums(artist.albums))
|
||||
|
||||
|
@ -197,9 +185,7 @@ class DetailViewModel : ViewModel() {
|
|||
onClick = { view ->
|
||||
currentMenuContext = DisplayMode.SHOW_ARTISTS
|
||||
mShowMenu.value = MenuConfig(view, settingsManager.detailArtistSort)
|
||||
}
|
||||
)
|
||||
)
|
||||
}))
|
||||
|
||||
data.addAll(settingsManager.detailArtistSort.sortArtist(artist))
|
||||
|
||||
|
@ -220,9 +206,7 @@ class DetailViewModel : ViewModel() {
|
|||
onClick = { view ->
|
||||
currentMenuContext = DisplayMode.SHOW_ALBUMS
|
||||
mShowMenu.value = MenuConfig(view, settingsManager.detailAlbumSort)
|
||||
}
|
||||
)
|
||||
)
|
||||
}))
|
||||
|
||||
data.addAll(settingsManager.detailAlbumSort.sortAlbum(curAlbum.value!!))
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* GenreDetailFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -53,15 +52,11 @@ class GenreDetailFragment : DetailFragment() {
|
|||
detailModel.setGenre(args.genreId)
|
||||
|
||||
val binding = FragmentDetailBinding.inflate(inflater)
|
||||
val detailAdapter = GenreDetailAdapter(
|
||||
val detailAdapter =
|
||||
GenreDetailAdapter(
|
||||
playbackModel,
|
||||
doOnClick = { song ->
|
||||
playbackModel.playSong(song, PlaybackMode.IN_GENRE)
|
||||
},
|
||||
doOnLongClick = { view, data ->
|
||||
newMenu(view, data, ActionMenu.FLAG_IN_GENRE)
|
||||
}
|
||||
)
|
||||
doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) },
|
||||
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) })
|
||||
|
||||
// --- UI SETUP ---
|
||||
|
||||
|
@ -75,34 +70,26 @@ class GenreDetailFragment : DetailFragment() {
|
|||
|
||||
// --- DETAILVIEWMODEL SETUP ---
|
||||
|
||||
detailModel.genreData.observe(viewLifecycleOwner) { data ->
|
||||
detailAdapter.submitList(data)
|
||||
}
|
||||
detailModel.genreData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) }
|
||||
|
||||
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
|
||||
when (item) {
|
||||
// All items will launch new detail fragments.
|
||||
is Artist -> {
|
||||
logD("Navigating to another artist")
|
||||
findNavController().navigate(
|
||||
GenreDetailFragmentDirections.actionShowArtist(item.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(GenreDetailFragmentDirections.actionShowArtist(item.id))
|
||||
}
|
||||
|
||||
is Album -> {
|
||||
logD("Navigating to another album")
|
||||
findNavController().navigate(
|
||||
GenreDetailFragmentDirections.actionShowAlbum(item.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
}
|
||||
|
||||
is Song -> {
|
||||
logD("Navigating to another song")
|
||||
findNavController().navigate(
|
||||
GenreDetailFragmentDirections.actionShowAlbum(item.album.id)
|
||||
)
|
||||
findNavController()
|
||||
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.id))
|
||||
}
|
||||
|
||||
null -> {}
|
||||
else -> logW("Unsupported navigation command ${item::class.java}")
|
||||
}
|
||||
|
@ -112,8 +99,7 @@ class GenreDetailFragment : DetailFragment() {
|
|||
|
||||
playbackModel.song.observe(viewLifecycleOwner) { song ->
|
||||
if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE &&
|
||||
playbackModel.parent.value?.id == detailModel.curGenre.value!!.id
|
||||
) {
|
||||
playbackModel.parent.value?.id == detailModel.curGenre.value!!.id) {
|
||||
detailAdapter.highlightSong(song, binding.detailRecycler)
|
||||
} else {
|
||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AlbumDetailAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -63,16 +62,11 @@ class AlbumDetailAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
ALBUM_DETAIL_ITEM_TYPE -> AlbumDetailViewHolder(
|
||||
ItemDetailBinding.inflate(parent.context.inflater)
|
||||
)
|
||||
|
||||
ALBUM_SONG_ITEM_TYPE -> AlbumSongViewHolder(
|
||||
ItemAlbumSongBinding.inflate(parent.context.inflater)
|
||||
)
|
||||
|
||||
ALBUM_DETAIL_ITEM_TYPE ->
|
||||
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
ALBUM_SONG_ITEM_TYPE ->
|
||||
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
|
||||
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
|
||||
|
||||
else -> error("Invalid ViewHolder item type $viewType")
|
||||
}
|
||||
}
|
||||
|
@ -84,8 +78,7 @@ class AlbumDetailAdapter(
|
|||
is Album -> (holder as AlbumDetailViewHolder).bind(item)
|
||||
is Song -> (holder as AlbumSongViewHolder).bind(item)
|
||||
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
|
||||
else -> {
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (holder is Highlightable) {
|
||||
|
@ -114,9 +107,7 @@ class AlbumDetailAdapter(
|
|||
|
||||
if (song != null) {
|
||||
// Use existing data instead of having to re-sort it.
|
||||
val pos = currentList.indexOfFirst { item ->
|
||||
item.id == song.id && item is Song
|
||||
}
|
||||
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
|
||||
|
||||
// Check if the ViewHolder for this song is visible, if it is then highlight it.
|
||||
// If the ViewHolder is not visible, then the adapter should take care of it if
|
||||
|
@ -130,9 +121,8 @@ class AlbumDetailAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
inner class AlbumDetailViewHolder(
|
||||
private val binding: ItemDetailBinding
|
||||
) : BaseViewHolder<Album>(binding) {
|
||||
inner class AlbumDetailViewHolder(private val binding: ItemDetailBinding) :
|
||||
BaseViewHolder<Album>(binding) {
|
||||
|
||||
override fun onBind(data: Album) {
|
||||
binding.detailCover.apply {
|
||||
|
@ -144,27 +134,21 @@ class AlbumDetailAdapter(
|
|||
|
||||
binding.detailSubhead.apply {
|
||||
text = data.artist.resolvedName
|
||||
setOnClickListener {
|
||||
detailModel.navToItem(data.artist)
|
||||
}
|
||||
setOnClickListener { detailModel.navToItem(data.artist) }
|
||||
}
|
||||
|
||||
binding.detailInfo.apply {
|
||||
text = context.getString(
|
||||
text =
|
||||
context.getString(
|
||||
R.string.fmt_three,
|
||||
data.year?.toString() ?: context.getString(R.string.def_date),
|
||||
context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size),
|
||||
data.totalDuration
|
||||
)
|
||||
data.totalDuration)
|
||||
}
|
||||
|
||||
binding.detailPlayButton.setOnClickListener {
|
||||
playbackModel.playAlbum(data, false)
|
||||
}
|
||||
binding.detailPlayButton.setOnClickListener { playbackModel.playAlbum(data, false) }
|
||||
|
||||
binding.detailShuffleButton.setOnClickListener {
|
||||
playbackModel.playAlbum(data, true)
|
||||
}
|
||||
binding.detailShuffleButton.setOnClickListener { playbackModel.playAlbum(data, true) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* ArtistDetailAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -70,22 +69,14 @@ class ArtistDetailAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
ARTIST_DETAIL_ITEM_TYPE -> ArtistDetailViewHolder(
|
||||
ItemDetailBinding.inflate(parent.context.inflater)
|
||||
)
|
||||
|
||||
ARTIST_ALBUM_ITEM_TYPE -> ArtistAlbumViewHolder(
|
||||
ItemArtistAlbumBinding.inflate(parent.context.inflater)
|
||||
)
|
||||
|
||||
ARTIST_SONG_ITEM_TYPE -> ArtistSongViewHolder(
|
||||
ItemArtistSongBinding.inflate(parent.context.inflater)
|
||||
)
|
||||
|
||||
ARTIST_DETAIL_ITEM_TYPE ->
|
||||
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
ARTIST_ALBUM_ITEM_TYPE ->
|
||||
ArtistAlbumViewHolder(ItemArtistAlbumBinding.inflate(parent.context.inflater))
|
||||
ARTIST_SONG_ITEM_TYPE ->
|
||||
ArtistSongViewHolder(ItemArtistSongBinding.inflate(parent.context.inflater))
|
||||
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
|
||||
|
||||
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
|
||||
|
||||
else -> error("Invalid ViewHolder item type $viewType")
|
||||
}
|
||||
}
|
||||
|
@ -99,8 +90,7 @@ class ArtistDetailAdapter(
|
|||
is Song -> (holder as ArtistSongViewHolder).bind(item)
|
||||
is Header -> (holder as HeaderViewHolder).bind(item)
|
||||
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
|
||||
else -> {
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (holder is Highlightable) {
|
||||
|
@ -133,9 +123,7 @@ class ArtistDetailAdapter(
|
|||
|
||||
if (album != null) {
|
||||
// Use existing data instead of having to re-sort it.
|
||||
val pos = currentList.indexOfFirst { item ->
|
||||
item.id == album.id && item is Album
|
||||
}
|
||||
val pos = currentList.indexOfFirst { item -> item.id == album.id && item is Album }
|
||||
|
||||
// Check if the ViewHolder if this album is visible, and highlight it if so.
|
||||
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
|
||||
|
@ -163,9 +151,7 @@ class ArtistDetailAdapter(
|
|||
if (song != null) {
|
||||
// Use existing data instead of having to re-sort it.
|
||||
// We also have to account for the album count when searching for the ViewHolder.
|
||||
val pos = currentList.indexOfFirst { item ->
|
||||
item.id == song.id && item is Song
|
||||
}
|
||||
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
|
||||
|
||||
// Check if the ViewHolder for this song is visible, if it is then highlight it.
|
||||
// If the ViewHolder is not visible, then the adapter should take care of it if
|
||||
|
@ -179,39 +165,35 @@ class ArtistDetailAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
inner class ArtistDetailViewHolder(
|
||||
private val binding: ItemDetailBinding
|
||||
) : BaseViewHolder<Artist>(binding) {
|
||||
inner class ArtistDetailViewHolder(private val binding: ItemDetailBinding) :
|
||||
BaseViewHolder<Artist>(binding) {
|
||||
|
||||
override fun onBind(data: Artist) {
|
||||
val context = binding.root.context
|
||||
|
||||
binding.detailCover.apply {
|
||||
bindArtistImage(data)
|
||||
contentDescription = context.getString(
|
||||
R.string.desc_artist_image,
|
||||
data.resolvedName
|
||||
)
|
||||
contentDescription =
|
||||
context.getString(R.string.desc_artist_image, data.resolvedName)
|
||||
}
|
||||
|
||||
binding.detailName.text = data.resolvedName
|
||||
|
||||
// Get the genre that corresponds to the most songs in this artist, which would be
|
||||
// the most "Prominent" genre.
|
||||
binding.detailSubhead.text = data.songs
|
||||
binding.detailSubhead.text =
|
||||
data.songs
|
||||
.groupBy { it.genre.resolvedName }
|
||||
.entries.maxByOrNull { it.value.size }
|
||||
?.key ?: context.getString(R.string.def_genre)
|
||||
.entries
|
||||
.maxByOrNull { it.value.size }
|
||||
?.key
|
||||
?: context.getString(R.string.def_genre)
|
||||
|
||||
binding.detailInfo.bindArtistInfo(data)
|
||||
|
||||
binding.detailPlayButton.setOnClickListener {
|
||||
playbackModel.playArtist(data, false)
|
||||
}
|
||||
binding.detailPlayButton.setOnClickListener { playbackModel.playArtist(data, false) }
|
||||
|
||||
binding.detailShuffleButton.setOnClickListener {
|
||||
playbackModel.playArtist(data, true)
|
||||
}
|
||||
binding.detailShuffleButton.setOnClickListener { playbackModel.playArtist(data, true) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* GenreDetailAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -60,16 +59,13 @@ class GenreDetailAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
GENRE_DETAIL_ITEM_TYPE -> GenreDetailViewHolder(
|
||||
ItemDetailBinding.inflate(parent.context.inflater)
|
||||
)
|
||||
|
||||
GENRE_SONG_ITEM_TYPE -> GenreSongViewHolder(
|
||||
GENRE_DETAIL_ITEM_TYPE ->
|
||||
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
GENRE_SONG_ITEM_TYPE ->
|
||||
GenreSongViewHolder(
|
||||
ItemGenreSongBinding.inflate(parent.context.inflater),
|
||||
)
|
||||
|
||||
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
|
||||
|
||||
else -> error("Bad ViewHolder item type $viewType")
|
||||
}
|
||||
}
|
||||
|
@ -110,9 +106,7 @@ class GenreDetailAdapter(
|
|||
|
||||
if (song != null) {
|
||||
// Use existing data instead of having to re-sort it.
|
||||
val pos = currentList.indexOfFirst { item ->
|
||||
item.id == song.id && item is Song
|
||||
}
|
||||
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
|
||||
|
||||
// Check if the ViewHolder for this song is visible, if it is then highlight it.
|
||||
// If the ViewHolder is not visible, then the adapter should take care of it if
|
||||
|
@ -126,31 +120,23 @@ class GenreDetailAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
inner class GenreDetailViewHolder(
|
||||
private val binding: ItemDetailBinding
|
||||
) : BaseViewHolder<Genre>(binding) {
|
||||
inner class GenreDetailViewHolder(private val binding: ItemDetailBinding) :
|
||||
BaseViewHolder<Genre>(binding) {
|
||||
override fun onBind(data: Genre) {
|
||||
val context = binding.root.context
|
||||
|
||||
binding.detailCover.apply {
|
||||
bindGenreImage(data)
|
||||
contentDescription = context.getString(
|
||||
R.string.desc_genre_image,
|
||||
data.resolvedName
|
||||
)
|
||||
contentDescription = context.getString(R.string.desc_genre_image, data.resolvedName)
|
||||
}
|
||||
|
||||
binding.detailName.text = data.resolvedName
|
||||
binding.detailSubhead.bindGenreInfo(data)
|
||||
binding.detailInfo.text = data.totalDuration
|
||||
|
||||
binding.detailPlayButton.setOnClickListener {
|
||||
playbackModel.playGenre(data, false)
|
||||
}
|
||||
binding.detailPlayButton.setOnClickListener { playbackModel.playGenre(data, false) }
|
||||
|
||||
binding.detailShuffleButton.setOnClickListener {
|
||||
playbackModel.playGenre(data, true)
|
||||
}
|
||||
binding.detailShuffleButton.setOnClickListener { playbackModel.playGenre(data, true) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Highlightable.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -18,9 +17,7 @@
|
|||
|
||||
package org.oxycblt.auxio.detail.recycler
|
||||
|
||||
/**
|
||||
* Interface that allows the highlighting of certain ViewHolders
|
||||
*/
|
||||
/** Interface that allows the highlighting of certain ViewHolders */
|
||||
interface Highlightable {
|
||||
fun setHighlighted(isHighlighted: Boolean)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home
|
||||
|
||||
import android.content.Context
|
||||
|
@ -11,10 +28,8 @@ import org.oxycblt.auxio.util.logD
|
|||
* - On medium screens, use only text
|
||||
* - On large screens, use text and an icon
|
||||
*/
|
||||
class AdaptiveTabStrategy(
|
||||
context: Context,
|
||||
private val homeModel: HomeViewModel
|
||||
) : TabLayoutMediator.TabConfigurationStrategy {
|
||||
class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel) :
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
|
@ -23,19 +38,15 @@ class AdaptiveTabStrategy(
|
|||
when {
|
||||
width < 370 -> {
|
||||
logD("Using icon-only configuration")
|
||||
tab.setIcon(tabMode.icon)
|
||||
.setContentDescription(tabMode.string)
|
||||
tab.setIcon(tabMode.icon).setContentDescription(tabMode.string)
|
||||
}
|
||||
|
||||
width < 640 -> {
|
||||
logD("Using text-only configuration")
|
||||
tab.setText(tabMode.string)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logD("Using icon-and-text configuration")
|
||||
tab.setIcon(tabMode.icon)
|
||||
.setText(tabMode.string)
|
||||
tab.setIcon(tabMode.icon).setText(tabMode.string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* EdgeFloatingActionButton.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -30,11 +29,10 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
* A container for a FloatingActionButton that enables edge-to-edge support.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class EdgeFabContainer @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
class EdgeFabContainer
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
FrameLayout(context, attrs, defStyleAttr) {
|
||||
init {
|
||||
clipToPadding = false
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MainFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -52,11 +51,10 @@ import org.oxycblt.auxio.util.logE
|
|||
import org.oxycblt.auxio.util.logTraceOrThrow
|
||||
|
||||
/**
|
||||
* The main "Launching Point" fragment of Auxio, allowing navigation to the detail
|
||||
* views for each respective item.
|
||||
* @author OxygenCobalt
|
||||
* TODO: Make tabs invisible when there is only one
|
||||
* TODO: Add duration and song count sorts
|
||||
* The main "Launching Point" fragment of Auxio, allowing navigation to the detail views for each
|
||||
* respective item.
|
||||
* @author OxygenCobalt TODO: Make tabs invisible when there is only one TODO: Add duration and song
|
||||
* count sorts
|
||||
*/
|
||||
class HomeFragment : Fragment() {
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
@ -83,26 +81,26 @@ class HomeFragment : Fragment() {
|
|||
logD("Navigating to search")
|
||||
findNavController().navigate(HomeFragmentDirections.actionShowSearch())
|
||||
}
|
||||
|
||||
R.id.action_settings -> {
|
||||
logD("Navigating to settings")
|
||||
parentFragment?.parentFragment?.findNavController()?.navigate(
|
||||
MainFragmentDirections.actionShowSettings()
|
||||
)
|
||||
parentFragment
|
||||
?.parentFragment
|
||||
?.findNavController()
|
||||
?.navigate(MainFragmentDirections.actionShowSettings())
|
||||
}
|
||||
|
||||
R.id.action_about -> {
|
||||
logD("Navigating to about")
|
||||
parentFragment?.parentFragment?.findNavController()?.navigate(
|
||||
MainFragmentDirections.actionShowAbout()
|
||||
)
|
||||
parentFragment
|
||||
?.parentFragment
|
||||
?.findNavController()
|
||||
?.navigate(MainFragmentDirections.actionShowAbout())
|
||||
}
|
||||
|
||||
R.id.submenu_sorting -> { }
|
||||
|
||||
R.id.submenu_sorting -> {}
|
||||
R.id.option_sort_asc -> {
|
||||
item.isChecked = !item.isChecked
|
||||
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!)
|
||||
val new =
|
||||
homeModel
|
||||
.getSortForDisplay(homeModel.curTab.value!!)
|
||||
.ascending(item.isChecked)
|
||||
homeModel.updateCurrentSort(new)
|
||||
}
|
||||
|
@ -110,7 +108,9 @@ class HomeFragment : Fragment() {
|
|||
// Sorting option was selected, mark it as selected and update the mode
|
||||
else -> {
|
||||
item.isChecked = true
|
||||
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!)
|
||||
val new =
|
||||
homeModel
|
||||
.getSortForDisplay(homeModel.curTab.value!!)
|
||||
.assignId(item.itemId)
|
||||
homeModel.updateCurrentSort(requireNotNull(new))
|
||||
}
|
||||
|
@ -129,9 +129,11 @@ class HomeFragment : Fragment() {
|
|||
// scroll events being registered as horizontal scroll events. Reflect into the
|
||||
// internal recyclerview and change the touch slope so that touch actions will
|
||||
// act more as a scroll than as a swipe.
|
||||
// Derived from: https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
|
||||
// Derived from:
|
||||
// https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
|
||||
try {
|
||||
val recycler = ViewPager2::class.java.getDeclaredField("mRecyclerView").run {
|
||||
val recycler =
|
||||
ViewPager2::class.java.getDeclaredField("mRecyclerView").run {
|
||||
isAccessible = true
|
||||
get(binding.homePager)
|
||||
}
|
||||
|
@ -152,18 +154,17 @@ class HomeFragment : Fragment() {
|
|||
// page transitions.
|
||||
offscreenPageLimit = homeModel.tabs.size
|
||||
|
||||
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) = homeModel.updateCurrentTab(position)
|
||||
registerOnPageChangeCallback(
|
||||
object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) =
|
||||
homeModel.updateCurrentTab(position)
|
||||
})
|
||||
|
||||
TabLayoutMediator(
|
||||
binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel)
|
||||
).attach()
|
||||
TabLayoutMediator(binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel))
|
||||
.attach()
|
||||
}
|
||||
|
||||
binding.homeFab.setOnClickListener {
|
||||
playbackModel.shuffleAll()
|
||||
}
|
||||
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
|
@ -213,18 +214,12 @@ class HomeFragment : Fragment() {
|
|||
// the tab changes.
|
||||
when (tab) {
|
||||
DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab)
|
||||
|
||||
DisplayMode.SHOW_ALBUMS -> updateSortMenu(sortItem, tab) { id ->
|
||||
id != R.id.option_sort_album
|
||||
}
|
||||
|
||||
DisplayMode.SHOW_ARTISTS -> updateSortMenu(sortItem, tab) { id ->
|
||||
id == R.id.option_sort_asc
|
||||
}
|
||||
|
||||
DisplayMode.SHOW_GENRES -> updateSortMenu(sortItem, tab) { id ->
|
||||
id == R.id.option_sort_asc
|
||||
}
|
||||
DisplayMode.SHOW_ALBUMS ->
|
||||
updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_album }
|
||||
DisplayMode.SHOW_ARTISTS ->
|
||||
updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc }
|
||||
DisplayMode.SHOW_GENRES ->
|
||||
updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc }
|
||||
}
|
||||
|
||||
binding.homeAppbar.liftOnScrollTargetViewId = tab.viewId
|
||||
|
@ -236,24 +231,19 @@ class HomeFragment : Fragment() {
|
|||
// This is only here just in case a collapsing toolbar is re-added.
|
||||
binding.homeAppbar.post {
|
||||
when (item) {
|
||||
is Song -> findNavController().navigate(
|
||||
HomeFragmentDirections.actionShowAlbum(item.album.id)
|
||||
)
|
||||
|
||||
is Album -> findNavController().navigate(
|
||||
HomeFragmentDirections.actionShowAlbum(item.id)
|
||||
)
|
||||
|
||||
is Artist -> findNavController().navigate(
|
||||
HomeFragmentDirections.actionShowArtist(item.id)
|
||||
)
|
||||
|
||||
is Genre -> findNavController().navigate(
|
||||
HomeFragmentDirections.actionShowGenre(item.id)
|
||||
)
|
||||
|
||||
else -> {
|
||||
}
|
||||
is Song ->
|
||||
findNavController()
|
||||
.navigate(HomeFragmentDirections.actionShowAlbum(item.album.id))
|
||||
is Album ->
|
||||
findNavController()
|
||||
.navigate(HomeFragmentDirections.actionShowAlbum(item.id))
|
||||
is Artist ->
|
||||
findNavController()
|
||||
.navigate(HomeFragmentDirections.actionShowArtist(item.id))
|
||||
is Genre ->
|
||||
findNavController()
|
||||
.navigate(HomeFragmentDirections.actionShowGenre(item.id))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -283,7 +273,9 @@ class HomeFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private val DisplayMode.viewId: Int get() = when (this) {
|
||||
private val DisplayMode.viewId: Int
|
||||
get() =
|
||||
when (this) {
|
||||
DisplayMode.SHOW_SONGS -> R.id.home_song_list
|
||||
DisplayMode.SHOW_ALBUMS -> R.id.home_album_list
|
||||
DisplayMode.SHOW_ARTISTS -> R.id.home_artist_list
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* HomeViewModel.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -42,31 +41,34 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
|
|||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
private val mSongs = MutableLiveData(listOf<Song>())
|
||||
val songs: LiveData<List<Song>> get() = mSongs
|
||||
val songs: LiveData<List<Song>>
|
||||
get() = mSongs
|
||||
|
||||
private val mAlbums = MutableLiveData(listOf<Album>())
|
||||
val albums: LiveData<List<Album>> get() = mAlbums
|
||||
val albums: LiveData<List<Album>>
|
||||
get() = mAlbums
|
||||
|
||||
private val mArtists = MutableLiveData(listOf<Artist>())
|
||||
val artists: LiveData<List<Artist>> get() = mArtists
|
||||
val artists: LiveData<List<Artist>>
|
||||
get() = mArtists
|
||||
|
||||
private val mGenres = MutableLiveData(listOf<Genre>())
|
||||
val genres: LiveData<List<Genre>> get() = mGenres
|
||||
val genres: LiveData<List<Genre>>
|
||||
get() = mGenres
|
||||
|
||||
var tabs: List<DisplayMode> = visibleTabs
|
||||
private set
|
||||
|
||||
/** Internal getter for getting the visible library tabs */
|
||||
private val visibleTabs: List<DisplayMode> get() = settingsManager.libTabs
|
||||
.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
private val visibleTabs: List<DisplayMode>
|
||||
get() = settingsManager.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
|
||||
private val mCurTab = MutableLiveData(tabs[0])
|
||||
val curTab: LiveData<DisplayMode> = mCurTab
|
||||
|
||||
/**
|
||||
* Marker to recreate all library tabs, usually initiated by a settings change.
|
||||
* When this flag is set, all tabs (and their respective viewpager fragments) will be
|
||||
* recreated from scratch.
|
||||
* Marker to recreate all library tabs, usually initiated by a settings change. When this flag
|
||||
* is set, all tabs (and their respective viewpager fragments) will be recreated from scratch.
|
||||
*/
|
||||
private val mRecreateTabs = MutableLiveData(false)
|
||||
val recreateTabs: LiveData<Boolean> = mRecreateTabs
|
||||
|
@ -86,9 +88,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current tab based off of the new ViewPager position.
|
||||
*/
|
||||
/** Update the current tab based off of the new ViewPager position. */
|
||||
fun updateCurrentTab(pos: Int) {
|
||||
logD("Updating current tab to ${tabs[pos]}")
|
||||
mCurTab.value = tabs[pos]
|
||||
|
@ -107,9 +107,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the currently displayed item's [Sort].
|
||||
*/
|
||||
/** Update the currently displayed item's [Sort]. */
|
||||
fun updateCurrentSort(sort: Sort) {
|
||||
logD("Updating ${mCurTab.value} sort to $sort")
|
||||
when (mCurTab.value) {
|
||||
|
@ -117,29 +115,25 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
|
|||
settingsManager.libSongSort = sort
|
||||
mSongs.value = sort.sortSongs(mSongs.value!!)
|
||||
}
|
||||
|
||||
DisplayMode.SHOW_ALBUMS -> {
|
||||
settingsManager.libAlbumSort = sort
|
||||
mAlbums.value = sort.sortAlbums(mAlbums.value!!)
|
||||
}
|
||||
|
||||
DisplayMode.SHOW_ARTISTS -> {
|
||||
settingsManager.libArtistSort = sort
|
||||
mArtists.value = sort.sortParents(mArtists.value!!)
|
||||
}
|
||||
|
||||
DisplayMode.SHOW_GENRES -> {
|
||||
settingsManager.libGenreSort = sort
|
||||
mGenres.value = sort.sortParents(mGenres.value!!)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the fast scroll state. This is used to control the FAB visibility whenever
|
||||
* the user begins to fast scroll.
|
||||
* Update the fast scroll state. This is used to control the FAB visibility whenever the user
|
||||
* begins to fast scroll.
|
||||
*/
|
||||
fun updateFastScrolling(scrolling: Boolean) {
|
||||
mFastScrolling.value = scrolling
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Md2PopupBackground.java is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -15,6 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home.fastscroll
|
||||
|
||||
import android.content.Context
|
||||
|
@ -30,19 +30,18 @@ import android.graphics.drawable.Drawable
|
|||
import android.os.Build
|
||||
import android.view.View
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import kotlin.math.sqrt
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getAttrColorSafe
|
||||
import org.oxycblt.auxio.util.getDimenOffsetSafe
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* The custom drawable used as FastScrollRecyclerView's popup background.
|
||||
* This is an adaptation from AndroidFastScroll's MD2 theme.
|
||||
* The custom drawable used as FastScrollRecyclerView's popup background. This is an adaptation from
|
||||
* AndroidFastScroll's MD2 theme.
|
||||
*
|
||||
* Attributions as per the Apache 2.0 license:
|
||||
* ORIGINAL AUTHOR: Hai Zhang [https://github.com/zhanghai]
|
||||
* PROJECT: Android Fast Scroll [https://github.com/zhanghai/AndroidFastScroll]
|
||||
* MODIFIER: OxygenCobalt [https://github.com/]
|
||||
* Attributions as per the Apache 2.0 license: ORIGINAL AUTHOR: Hai Zhang
|
||||
* [https://github.com/zhanghai] PROJECT: Android Fast Scroll
|
||||
* [https://github.com/zhanghai/AndroidFastScroll] MODIFIER: OxygenCobalt [https://github.com/]
|
||||
*
|
||||
* !!! MODIFICATIONS !!!:
|
||||
* - Use modified Auxio resources instead of AFS resources
|
||||
|
@ -53,7 +52,8 @@ import kotlin.math.sqrt
|
|||
* @author Hai Zhang, OxygenCobalt
|
||||
*/
|
||||
class FastScrollPopupDrawable(context: Context) : Drawable() {
|
||||
private val paint: Paint = Paint().apply {
|
||||
private val paint: Paint =
|
||||
Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = context.getAttrColorSafe(R.attr.colorSecondary)
|
||||
style = Paint.Style.FILL
|
||||
|
@ -86,10 +86,11 @@ class FastScrollPopupDrawable(context: Context) : Drawable() {
|
|||
// Paths don't need to be convex on android Q, but the API was mislabeled and so
|
||||
// we still have to use this method.
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
|
||||
|
||||
else -> if (!path.isConvex) {
|
||||
else ->
|
||||
if (!path.isConvex) {
|
||||
// The outline path must be convex before Q, but we may run into floating point
|
||||
// errors caused by calculations involving sqrt(2) or OEM implementation differences,
|
||||
// errors caused by calculations involving sqrt(2) or OEM implementation
|
||||
// differences,
|
||||
// so in this case we just omit the shadow instead of crashing.
|
||||
super.getOutline(outline)
|
||||
}
|
||||
|
@ -153,11 +154,15 @@ class FastScrollPopupDrawable(context: Context) : Drawable() {
|
|||
sweepAngle: Float
|
||||
) {
|
||||
path.arcTo(
|
||||
centerX - radius, centerY - radius, centerX + radius, centerY + radius,
|
||||
startAngle, sweepAngle, false
|
||||
)
|
||||
centerX - radius,
|
||||
centerY - radius,
|
||||
centerX + radius,
|
||||
centerY + radius,
|
||||
startAngle,
|
||||
sweepAngle,
|
||||
false)
|
||||
}
|
||||
|
||||
private val isRtl: Boolean get() =
|
||||
DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
|
||||
private val isRtl: Boolean
|
||||
get() = DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* FastScrollRecyclerView.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -41,6 +40,7 @@ import androidx.core.widget.TextViewCompat
|
|||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.canScroll
|
||||
import org.oxycblt.auxio.util.getAttrColorSafe
|
||||
|
@ -48,20 +48,18 @@ import org.oxycblt.auxio.util.getDimenOffsetSafe
|
|||
import org.oxycblt.auxio.util.getDimenSizeSafe
|
||||
import org.oxycblt.auxio.util.getDrawableSafe
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of
|
||||
* Hai Zhang's AndroidFastScroll but slimmed down for Auxio and with a couple of enhancements.
|
||||
*
|
||||
* Attributions as per the Apache 2.0 license:
|
||||
* ORIGINAL AUTHOR: Hai Zhang [https://github.com/zhanghai]
|
||||
* PROJECT: Android Fast Scroll [https://github.com/zhanghai/AndroidFastScroll]
|
||||
* MODIFIER: OxygenCobalt [https://github.com/]
|
||||
* Attributions as per the Apache 2.0 license: ORIGINAL AUTHOR: Hai Zhang
|
||||
* [https://github.com/zhanghai] PROJECT: Android Fast Scroll
|
||||
* [https://github.com/zhanghai/AndroidFastScroll] MODIFIER: OxygenCobalt [https://github.com/]
|
||||
*
|
||||
* !!! MODIFICATIONS !!!:
|
||||
* - Scroller will no longer show itself on startup or relayouts, which looked unpleasant
|
||||
* with multiple views
|
||||
* - Scroller will no longer show itself on startup or relayouts, which looked unpleasant with
|
||||
* multiple views
|
||||
* - DefaultAnimationHelper and RecyclerViewHelper were merged into the class
|
||||
* - FastScroller overlay was merged into RecyclerView instance
|
||||
* - Removed FastScrollerBuilder
|
||||
|
@ -75,17 +73,16 @@ import kotlin.math.abs
|
|||
*
|
||||
* @author Hai Zhang, OxygenCobalt
|
||||
*/
|
||||
class FastScrollRecyclerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : RecyclerView(context, attrs, defStyleAttr) {
|
||||
class FastScrollRecyclerView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
RecyclerView(context, attrs, defStyleAttr) {
|
||||
/** Callback to provide a string to be shown on the popup when an item is passed */
|
||||
var popupProvider: ((Int) -> String)? = null
|
||||
|
||||
/**
|
||||
* A listener for when a drag event occurs. The value will be true if a drag has begun,
|
||||
* and false if a drag ended.
|
||||
* A listener for when a drag event occurs. The value will be true if a drag has begun, and
|
||||
* false if a drag ended.
|
||||
*/
|
||||
var onDragListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
|
@ -128,16 +125,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
val thumbDrawable = context.getDrawableSafe(R.drawable.ui_scroll_thumb)
|
||||
|
||||
trackView = View(context)
|
||||
thumbView = View(context).apply {
|
||||
thumbView =
|
||||
View(context).apply {
|
||||
alpha = 0f
|
||||
background = thumbDrawable
|
||||
}
|
||||
|
||||
popupView = AppCompatTextView(context).apply {
|
||||
popupView =
|
||||
AppCompatTextView(context).apply {
|
||||
alpha = 0f
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
minimumWidth = context.getDimenSizeSafe(R.dimen.popup_min_width)
|
||||
minimumHeight = context.getDimenSizeSafe(R.dimen.size_btn_large)
|
||||
|
@ -168,23 +167,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
overlay.add(thumbView)
|
||||
overlay.add(popupView)
|
||||
|
||||
addItemDecoration(object : ItemDecoration() {
|
||||
override fun onDraw(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
state: State
|
||||
) {
|
||||
addItemDecoration(
|
||||
object : ItemDecoration() {
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: State) {
|
||||
onPreDraw()
|
||||
}
|
||||
})
|
||||
|
||||
// We use a listener instead of overriding onTouchEvent so that we don't conflict with
|
||||
// RecyclerView touch events.
|
||||
addOnItemTouchListener(object : SimpleOnItemTouchListener() {
|
||||
override fun onTouchEvent(
|
||||
recyclerView: RecyclerView,
|
||||
event: MotionEvent
|
||||
) {
|
||||
addOnItemTouchListener(
|
||||
object : SimpleOnItemTouchListener() {
|
||||
override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) {
|
||||
onItemTouch(event)
|
||||
}
|
||||
|
||||
|
@ -206,18 +200,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
thumbView.layoutDirection = layoutDirection
|
||||
popupView.layoutDirection = layoutDirection
|
||||
|
||||
val trackLeft = if (isRtl) {
|
||||
val trackLeft =
|
||||
if (isRtl) {
|
||||
scrollerPadding.left
|
||||
} else {
|
||||
width - scrollerPadding.right - thumbWidth
|
||||
}
|
||||
|
||||
trackView.layout(
|
||||
trackLeft, scrollerPadding.top, trackLeft + thumbWidth,
|
||||
height - scrollerPadding.bottom
|
||||
)
|
||||
trackLeft, scrollerPadding.top, trackLeft + thumbWidth, height - scrollerPadding.bottom)
|
||||
|
||||
val thumbLeft = if (isRtl) {
|
||||
val thumbLeft =
|
||||
if (isRtl) {
|
||||
scrollerPadding.left
|
||||
} else {
|
||||
width - scrollerPadding.right - thumbWidth
|
||||
|
@ -228,7 +222,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
||||
|
||||
val firstPos = firstAdapterPos
|
||||
val popupText = if (firstPos != NO_POSITION) {
|
||||
val popupText =
|
||||
if (firstPos != NO_POSITION) {
|
||||
popupProvider?.invoke(firstPos) ?: ""
|
||||
} else {
|
||||
""
|
||||
|
@ -242,58 +237,67 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
if (popupView.text != popupText) {
|
||||
popupView.text = popupText
|
||||
|
||||
val widthMeasureSpec = ViewGroup.getChildMeasureSpec(
|
||||
val widthMeasureSpec =
|
||||
ViewGroup.getChildMeasureSpec(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
scrollerPadding.left + scrollerPadding.right + thumbWidth +
|
||||
popupLayoutParams.leftMargin + popupLayoutParams.rightMargin,
|
||||
popupLayoutParams.width
|
||||
)
|
||||
scrollerPadding.left +
|
||||
scrollerPadding.right +
|
||||
thumbWidth +
|
||||
popupLayoutParams.leftMargin +
|
||||
popupLayoutParams.rightMargin,
|
||||
popupLayoutParams.width)
|
||||
|
||||
val heightMeasureSpec = ViewGroup.getChildMeasureSpec(
|
||||
val heightMeasureSpec =
|
||||
ViewGroup.getChildMeasureSpec(
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||
scrollerPadding.top + scrollerPadding.bottom + popupLayoutParams.topMargin +
|
||||
scrollerPadding.top +
|
||||
scrollerPadding.bottom +
|
||||
popupLayoutParams.topMargin +
|
||||
popupLayoutParams.bottomMargin,
|
||||
popupLayoutParams.height
|
||||
)
|
||||
popupLayoutParams.height)
|
||||
|
||||
popupView.measure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
|
||||
val popupWidth = popupView.measuredWidth
|
||||
val popupHeight = popupView.measuredHeight
|
||||
val popupLeft = if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
|
||||
val popupLeft =
|
||||
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
|
||||
scrollerPadding.left + thumbWidth + popupLayoutParams.leftMargin
|
||||
} else {
|
||||
width - scrollerPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
|
||||
width -
|
||||
scrollerPadding.right -
|
||||
thumbWidth -
|
||||
popupLayoutParams.rightMargin -
|
||||
popupWidth
|
||||
}
|
||||
|
||||
// We handle RTL separately, so it's okay if Gravity.RIGHT is used here
|
||||
@SuppressLint("RtlHardcoded")
|
||||
val popupAnchorY = when (popupLayoutParams.gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
|
||||
val popupAnchorY =
|
||||
when (popupLayoutParams.gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
|
||||
Gravity.CENTER_HORIZONTAL -> popupHeight / 2
|
||||
Gravity.RIGHT -> popupHeight
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val thumbAnchorY = when (popupLayoutParams.gravity and Gravity.VERTICAL_GRAVITY_MASK) {
|
||||
val thumbAnchorY =
|
||||
when (popupLayoutParams.gravity and Gravity.VERTICAL_GRAVITY_MASK) {
|
||||
Gravity.CENTER_VERTICAL -> {
|
||||
thumbView.paddingTop + (
|
||||
thumbHeight - thumbView.paddingTop - thumbView.paddingBottom
|
||||
) / 2
|
||||
thumbView.paddingTop +
|
||||
(thumbHeight - thumbView.paddingTop - thumbView.paddingBottom) / 2
|
||||
}
|
||||
Gravity.BOTTOM -> thumbHeight - thumbView.paddingBottom
|
||||
else -> thumbView.paddingTop
|
||||
}
|
||||
|
||||
val popupTop = MathUtils.clamp(
|
||||
val popupTop =
|
||||
MathUtils.clamp(
|
||||
thumbTop + thumbAnchorY - popupAnchorY,
|
||||
scrollerPadding.top + popupLayoutParams.topMargin,
|
||||
height - scrollerPadding.bottom - popupLayoutParams.bottomMargin - popupHeight
|
||||
)
|
||||
height - scrollerPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
|
||||
|
||||
popupView.layout(
|
||||
popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight
|
||||
)
|
||||
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -315,9 +319,10 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
val bars = insets.systemBarInsetsCompat
|
||||
|
||||
updatePadding(
|
||||
initialPadding.left, initialPadding.top, initialPadding.right,
|
||||
initialPadding.bottom + bars.bottom
|
||||
)
|
||||
initialPadding.left,
|
||||
initialPadding.top,
|
||||
initialPadding.right,
|
||||
initialPadding.bottom + bars.bottom)
|
||||
|
||||
scrollerPadding.bottom = bars.bottom
|
||||
|
||||
|
@ -358,24 +363,25 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
if (isInViewTouchTarget(thumbView, eventX, eventY)) {
|
||||
dragStartThumbOffset = thumbOffset
|
||||
} else {
|
||||
dragStartThumbOffset = (eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
|
||||
dragStartThumbOffset =
|
||||
(eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
|
||||
scrollToThumbOffset(dragStartThumbOffset)
|
||||
}
|
||||
|
||||
setDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (!dragging && isInViewTouchTarget(trackView, downX, downY) &&
|
||||
abs(eventY - downY) > touchSlop
|
||||
) {
|
||||
if (!dragging &&
|
||||
isInViewTouchTarget(trackView, downX, downY) &&
|
||||
abs(eventY - downY) > touchSlop) {
|
||||
if (isInViewTouchTarget(thumbView, downX, downY)) {
|
||||
dragStartY = lastY
|
||||
dragStartThumbOffset = thumbOffset
|
||||
} else {
|
||||
dragStartY = eventY
|
||||
dragStartThumbOffset = (eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
|
||||
dragStartThumbOffset =
|
||||
(eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
|
||||
scrollToThumbOffset(dragStartThumbOffset)
|
||||
}
|
||||
setDragging(true)
|
||||
|
@ -386,7 +392,6 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
scrollToThumbOffset(thumbOffset)
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> setDragging(false)
|
||||
}
|
||||
|
||||
|
@ -433,9 +438,9 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
private fun scrollToThumbOffset(thumbOffset: Int) {
|
||||
val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange)
|
||||
|
||||
val scrollOffset = (
|
||||
scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange
|
||||
).toInt() - paddingTop
|
||||
val scrollOffset =
|
||||
(scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
|
||||
paddingTop
|
||||
|
||||
scrollTo(scrollOffset)
|
||||
}
|
||||
|
@ -461,7 +466,6 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
targetPosition *= mgr.spanCount
|
||||
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
|
||||
}
|
||||
|
||||
is LinearLayoutManager -> {
|
||||
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
|
||||
}
|
||||
|
@ -538,10 +542,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
private fun animateView(view: View, alpha: Float) {
|
||||
view.animate()
|
||||
.alpha(alpha)
|
||||
.setDuration(ANIM_MILLIS)
|
||||
.start()
|
||||
view.animate().alpha(alpha).setDuration(ANIM_MILLIS).start()
|
||||
}
|
||||
|
||||
// --- LAYOUT STATE ---
|
||||
|
@ -601,7 +602,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
private val itemCount: Int
|
||||
get() = when (val mgr = layoutManager) {
|
||||
get() =
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
|
||||
is LinearLayoutManager -> mgr.itemCount
|
||||
else -> 0
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AlbumListFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -49,14 +48,12 @@ class AlbumListFragment : HomeListFragment() {
|
|||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
val adapter = AlbumAdapter(
|
||||
val adapter =
|
||||
AlbumAdapter(
|
||||
doOnClick = { album ->
|
||||
findNavController().navigate(
|
||||
HomeFragmentDirections.actionShowAlbum(album.id)
|
||||
)
|
||||
findNavController().navigate(HomeFragmentDirections.actionShowAlbum(album.id))
|
||||
},
|
||||
::newMenu
|
||||
)
|
||||
::newMenu)
|
||||
|
||||
setupRecycler(R.id.home_album_list, binding, adapter, homeModel.albums)
|
||||
|
||||
|
@ -70,16 +67,13 @@ class AlbumListFragment : HomeListFragment() {
|
|||
// Change how we display the popup depending on the mode.
|
||||
when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) {
|
||||
// By Name -> Use Name
|
||||
is Sort.ByName -> album.name.sliceArticle()
|
||||
.first().uppercase()
|
||||
is Sort.ByName -> album.name.sliceArticle().first().uppercase()
|
||||
|
||||
// By Artist -> Use Artist Name
|
||||
is Sort.ByArtist -> album.artist.resolvedName.sliceArticle()
|
||||
.first().uppercase()
|
||||
is Sort.ByArtist -> album.artist.resolvedName.sliceArticle().first().uppercase()
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.ByYear -> album.year?.toString()
|
||||
?: getString(R.string.def_date)
|
||||
is Sort.ByYear -> album.year?.toString() ?: getString(R.string.def_date)
|
||||
|
||||
// Unsupported sort, error gracefully
|
||||
else -> ""
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AlbumListFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -47,14 +46,12 @@ class ArtistListFragment : HomeListFragment() {
|
|||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
val adapter = ArtistAdapter(
|
||||
val adapter =
|
||||
ArtistAdapter(
|
||||
doOnClick = { artist ->
|
||||
findNavController().navigate(
|
||||
HomeFragmentDirections.actionShowArtist(artist.id)
|
||||
)
|
||||
findNavController().navigate(HomeFragmentDirections.actionShowArtist(artist.id))
|
||||
},
|
||||
::newMenu
|
||||
)
|
||||
::newMenu)
|
||||
|
||||
setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists)
|
||||
|
||||
|
@ -63,8 +60,7 @@ class ArtistListFragment : HomeListFragment() {
|
|||
|
||||
override val listPopupProvider: (Int) -> String
|
||||
get() = { idx ->
|
||||
homeModel.artists.value!![idx].resolvedName
|
||||
.sliceArticle().first().uppercase()
|
||||
homeModel.artists.value!![idx].resolvedName.sliceArticle().first().uppercase()
|
||||
}
|
||||
|
||||
class ArtistAdapter(
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AlbumListFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -47,14 +46,12 @@ class GenreListFragment : HomeListFragment() {
|
|||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
val adapter = GenreAdapter(
|
||||
val adapter =
|
||||
GenreAdapter(
|
||||
doOnClick = { Genre ->
|
||||
findNavController().navigate(
|
||||
HomeFragmentDirections.actionShowGenre(Genre.id)
|
||||
)
|
||||
findNavController().navigate(HomeFragmentDirections.actionShowGenre(Genre.id))
|
||||
},
|
||||
::newMenu
|
||||
)
|
||||
::newMenu)
|
||||
|
||||
setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres)
|
||||
|
||||
|
@ -63,8 +60,7 @@ class GenreListFragment : HomeListFragment() {
|
|||
|
||||
override val listPopupProvider: (Int) -> String
|
||||
get() = { idx ->
|
||||
homeModel.genres.value!![idx].resolvedName
|
||||
.sliceArticle().first().uppercase()
|
||||
homeModel.genres.value!![idx].resolvedName.sliceArticle().first().uppercase()
|
||||
}
|
||||
|
||||
class GenreAdapter(
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* HomeListFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -38,9 +37,7 @@ abstract class HomeListFragment : Fragment() {
|
|||
protected val homeModel: HomeViewModel by activityViewModels()
|
||||
protected val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
||||
/**
|
||||
* The popup provider to use for the fast scroller view.
|
||||
*/
|
||||
/** The popup provider to use for the fast scroller view. */
|
||||
abstract val listPopupProvider: (Int) -> String
|
||||
|
||||
protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler(
|
||||
|
@ -56,18 +53,15 @@ abstract class HomeListFragment : Fragment() {
|
|||
applySpans()
|
||||
|
||||
popupProvider = listPopupProvider
|
||||
onDragListener = { dragging ->
|
||||
homeModel.updateFastScrolling(dragging)
|
||||
}
|
||||
onDragListener = { dragging -> homeModel.updateFastScrolling(dragging) }
|
||||
}
|
||||
|
||||
// Make sure that this RecyclerView has data before startup
|
||||
homeData.observe(viewLifecycleOwner) { data ->
|
||||
homeAdapter.updateData(data)
|
||||
}
|
||||
homeData.observe(viewLifecycleOwner) { data -> homeAdapter.updateData(data) }
|
||||
}
|
||||
|
||||
abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||
abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> :
|
||||
RecyclerView.Adapter<VH>() {
|
||||
protected var data = listOf<T>()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SongListFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -47,12 +46,7 @@ class SongListFragment : HomeListFragment() {
|
|||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
val adapter = SongsAdapter(
|
||||
doOnClick = { song ->
|
||||
playbackModel.playSong(song)
|
||||
},
|
||||
::newMenu
|
||||
)
|
||||
val adapter = SongsAdapter(doOnClick = { song -> playbackModel.playSong(song) }, ::newMenu)
|
||||
|
||||
setupRecycler(R.id.home_song_list, binding, adapter, homeModel.songs)
|
||||
|
||||
|
@ -68,21 +62,17 @@ class SongListFragment : HomeListFragment() {
|
|||
// based off the names of the parent objects and not the child objects.
|
||||
when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) {
|
||||
// Name -> Use name
|
||||
is Sort.ByName -> song.name.sliceArticle()
|
||||
.first().uppercase()
|
||||
is Sort.ByName -> song.name.sliceArticle().first().uppercase()
|
||||
|
||||
// Artist -> Use Artist Name
|
||||
is Sort.ByArtist ->
|
||||
song.album.artist.resolvedName
|
||||
.sliceArticle().first().uppercase()
|
||||
song.album.artist.resolvedName.sliceArticle().first().uppercase()
|
||||
|
||||
// Album -> Use Album Name
|
||||
is Sort.ByAlbum -> song.album.name.sliceArticle()
|
||||
.first().uppercase()
|
||||
is Sort.ByAlbum -> song.album.name.sliceArticle().first().uppercase()
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.ByYear -> song.album.year?.toString()
|
||||
?: getString(R.string.def_date)
|
||||
is Sort.ByYear -> song.album.year?.toString() ?: getString(R.string.def_date)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Tab.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -22,17 +21,17 @@ import org.oxycblt.auxio.ui.DisplayMode
|
|||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
* A data representation of a library tab.
|
||||
* A tab can come in two moves, [Visible] or [Invisible]. Invisibility means that the tab
|
||||
* will still be present in the customization menu, but will not be shown on the home UI.
|
||||
* A data representation of a library tab. A tab can come in two moves, [Visible] or [Invisible].
|
||||
* Invisibility means that the tab will still be present in the customization menu, but will not be
|
||||
* shown on the home UI.
|
||||
*
|
||||
* Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs cannot
|
||||
* be serialized on their own. Instead, they are saved as a sequence of tabs as shown below:
|
||||
*
|
||||
* 0bTAB1_TAB2_TAB3_TAB4_TAB5
|
||||
*
|
||||
* Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists.
|
||||
* Each chunk in a sequence is represented as:
|
||||
* Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists. Each
|
||||
* chunk in a sequence is represented as:
|
||||
*
|
||||
* VTTT
|
||||
*
|
||||
|
@ -49,14 +48,12 @@ sealed class Tab(open val mode: DisplayMode) {
|
|||
data class Invisible(override val mode: DisplayMode) : Tab(mode)
|
||||
|
||||
companion object {
|
||||
/** The length a well-formed tab sequence should be **/
|
||||
/** The length a well-formed tab sequence should be */
|
||||
const val SEQUENCE_LEN = 4
|
||||
/** The default tab sequence, represented in integer form **/
|
||||
/** The default tab sequence, represented in integer form */
|
||||
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
|
||||
|
||||
/**
|
||||
* Convert an array [tabs] into a sequence of tabs.
|
||||
*/
|
||||
/** Convert an array [tabs] into a sequence of tabs. */
|
||||
fun toSequence(tabs: Array<Tab>): Int {
|
||||
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||
val distinct = tabs.distinctBy { it.mode }
|
||||
|
@ -65,7 +62,8 @@ sealed class Tab(open val mode: DisplayMode) {
|
|||
var shift = SEQUENCE_LEN * 4
|
||||
|
||||
for (tab in distinct) {
|
||||
val bin = when (tab) {
|
||||
val bin =
|
||||
when (tab) {
|
||||
is Visible -> 1.shl(3) or tab.mode.ordinal
|
||||
is Invisible -> tab.mode.ordinal
|
||||
}
|
||||
|
@ -77,9 +75,7 @@ sealed class Tab(open val mode: DisplayMode) {
|
|||
return sequence
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a [sequence] into an array of tabs.
|
||||
*/
|
||||
/** Convert a [sequence] into an array of tabs. */
|
||||
fun fromSequence(sequence: Int): Array<Tab>? {
|
||||
val tabs = mutableListOf<Tab>()
|
||||
|
||||
|
@ -88,7 +84,8 @@ sealed class Tab(open val mode: DisplayMode) {
|
|||
for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
|
||||
val chunk = sequence.shr(shift) and 0b1111
|
||||
|
||||
val mode = when (chunk and 7) {
|
||||
val mode =
|
||||
when (chunk and 7) {
|
||||
0 -> DisplayMode.SHOW_SONGS
|
||||
1 -> DisplayMode.SHOW_ALBUMS
|
||||
2 -> DisplayMode.SHOW_ARTISTS
|
||||
|
@ -97,7 +94,8 @@ sealed class Tab(open val mode: DisplayMode) {
|
|||
}
|
||||
|
||||
// Figure out the visibility
|
||||
tabs += if (chunk and 1.shl(3) != 0) {
|
||||
tabs +=
|
||||
if (chunk and 1.shl(3) != 0) {
|
||||
Visible(mode)
|
||||
} else {
|
||||
Invisible(mode)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* TabAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -31,7 +30,8 @@ class TabAdapter(
|
|||
private val getTabs: () -> Array<Tab>,
|
||||
private val onTabSwitch: (Tab) -> Unit,
|
||||
) : RecyclerView.Adapter<TabAdapter.TabViewHolder>() {
|
||||
private val tabs: Array<Tab> get() = getTabs()
|
||||
private val tabs: Array<Tab>
|
||||
get() = getTabs()
|
||||
|
||||
override fun getItemCount(): Int = Tab.SEQUENCE_LEN
|
||||
|
||||
|
@ -43,13 +43,12 @@ class TabAdapter(
|
|||
holder.bind(tabs[position])
|
||||
}
|
||||
|
||||
inner class TabViewHolder(
|
||||
private val binding: ItemTabBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
inner class TabViewHolder(private val binding: ItemTabBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
binding.root.layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
binding.root.layoutParams =
|
||||
RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* CustomizeListDialog.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -32,8 +31,8 @@ import org.oxycblt.auxio.ui.LifecycleDialog
|
|||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* The dialog for customizing library tabs. This dialog does not rely on any specific ViewModel
|
||||
* and serializes it's state instead of
|
||||
* The dialog for customizing library tabs. This dialog does not rely on any specific ViewModel and
|
||||
* serializes it's state instead of
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class TabCustomizeDialog : LifecycleDialog() {
|
||||
|
@ -58,7 +57,8 @@ class TabCustomizeDialog : LifecycleDialog() {
|
|||
// Set up adapter & drag callback
|
||||
val callback = TabDragCallback { pendingTabs }
|
||||
val helper = ItemTouchHelper(callback)
|
||||
val tabAdapter = TabAdapter(
|
||||
val tabAdapter =
|
||||
TabAdapter(
|
||||
helper,
|
||||
getTabs = { pendingTabs },
|
||||
onTabSwitch = { tab ->
|
||||
|
@ -69,16 +69,16 @@ class TabCustomizeDialog : LifecycleDialog() {
|
|||
if (index != -1) {
|
||||
val curTab = pendingTabs[index]
|
||||
logD("Updating tab $curTab to $tab")
|
||||
pendingTabs[index] = when (curTab) {
|
||||
pendingTabs[index] =
|
||||
when (curTab) {
|
||||
is Tab.Visible -> Tab.Invisible(curTab.mode)
|
||||
is Tab.Invisible -> Tab.Visible(curTab.mode)
|
||||
}
|
||||
}
|
||||
|
||||
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
|
||||
pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty()
|
||||
}
|
||||
)
|
||||
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
.isEnabled = pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty()
|
||||
})
|
||||
|
||||
callback.addTabAdapter(tabAdapter)
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* QueueDragCallback.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -24,21 +23,18 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
|
||||
/**
|
||||
* A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu.
|
||||
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple.
|
||||
* TODO: Consider unifying the shared behavior between this and QueueDragCallback into a single
|
||||
* class.
|
||||
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. TODO: Consider unifying the
|
||||
* shared behavior between this and QueueDragCallback into a single class.
|
||||
*/
|
||||
class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.Callback() {
|
||||
private val tabs: Array<Tab> get() = getTabs()
|
||||
private val tabs: Array<Tab>
|
||||
get() = getTabs()
|
||||
private lateinit var tabAdapter: TabAdapter
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int = makeFlag(
|
||||
ItemTouchHelper.ACTION_STATE_DRAG,
|
||||
ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
)
|
||||
): Int = makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN)
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas,
|
||||
|
@ -76,8 +72,8 @@ class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.C
|
|||
override fun isLongPressDragEnabled(): Boolean = false
|
||||
|
||||
/**
|
||||
* Add the tab adapter to this callback.
|
||||
* Done because there's a circular dependency between the two objects
|
||||
* Add the tab adapter to this callback. Done because there's a circular dependency between the
|
||||
* two objects
|
||||
*/
|
||||
fun addTabAdapter(adapter: TabAdapter) {
|
||||
tabAdapter = adapter
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Models.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -27,18 +26,15 @@ import androidx.annotation.StringRes
|
|||
|
||||
// --- MUSIC MODELS ---
|
||||
|
||||
/**
|
||||
* The base for all items in Auxio.
|
||||
*/
|
||||
/** The base for all items in Auxio. */
|
||||
sealed class Item {
|
||||
/** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
|
||||
abstract val id: Long
|
||||
}
|
||||
|
||||
/**
|
||||
* [Item] variant that represents a music item.
|
||||
* TODO: Make name the actual display name and move raw names (including file names) to a new
|
||||
* field called rawName.
|
||||
* [Item] variant that represents a music item. TODO: Make name the actual display name and move raw
|
||||
* names (including file names) to a new field called rawName.
|
||||
*/
|
||||
sealed class Music : Item() {
|
||||
/** The raw name of this item. */
|
||||
|
@ -46,21 +42,19 @@ sealed class Music : Item() {
|
|||
}
|
||||
|
||||
/**
|
||||
* [Music] variant that denotes that this object is a parent of other data objects, such
|
||||
* as an [Album] or [Artist]
|
||||
* [Music] variant that denotes that this object is a parent of other data objects, such as an
|
||||
* [Album] or [Artist]
|
||||
* @property resolvedName
|
||||
*/
|
||||
sealed class MusicParent : Music() {
|
||||
/**
|
||||
* A name resolved from it's raw form to a form suitable to be shown in a ui.
|
||||
* Ex. "unknown" would become Unknown Artist, (124) would become its proper genre name, etc.
|
||||
* A name resolved from it's raw form to a form suitable to be shown in a ui. Ex. "unknown"
|
||||
* would become Unknown Artist, (124) would become its proper genre name, etc.
|
||||
*/
|
||||
abstract val resolvedName: String
|
||||
}
|
||||
|
||||
/**
|
||||
* The data object for a song.
|
||||
*/
|
||||
/** The data object for a song. */
|
||||
data class Song(
|
||||
override val name: String,
|
||||
/** The file name of this song, excluding the full path. */
|
||||
|
@ -82,7 +76,8 @@ data class Song(
|
|||
/** Internal field. Do not use. */
|
||||
val internalMediaStoreAlbumArtistName: String?,
|
||||
) : Music() {
|
||||
override val id: Long get() {
|
||||
override val id: Long
|
||||
get() {
|
||||
var result = name.hashCode().toLong()
|
||||
result = 31 * result + album.name.hashCode()
|
||||
result = 31 * result + album.artist.name.hashCode()
|
||||
|
@ -92,47 +87,58 @@ data class Song(
|
|||
}
|
||||
|
||||
/** The URI for this song. */
|
||||
val uri: Uri get() = ContentUris.withAppendedId(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, internalMediaStoreId
|
||||
)
|
||||
val uri: Uri
|
||||
get() =
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, internalMediaStoreId)
|
||||
/** The duration of this song, in seconds (rounded down) */
|
||||
val seconds: Long get() = duration / 1000
|
||||
val seconds: Long
|
||||
get() = duration / 1000
|
||||
/** The seconds of this song, but as a duration. */
|
||||
val formattedDuration: String get() = seconds.toDuration(false)
|
||||
val formattedDuration: String
|
||||
get() = seconds.toDuration(false)
|
||||
|
||||
private var mAlbum: Album? = null
|
||||
/** The album of this song. */
|
||||
val album: Album get() = requireNotNull(mAlbum)
|
||||
val album: Album
|
||||
get() = requireNotNull(mAlbum)
|
||||
|
||||
private var mGenre: Genre? = null
|
||||
/** The genre of this song. Will be an "unknown genre" if the song does not have any. */
|
||||
val genre: Genre get() = requireNotNull(mGenre)
|
||||
val genre: Genre
|
||||
get() = requireNotNull(mGenre)
|
||||
|
||||
/** An album name resolved to this song in particular. */
|
||||
val resolvedAlbumName: String get() =
|
||||
album.resolvedName
|
||||
val resolvedAlbumName: String
|
||||
get() = album.resolvedName
|
||||
|
||||
/** An artist name resolved to this song in particular. */
|
||||
val resolvedArtistName: String get() =
|
||||
internalMediaStoreArtistName ?: album.artist.resolvedName
|
||||
val resolvedArtistName: String
|
||||
get() = internalMediaStoreArtistName ?: album.artist.resolvedName
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val internalAlbumGroupingId: Long get() {
|
||||
val internalAlbumGroupingId: Long
|
||||
get() {
|
||||
var result = internalGroupingArtistName.lowercase().hashCode().toLong()
|
||||
result = 31 * result + internalMediaStoreAlbumName.lowercase().hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val internalGroupingArtistName: String get() = internalMediaStoreAlbumArtistName
|
||||
val internalGroupingArtistName: String
|
||||
get() =
|
||||
internalMediaStoreAlbumArtistName
|
||||
?: internalMediaStoreArtistName ?: MediaStore.UNKNOWN_STRING
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val internalIsMissingAlbum: Boolean get() = mAlbum == null
|
||||
val internalIsMissingAlbum: Boolean
|
||||
get() = mAlbum == null
|
||||
/** Internal field. Do not use. */
|
||||
val internalIsMissingArtist: Boolean get() = mAlbum?.internalIsMissingArtist ?: true
|
||||
/** Internal field. Do not use. **/
|
||||
val internalIsMissingGenre: Boolean get() = mGenre == null
|
||||
val internalIsMissingArtist: Boolean
|
||||
get() = mAlbum?.internalIsMissingArtist ?: true
|
||||
/** Internal field. Do not use. */
|
||||
val internalIsMissingGenre: Boolean
|
||||
get() = mGenre == null
|
||||
|
||||
/** Internal method. Do not use. */
|
||||
fun internalLinkAlbum(album: Album) {
|
||||
|
@ -145,9 +151,7 @@ data class Song(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The data object for an album.
|
||||
*/
|
||||
/** The data object for an album. */
|
||||
data class Album(
|
||||
override val name: String,
|
||||
/** The latest year of the songs in this album. Null if none of the songs had metadata. */
|
||||
|
@ -165,7 +169,8 @@ data class Album(
|
|||
}
|
||||
}
|
||||
|
||||
override val id: Long get() {
|
||||
override val id: Long
|
||||
get() {
|
||||
var result = name.hashCode().toLong()
|
||||
result = 31 * result + artist.name.hashCode()
|
||||
result = 31 * result + (year ?: 0)
|
||||
|
@ -176,23 +181,25 @@ data class Album(
|
|||
get() = name
|
||||
|
||||
/** The formatted total duration of this album */
|
||||
val totalDuration: String get() =
|
||||
songs.sumOf { it.seconds }.toDuration(false)
|
||||
val totalDuration: String
|
||||
get() = songs.sumOf { it.seconds }.toDuration(false)
|
||||
|
||||
private var mArtist: Artist? = null
|
||||
/** The parent artist of this album. */
|
||||
val artist: Artist get() = requireNotNull(mArtist)
|
||||
val artist: Artist
|
||||
get() = requireNotNull(mArtist)
|
||||
|
||||
/** The artist name, resolved to this album in particular. */
|
||||
val resolvedArtistName: String get() =
|
||||
artist.resolvedName
|
||||
val resolvedArtistName: String
|
||||
get() = artist.resolvedName
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val internalArtistGroupingId: Long get() =
|
||||
internalGroupingArtistName.lowercase().hashCode().toLong()
|
||||
val internalArtistGroupingId: Long
|
||||
get() = internalGroupingArtistName.lowercase().hashCode().toLong()
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val internalIsMissingArtist: Boolean get() = mArtist == null
|
||||
val internalIsMissingArtist: Boolean
|
||||
get() = mArtist == null
|
||||
|
||||
/** Internal method. Do not use. */
|
||||
fun internalLinkArtist(artist: Artist) {
|
||||
|
@ -201,8 +208,8 @@ data class Album(
|
|||
}
|
||||
|
||||
/**
|
||||
* The [MusicParent] for an *album* artist. This reflects a group of songs with the same(ish)
|
||||
* album artist or artist field, not the individual performers of an artist.
|
||||
* The [MusicParent] for an *album* artist. This reflects a group of songs with the same(ish) album
|
||||
* artist or artist field, not the individual performers of an artist.
|
||||
*/
|
||||
data class Artist(
|
||||
override val name: String,
|
||||
|
@ -222,9 +229,7 @@ data class Artist(
|
|||
val songs = albums.flatMap { it.songs }
|
||||
}
|
||||
|
||||
/**
|
||||
* The data object for a genre.
|
||||
*/
|
||||
/** The data object for a genre. */
|
||||
data class Genre(
|
||||
override val name: String,
|
||||
override val resolvedName: String,
|
||||
|
@ -239,13 +244,11 @@ data class Genre(
|
|||
override val id = name.hashCode().toLong()
|
||||
|
||||
/** The formatted total duration of this genre */
|
||||
val totalDuration: String get() =
|
||||
songs.sumOf { it.seconds }.toDuration(false)
|
||||
val totalDuration: String
|
||||
get() = songs.sumOf { it.seconds }.toDuration(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* A data object used solely for the "Header" UI element.
|
||||
*/
|
||||
/** A data object used solely for the "Header" UI element. */
|
||||
data class Header(
|
||||
override val id: Long,
|
||||
/** The string resource used for the header. */
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.ContentResolver
|
||||
|
@ -15,53 +32,52 @@ import org.oxycblt.auxio.util.logD
|
|||
/**
|
||||
* This class acts as the base for most the black magic required to get a remotely sensible music
|
||||
* indexing system while still optimizing for time. I would recommend you leave this module now
|
||||
* before you lose your sanity trying to understand the hoops I had to jump through for this
|
||||
* system, but if you really want to stay, here's a debrief on why this code is so awful.
|
||||
* before you lose your sanity trying to understand the hoops I had to jump through for this system,
|
||||
* but if you really want to stay, here's a debrief on why this code is so awful.
|
||||
*
|
||||
* MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to
|
||||
* other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a
|
||||
* crime against humanity and probably a way to summon Zalgo if you look at it the wrong way.
|
||||
* other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime
|
||||
* against humanity and probably a way to summon Zalgo if you look at it the wrong way.
|
||||
*
|
||||
* You think that if you wanted to query a song's genre from a media database, you could just
|
||||
* put "genre" in the query and it would return it, right? But not with MediaStore! No, that's
|
||||
* too straightforward for this contract that was dropped on it's head as a baby. So instead, you
|
||||
* have to query for each genre, query all the songs in each genre, and then iterate through those
|
||||
* songs to link every song with their genre. This is not documented anywhere, and the
|
||||
* O(mom im scared) algorithm you have to run to get it working single-handedly DOUBLES Auxio's
|
||||
* loading times. At no point have the devs considered that this system is absolutely insane, and
|
||||
* instead focused on adding infuriat- I mean nice proprietary extensions to MediaStore for their
|
||||
* own Google Play Music, and of course every Google Play Music user knew how great that turned
|
||||
* out!
|
||||
* You think that if you wanted to query a song's genre from a media database, you could just put
|
||||
* "genre" in the query and it would return it, right? But not with MediaStore! No, that's too
|
||||
* straightforward for this contract that was dropped on it's head as a baby. So instead, you have
|
||||
* to query for each genre, query all the songs in each genre, and then iterate through those songs
|
||||
* to link every song with their genre. This is not documented anywhere, and the O(mom im scared)
|
||||
* algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no
|
||||
* point have the devs considered that this system is absolutely insane, and instead focused on
|
||||
* adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play
|
||||
* Music, and of course every Google Play Music user knew how great that turned out!
|
||||
*
|
||||
* It's not even ergonomics that makes this API bad. It's base implementation is completely borked
|
||||
* as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files?
|
||||
* I sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see
|
||||
* that the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or
|
||||
* DATE tag. Once again, this is because internally android uses an ancient in-house metadata
|
||||
* parser to get everything indexed, and so far they have not bothered to modernize this parser
|
||||
* or even switch it to something more powerful like Taglib, not even in Android 12. ID3v2.4 has
|
||||
* been around for *21 years.* *It can drink now.* All of my what.
|
||||
* as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? I
|
||||
* sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see that
|
||||
* the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or DATE tag.
|
||||
* Once again, this is because internally android uses an ancient in-house metadata parser to get
|
||||
* everything indexed, and so far they have not bothered to modernize this parser or even switch it
|
||||
* to something more powerful like Taglib, not even in Android 12. ID3v2.4 has been around for *21
|
||||
* years.* *It can drink now.* All of my what.
|
||||
*
|
||||
* Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums
|
||||
* table, so we have to go for the less efficient "make a big query on all the songs lol" method
|
||||
* so that songs don't end up fragmented across artists. Pretty much every OEM has added some
|
||||
* extension or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH)
|
||||
* crippling the normal tables so that you're railroaded into their music app. The way I do
|
||||
* blacklisting relies on a semi-deprecated method, and the supposedly "modern" method is SLOWER and
|
||||
* causes even more problems since I have to manage databases across version boundaries. Sometimes
|
||||
* music will have a deformed clone that I can't filter out, sometimes Genres will just break for
|
||||
* no reason, and sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to
|
||||
* Latin-1 to *Shift JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY
|
||||
* table, so we have to go for the less efficient "make a big query on all the songs lol" method so
|
||||
* that songs don't end up fragmented across artists. Pretty much every OEM has added some extension
|
||||
* or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the
|
||||
* normal tables so that you're railroaded into their music app. The way I do blacklisting relies on
|
||||
* a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more
|
||||
* problems since I have to manage databases across version boundaries. Sometimes music will have a
|
||||
* deformed clone that I can't filter out, sometimes Genres will just break for no reason, and
|
||||
* sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift
|
||||
* JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY
|
||||
*
|
||||
* Is there anything we can do about it? No. Google has routinely shut down issues that begged google
|
||||
* to fix glaring issues with MediaStore or to just take the API behind the woodshed and shoot it.
|
||||
* Largely because they have zero incentive to improve it given how "obscure" local music listening
|
||||
* is. As a result, some players like Vanilla and VLC just hack their own pseudo-MediaStore
|
||||
* implementation from their own (better) parsers, but this is both infeasible for Auxio due to how
|
||||
* incredibly slow it is to get a file handle from the android sandbox AND how much harder it is to
|
||||
* manage a database of your own media that mirrors the filesystem perfectly. And even if I set
|
||||
* aside those crippling issues and changed my indexer to that, it would face the even larger
|
||||
* problem of how google keeps trying to kill the filesystem and force you into their
|
||||
* Is there anything we can do about it? No. Google has routinely shut down issues that begged
|
||||
* google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
|
||||
* shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
|
||||
* listening is. As a result, some players like Vanilla and VLC just hack their own
|
||||
* pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for
|
||||
* Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much
|
||||
* harder it is to manage a database of your own media that mirrors the filesystem perfectly. And
|
||||
* even if I set aside those crippling issues and changed my indexer to that, it would face the even
|
||||
* larger problem of how google keeps trying to kill the filesystem and force you into their
|
||||
* ContentResolver API. In the future MediaStore could be the only system we have, which is also the
|
||||
* day that greenland melts and birthdays stop happening forever.
|
||||
*
|
||||
|
@ -94,38 +110,30 @@ class MusicLoader {
|
|||
for (song in songs) {
|
||||
if (song.internalIsMissingAlbum ||
|
||||
song.internalIsMissingArtist ||
|
||||
song.internalIsMissingGenre
|
||||
) {
|
||||
song.internalIsMissingGenre) {
|
||||
throw IllegalStateException(
|
||||
"Found malformed song: ${song.name} [" +
|
||||
"album: ${!song.internalIsMissingAlbum} " +
|
||||
"artist: ${!song.internalIsMissingArtist} " +
|
||||
"genre: ${!song.internalIsMissingGenre}]"
|
||||
)
|
||||
"genre: ${!song.internalIsMissingGenre}]")
|
||||
}
|
||||
}
|
||||
|
||||
return Library(
|
||||
genres,
|
||||
artists,
|
||||
albums,
|
||||
songs
|
||||
)
|
||||
return Library(genres, artists, albums, songs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a content resolver in a way that does not mangle metadata on
|
||||
* certain OEM skins. See https://github.com/OxygenCobalt/Auxio/issues/50
|
||||
* for more info.
|
||||
* Gets a content resolver in a way that does not mangle metadata on certain OEM skins. See
|
||||
* https://github.com/OxygenCobalt/Auxio/issues/50 for more info.
|
||||
*/
|
||||
private val Context.contentResolverSafe: ContentResolver get() =
|
||||
applicationContext.contentResolver
|
||||
private val Context.contentResolverSafe: ContentResolver
|
||||
get() = applicationContext.contentResolver
|
||||
|
||||
/**
|
||||
* Does the initial query over the song database, including excluded directory
|
||||
* checks. The songs returned by this function are **not** well-formed. The
|
||||
* companion [buildAlbums], [buildArtists], and [readGenres] functions must be
|
||||
* called with the returned list so that all songs are properly linked up.
|
||||
* Does the initial query over the song database, including excluded directory checks. The songs
|
||||
* returned by this function are **not** well-formed. The companion [buildAlbums],
|
||||
* [buildArtists], and [readGenres] functions must be called with the returned list so that all
|
||||
* songs are properly linked up.
|
||||
*/
|
||||
private fun loadSongs(context: Context): List<Song> {
|
||||
val blacklistDatabase = ExcludedDatabase.getInstance(context)
|
||||
|
@ -157,18 +165,22 @@ class MusicLoader {
|
|||
MediaStore.Audio.AudioColumns.ALBUM,
|
||||
MediaStore.Audio.AudioColumns.ALBUM_ID,
|
||||
MediaStore.Audio.AudioColumns.ARTIST,
|
||||
AUDIO_COLUMN_ALBUM_ARTIST
|
||||
),
|
||||
selector, args.toTypedArray(), null
|
||||
)?.use { cursor ->
|
||||
AUDIO_COLUMN_ALBUM_ARTIST),
|
||||
selector,
|
||||
args.toTypedArray(),
|
||||
null)
|
||||
?.use { cursor ->
|
||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
|
||||
val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||
val fileIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||
val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
||||
val durationIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
||||
val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
|
||||
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
|
||||
val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
|
||||
val albumIdIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
|
||||
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
||||
val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
|
||||
|
@ -192,7 +204,8 @@ class MusicLoader {
|
|||
|
||||
// If the artist field is <unknown>, make it null. This makes handling the
|
||||
// insanity of the artist field easier later on.
|
||||
val artist = cursor.getStringOrNull(artistIndex)?.run {
|
||||
val artist =
|
||||
cursor.getStringOrNull(artistIndex)?.run {
|
||||
if (this == MediaStore.UNKNOWN_STRING) {
|
||||
null
|
||||
} else {
|
||||
|
@ -219,16 +232,22 @@ class MusicLoader {
|
|||
albumId,
|
||||
artist,
|
||||
albumArtist,
|
||||
)
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate songs to prevent (most) deformed music clones
|
||||
songs = songs.distinctBy {
|
||||
it.name to it.internalMediaStoreAlbumName to it.internalMediaStoreArtistName to
|
||||
it.internalMediaStoreAlbumArtistName to it.track to it.duration
|
||||
}.toMutableList()
|
||||
songs =
|
||||
songs
|
||||
.distinctBy {
|
||||
it.name to
|
||||
it.internalMediaStoreAlbumName to
|
||||
it.internalMediaStoreArtistName to
|
||||
it.internalMediaStoreAlbumArtistName to
|
||||
it.track to
|
||||
it.duration
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
logD("Successfully loaded ${songs.size} songs")
|
||||
|
||||
|
@ -236,17 +255,17 @@ class MusicLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Group songs up into their respective albums. Instead of using the unreliable album or
|
||||
* artist databases, we instead group up songs by their *lowercase* artist and album name
|
||||
* to create albums. This serves two purposes:
|
||||
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN".
|
||||
* This makes sure both of those are resolved into a single artist called "Rammstein"
|
||||
* 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This
|
||||
* ensures that all songs are unified under a single album.
|
||||
* Group songs up into their respective albums. Instead of using the unreliable album or artist
|
||||
* databases, we instead group up songs by their *lowercase* artist and album name to create
|
||||
* albums. This serves two purposes:
|
||||
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
|
||||
* makes sure both of those are resolved into a single artist called "Rammstein"
|
||||
* 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
|
||||
* that all songs are unified under a single album.
|
||||
*
|
||||
* This does come with some costs, it's far slower than using the album ID itself, and
|
||||
* it may result in an unrelated album art being selected depending on the song chosen
|
||||
* as the template, but it seems to work pretty well.
|
||||
* This does come with some costs, it's far slower than using the album ID itself, and it may
|
||||
* result in an unrelated album art being selected depending on the song chosen as the template,
|
||||
* but it seems to work pretty well.
|
||||
*/
|
||||
private fun buildAlbums(songs: List<Song>): List<Album> {
|
||||
val albums = mutableListOf<Album>()
|
||||
|
@ -259,15 +278,14 @@ class MusicLoader {
|
|||
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
|
||||
// weird years like "0" wont show up if there are alternatives.
|
||||
// TODO: Weigh songs with null years lower than songs with zero years
|
||||
val templateSong = requireNotNull(
|
||||
albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 }
|
||||
)
|
||||
val templateSong =
|
||||
requireNotNull(albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 })
|
||||
val albumName = templateSong.internalMediaStoreAlbumName
|
||||
val albumYear = templateSong.internalMediaStoreYear
|
||||
val albumCoverUri = ContentUris.withAppendedId(
|
||||
val albumCoverUri =
|
||||
ContentUris.withAppendedId(
|
||||
Uri.parse("content://media/external/audio/albumart"),
|
||||
templateSong.internalMediaStoreAlbumId
|
||||
)
|
||||
templateSong.internalMediaStoreAlbumId)
|
||||
val artistName = templateSong.internalGroupingArtistName
|
||||
|
||||
albums.add(
|
||||
|
@ -277,8 +295,7 @@ class MusicLoader {
|
|||
albumCoverUri,
|
||||
albumSongs,
|
||||
artistName,
|
||||
)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
logD("Successfully built ${albums.size} albums")
|
||||
|
@ -287,8 +304,8 @@ class MusicLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Group up albums into artists. This also requires a de-duplication step due to some
|
||||
* edge cases where [buildAlbums] could not detect duplicates.
|
||||
* Group up albums into artists. This also requires a de-duplication step due to some edge cases
|
||||
* where [buildAlbums] could not detect duplicates.
|
||||
*/
|
||||
private fun buildArtists(context: Context, albums: List<Album>): List<Artist> {
|
||||
val artists = mutableListOf<Artist>()
|
||||
|
@ -297,19 +314,14 @@ class MusicLoader {
|
|||
for (entry in albumsByArtist) {
|
||||
val templateAlbum = entry.value[0]
|
||||
val artistName = templateAlbum.internalGroupingArtistName
|
||||
val resolvedName = when (templateAlbum.internalGroupingArtistName) {
|
||||
val resolvedName =
|
||||
when (templateAlbum.internalGroupingArtistName) {
|
||||
MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist)
|
||||
else -> artistName
|
||||
}
|
||||
val artistAlbums = entry.value
|
||||
|
||||
artists.add(
|
||||
Artist(
|
||||
artistName,
|
||||
resolvedName,
|
||||
artistAlbums
|
||||
)
|
||||
)
|
||||
artists.add(Artist(artistName, resolvedName, artistAlbums))
|
||||
}
|
||||
|
||||
logD("Successfully built ${artists.size} artists")
|
||||
|
@ -318,50 +330,45 @@ class MusicLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Read all genres and link them up to the given songs. This is the code that
|
||||
* requires me to make dozens of useless queries just to link genres up.
|
||||
* Read all genres and link them up to the given songs. This is the code that requires me to
|
||||
* make dozens of useless queries just to link genres up.
|
||||
*/
|
||||
private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
|
||||
val genres = mutableListOf<Genre>()
|
||||
|
||||
context.contentResolverSafe.query(
|
||||
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(
|
||||
MediaStore.Audio.Genres._ID,
|
||||
MediaStore.Audio.Genres.NAME
|
||||
),
|
||||
null, null, null
|
||||
)?.use { cursor ->
|
||||
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME),
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
?.use { cursor ->
|
||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
|
||||
val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
// Genre names can be a normal name, an ID3v2 constant, or null. Normal names are
|
||||
// resolved as usual, but null values don't make sense and are often junk anyway,
|
||||
// Genre names can be a normal name, an ID3v2 constant, or null. Normal names
|
||||
// are
|
||||
// resolved as usual, but null values don't make sense and are often junk
|
||||
// anyway,
|
||||
// so we skip genres that have them.
|
||||
val id = cursor.getLong(idIndex)
|
||||
val name = cursor.getStringOrNull(nameIndex) ?: continue
|
||||
val resolvedName = name.genreNameCompat ?: name
|
||||
val genreSongs = queryGenreSongs(context, id, songs) ?: continue
|
||||
|
||||
genres.add(
|
||||
Genre(
|
||||
name,
|
||||
resolvedName,
|
||||
genreSongs
|
||||
)
|
||||
)
|
||||
genres.add(Genre(name, resolvedName, genreSongs))
|
||||
}
|
||||
}
|
||||
|
||||
val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
|
||||
if (songsWithoutGenres.isNotEmpty()) {
|
||||
// Songs that don't have a genre will be thrown into an unknown genre.
|
||||
val unknownGenre = Genre(
|
||||
val unknownGenre =
|
||||
Genre(
|
||||
name = MediaStore.UNKNOWN_STRING,
|
||||
resolvedName = context.getString(R.string.def_genre),
|
||||
songsWithoutGenres
|
||||
)
|
||||
songsWithoutGenres)
|
||||
|
||||
genres.add(unknownGenre)
|
||||
}
|
||||
|
@ -372,10 +379,11 @@ class MusicLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the
|
||||
* genre constant map that Auxio uses.
|
||||
* Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the genre
|
||||
* constant map that Auxio uses.
|
||||
*/
|
||||
private val String.genreNameCompat: String? get() {
|
||||
private val String.genreNameCompat: String?
|
||||
get() {
|
||||
if (isDigitsOnly()) {
|
||||
// ID3v1, just parse as an integer
|
||||
return genreConstantTable.getOrNull(toInt())
|
||||
|
@ -395,8 +403,8 @@ class MusicLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Queries the genre songs for [genreId]. Some genres are insane and don't contain songs
|
||||
* for some reason, so if that's the case then this function will return null.
|
||||
* Queries the genre songs for [genreId]. Some genres are insane and don't contain songs for
|
||||
* some reason, so if that's the case then this function will return null.
|
||||
*/
|
||||
private fun queryGenreSongs(context: Context, genreId: Long, songs: List<Song>): List<Song>? {
|
||||
val genreSongs = mutableListOf<Song>()
|
||||
|
@ -405,8 +413,10 @@ class MusicLoader {
|
|||
context.contentResolverSafe.query(
|
||||
MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
|
||||
arrayOf(MediaStore.Audio.Genres.Members._ID),
|
||||
null, null, null
|
||||
)?.use { cursor ->
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
?.use { cursor ->
|
||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
|
@ -422,10 +432,10 @@ class MusicLoader {
|
|||
|
||||
companion object {
|
||||
/**
|
||||
* The album_artist MediaStore field has existed since at least API 21, but until API
|
||||
* 30 it was a proprietary extension for Google Play Music and was not documented.
|
||||
* Since this field probably works on all versions Auxio supports, we suppress the
|
||||
* warning about using a possibly-unsupported constant.
|
||||
* The album_artist MediaStore field has existed since at least API 21, but until API 30 it
|
||||
* was a proprietary extension for Google Play Music and was not documented. Since this
|
||||
* field probably works on all versions Auxio supports, we suppress the warning about using
|
||||
* a possibly-unsupported constant.
|
||||
*/
|
||||
@Suppress("InlinedApi")
|
||||
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
|
||||
|
@ -434,42 +444,205 @@ class MusicLoader {
|
|||
* A complete table of all the constant genre values for ID3(v2), including non-standard
|
||||
* extensions.
|
||||
*/
|
||||
private val genreConstantTable = arrayOf(
|
||||
private val genreConstantTable =
|
||||
arrayOf(
|
||||
// ID3 Standard
|
||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop",
|
||||
"Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock",
|
||||
"Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack",
|
||||
"Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance",
|
||||
"Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
|
||||
"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop",
|
||||
"Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic",
|
||||
"Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta",
|
||||
"Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret",
|
||||
"New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
|
||||
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
"Alternative",
|
||||
"Ska",
|
||||
"Death Metal",
|
||||
"Pranks",
|
||||
"Soundtrack",
|
||||
"Euro-Techno",
|
||||
"Ambient",
|
||||
"Trip-Hop",
|
||||
"Vocal",
|
||||
"Jazz+Funk",
|
||||
"Fusion",
|
||||
"Trance",
|
||||
"Classical",
|
||||
"Instrumental",
|
||||
"Acid",
|
||||
"House",
|
||||
"Game",
|
||||
"Sound Clip",
|
||||
"Gospel",
|
||||
"Noise",
|
||||
"AlternRock",
|
||||
"Bass",
|
||||
"Soul",
|
||||
"Punk",
|
||||
"Space",
|
||||
"Meditative",
|
||||
"Instrumental Pop",
|
||||
"Instrumental Rock",
|
||||
"Ethnic",
|
||||
"Gothic",
|
||||
"Darkwave",
|
||||
"Techno-Industrial",
|
||||
"Electronic",
|
||||
"Pop-Folk",
|
||||
"Eurodance",
|
||||
"Dream",
|
||||
"Southern Rock",
|
||||
"Comedy",
|
||||
"Cult",
|
||||
"Gangsta",
|
||||
"Top 40",
|
||||
"Christian Rap",
|
||||
"Pop/Funk",
|
||||
"Jungle",
|
||||
"Native American",
|
||||
"Cabaret",
|
||||
"New Wave",
|
||||
"Psychadelic",
|
||||
"Rave",
|
||||
"Showtunes",
|
||||
"Trailer",
|
||||
"Lo-Fi",
|
||||
"Tribal",
|
||||
"Acid Punk",
|
||||
"Acid Jazz",
|
||||
"Polka",
|
||||
"Retro",
|
||||
"Musical",
|
||||
"Rock & Roll",
|
||||
"Hard Rock",
|
||||
|
||||
// Winamp extensions, more or less a de-facto standard
|
||||
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin",
|
||||
"Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock",
|
||||
"Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus",
|
||||
"Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", "Opera", "Chamber Music",
|
||||
"Sonata", "Symphony", "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam",
|
||||
"Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul",
|
||||
"Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall",
|
||||
"Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", "Britpop",
|
||||
"Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta", "Heavy Metal", "Black Metal",
|
||||
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
||||
"Thrash Metal", "Anime", "JPop", "Synthpop",
|
||||
"Folk",
|
||||
"Folk-Rock",
|
||||
"National Folk",
|
||||
"Swing",
|
||||
"Fast Fusion",
|
||||
"Bebob",
|
||||
"Latin",
|
||||
"Revival",
|
||||
"Celtic",
|
||||
"Bluegrass",
|
||||
"Avantgarde",
|
||||
"Gothic Rock",
|
||||
"Progressive Rock",
|
||||
"Psychedelic Rock",
|
||||
"Symphonic Rock",
|
||||
"Slow Rock",
|
||||
"Big Band",
|
||||
"Chorus",
|
||||
"Easy Listening",
|
||||
"Acoustic",
|
||||
"Humour",
|
||||
"Speech",
|
||||
"Chanson",
|
||||
"Opera",
|
||||
"Chamber Music",
|
||||
"Sonata",
|
||||
"Symphony",
|
||||
"Booty Bass",
|
||||
"Primus",
|
||||
"Porn Groove",
|
||||
"Satire",
|
||||
"Slow Jam",
|
||||
"Club",
|
||||
"Tango",
|
||||
"Samba",
|
||||
"Folklore",
|
||||
"Ballad",
|
||||
"Power Ballad",
|
||||
"Rhythmic Soul",
|
||||
"Freestyle",
|
||||
"Duet",
|
||||
"Punk Rock",
|
||||
"Drum Solo",
|
||||
"A capella",
|
||||
"Euro-House",
|
||||
"Dance Hall",
|
||||
"Goa",
|
||||
"Drum & Bass",
|
||||
"Club-House",
|
||||
"Hardcore",
|
||||
"Terror",
|
||||
"Indie",
|
||||
"Britpop",
|
||||
"Negerpunk",
|
||||
"Polsk Punk",
|
||||
"Beat",
|
||||
"Christian Gangsta",
|
||||
"Heavy Metal",
|
||||
"Black Metal",
|
||||
"Crossover",
|
||||
"Contemporary Christian",
|
||||
"Christian Rock",
|
||||
"Merengue",
|
||||
"Salsa",
|
||||
"Thrash Metal",
|
||||
"Anime",
|
||||
"JPop",
|
||||
"Synthpop",
|
||||
|
||||
// Winamp 5.6+ extensions, also used by EasyTAG.
|
||||
// I only include this because post-rock is a based genre and deserves a slot.
|
||||
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout",
|
||||
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental",
|
||||
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock",
|
||||
"Leftfield", "Lounge", "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk",
|
||||
"Post-Rock", "Psytrance", "Shoegaze", "Space Rock", "Trop Rock", "World Music",
|
||||
"Neoclassical", "Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast",
|
||||
"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient"
|
||||
)
|
||||
"Abstract",
|
||||
"Art Rock",
|
||||
"Baroque",
|
||||
"Bhangra",
|
||||
"Big Beat",
|
||||
"Breakbeat",
|
||||
"Chillout",
|
||||
"Downtempo",
|
||||
"Dub",
|
||||
"EBM",
|
||||
"Eclectic",
|
||||
"Electro",
|
||||
"Electroclash",
|
||||
"Emo",
|
||||
"Experimental",
|
||||
"Garage",
|
||||
"Global",
|
||||
"IDM",
|
||||
"Illbient",
|
||||
"Industro-Goth",
|
||||
"Jam Band",
|
||||
"Krautrock",
|
||||
"Leftfield",
|
||||
"Lounge",
|
||||
"Math Rock",
|
||||
"New Romantic",
|
||||
"Nu-Breakz",
|
||||
"Post-Punk",
|
||||
"Post-Rock",
|
||||
"Psytrance",
|
||||
"Shoegaze",
|
||||
"Space Rock",
|
||||
"Trop Rock",
|
||||
"World Music",
|
||||
"Neoclassical",
|
||||
"Audiobook",
|
||||
"Audio Theatre",
|
||||
"Neue Deutsche Welle",
|
||||
"Podcast",
|
||||
"Indie Rock",
|
||||
"G-Funk",
|
||||
"Dubstep",
|
||||
"Garage Rock",
|
||||
"Psybient")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MusicStore.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -25,41 +24,42 @@ import android.content.pm.PackageManager
|
|||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.lang.Exception
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import java.lang.Exception
|
||||
|
||||
/**
|
||||
* The main storage for music items.
|
||||
* Getting an instance of this object is more complicated as it loads asynchronously.
|
||||
* See the companion object for more.
|
||||
* TODO: Add automatic rescanning [major change]
|
||||
* The main storage for music items. Getting an instance of this object is more complicated as it
|
||||
* loads asynchronously. See the companion object for more. TODO: Add automatic rescanning [major
|
||||
* change]
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class MusicStore private constructor() {
|
||||
private var mGenres = listOf<Genre>()
|
||||
val genres: List<Genre> get() = mGenres
|
||||
val genres: List<Genre>
|
||||
get() = mGenres
|
||||
|
||||
private var mArtists = listOf<Artist>()
|
||||
val artists: List<Artist> get() = mArtists
|
||||
val artists: List<Artist>
|
||||
get() = mArtists
|
||||
|
||||
private var mAlbums = listOf<Album>()
|
||||
val albums: List<Album> get() = mAlbums
|
||||
val albums: List<Album>
|
||||
get() = mAlbums
|
||||
|
||||
private var mSongs = listOf<Song>()
|
||||
val songs: List<Song> get() = mSongs
|
||||
val songs: List<Song>
|
||||
get() = mSongs
|
||||
|
||||
/**
|
||||
* Load/Sort the entire music library. Should always be ran on a coroutine.
|
||||
*/
|
||||
/** Load/Sort the entire music library. Should always be ran on a coroutine. */
|
||||
private fun load(context: Context): Response {
|
||||
logD("Starting initial music load")
|
||||
|
||||
val notGranted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_DENIED
|
||||
val notGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
|
||||
PackageManager.PERMISSION_DENIED
|
||||
|
||||
if (notGranted) {
|
||||
return Response.Err(ErrorKind.NO_PERMS)
|
||||
|
@ -69,8 +69,7 @@ class MusicStore private constructor() {
|
|||
val start = System.currentTimeMillis()
|
||||
|
||||
val loader = MusicLoader()
|
||||
val library = loader.load(context)
|
||||
?: return Response.Err(ErrorKind.NO_MUSIC)
|
||||
val library = loader.load(context) ?: return Response.Err(ErrorKind.NO_MUSIC)
|
||||
|
||||
mSongs = library.songs
|
||||
mAlbums = library.albums
|
||||
|
@ -87,27 +86,22 @@ class MusicStore private constructor() {
|
|||
return Response.Ok(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a song in a faster manner using an ID for its album as well.
|
||||
*/
|
||||
/** Find a song in a faster manner using an ID for its album as well. */
|
||||
fun findSongFast(songId: Long, albumId: Long): Song? {
|
||||
return albums.find { it.id == albumId }?.songs?.find { it.id == songId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a song for a [uri], this is similar to [findSongFast], but with some kind of content uri.
|
||||
* Find a song for a [uri], this is similar to [findSongFast], but with some kind of content
|
||||
* uri.
|
||||
* @return The corresponding [Song] for this [uri], null if there isn't one.
|
||||
*/
|
||||
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
|
||||
resolver.query(
|
||||
uri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME),
|
||||
null, null, null
|
||||
)?.use { cursor ->
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor
|
||||
->
|
||||
cursor.moveToFirst()
|
||||
val fileName = cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
|
||||
)
|
||||
val fileName =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||
|
||||
return songs.find { it.fileName == fileName }
|
||||
}
|
||||
|
@ -116,9 +110,8 @@ class MusicStore private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* A response that [MusicStore] returns when loading music.
|
||||
* And before you ask, yes, I do like rust.
|
||||
* TODO: Add the exception to the "FAILED" ErrorKind
|
||||
* A response that [MusicStore] returns when loading music. And before you ask, yes, I do like
|
||||
* rust. TODO: Add the exception to the "FAILED" ErrorKind
|
||||
*/
|
||||
sealed class Response {
|
||||
class Ok(val musicStore: MusicStore) : Response()
|
||||
|
@ -126,12 +119,13 @@ class MusicStore private constructor() {
|
|||
}
|
||||
|
||||
enum class ErrorKind {
|
||||
NO_PERMS, NO_MUSIC, FAILED
|
||||
NO_PERMS,
|
||||
NO_MUSIC,
|
||||
FAILED
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var RESPONSE: Response? = null
|
||||
@Volatile private var RESPONSE: Response? = null
|
||||
|
||||
/**
|
||||
* Initialize the loading process for this instance. This must be ran on a background
|
||||
|
@ -145,11 +139,10 @@ class MusicStore private constructor() {
|
|||
return currentInstance
|
||||
}
|
||||
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
val response = MusicStore().load(context)
|
||||
synchronized(this) {
|
||||
RESPONSE = response
|
||||
}
|
||||
synchronized(this) { RESPONSE = response }
|
||||
response
|
||||
}
|
||||
|
||||
|
@ -157,11 +150,12 @@ class MusicStore private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Await the successful creation of a [MusicStore] instance. The co-routine calling
|
||||
* this will block until the successful creation of a [MusicStore], in which it will
|
||||
* then be returned.
|
||||
* Await the successful creation of a [MusicStore] instance. The co-routine calling this
|
||||
* will block until the successful creation of a [MusicStore], in which it will then be
|
||||
* returned.
|
||||
*/
|
||||
suspend fun awaitInstance() = withContext(Dispatchers.Default) {
|
||||
suspend fun awaitInstance() =
|
||||
withContext(Dispatchers.Default) {
|
||||
// We have to do a withContext call so we don't block the JVM thread
|
||||
val musicStore: MusicStore
|
||||
|
||||
|
@ -178,8 +172,8 @@ class MusicStore private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Maybe get a MusicStore instance. This is useful if you are running code while the
|
||||
* loading process may still be going on.
|
||||
* Maybe get a MusicStore instance. This is useful if you are running code while the loading
|
||||
* process may still be going on.
|
||||
*
|
||||
* @return null if the music store instance is still loading or if the loading process has
|
||||
* encountered an error. An instance is returned otherwise.
|
||||
|
@ -195,9 +189,8 @@ class MusicStore private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Require a MusicStore instance. This function is dangerous and should only be used if
|
||||
* it's guaranteed that the caller's code will only be called after the initial loading
|
||||
* process.
|
||||
* Require a MusicStore instance. This function is dangerous and should only be used if it's
|
||||
* guaranteed that the caller's code will only be called after the initial loading process.
|
||||
*/
|
||||
fun requireInstance(): MusicStore {
|
||||
return requireNotNull(maybeGetInstance()) {
|
||||
|
@ -205,9 +198,7 @@ class MusicStore private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this instance has successfully loaded or not.
|
||||
*/
|
||||
/** Check if this instance has successfully loaded or not. */
|
||||
fun loaded(): Boolean {
|
||||
return maybeGetInstance() != null
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MusicUtils.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -30,8 +29,8 @@ import org.oxycblt.auxio.util.logW
|
|||
|
||||
/**
|
||||
* Convert a [Long] of seconds into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then
|
||||
* --:-- will be returned if the second value is 0.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.toDuration(isElapsed: Boolean): String {
|
||||
if (!isElapsed && this == 0L) {
|
||||
|
@ -58,11 +57,7 @@ fun TextView.bindSongInfo(song: Song?) {
|
|||
return
|
||||
}
|
||||
|
||||
text = context.getString(
|
||||
R.string.fmt_two,
|
||||
song.resolvedArtistName,
|
||||
song.resolvedAlbumName
|
||||
)
|
||||
text = context.getString(R.string.fmt_two, song.resolvedArtistName, song.resolvedAlbumName)
|
||||
}
|
||||
|
||||
@BindingAdapter("albumInfo")
|
||||
|
@ -72,11 +67,11 @@ fun TextView.bindAlbumInfo(album: Album?) {
|
|||
return
|
||||
}
|
||||
|
||||
text = context.getString(
|
||||
text =
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
album.resolvedArtistName,
|
||||
context.getPluralSafe(R.plurals.fmt_song_count, album.songs.size)
|
||||
)
|
||||
context.getPluralSafe(R.plurals.fmt_song_count, album.songs.size))
|
||||
}
|
||||
|
||||
@BindingAdapter("artistInfo")
|
||||
|
@ -86,11 +81,11 @@ fun TextView.bindArtistInfo(artist: Artist?) {
|
|||
return
|
||||
}
|
||||
|
||||
text = context.getString(
|
||||
text =
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
context.getPluralSafe(R.plurals.fmt_album_count, artist.albums.size),
|
||||
context.getPluralSafe(R.plurals.fmt_song_count, artist.songs.size)
|
||||
)
|
||||
context.getPluralSafe(R.plurals.fmt_song_count, artist.songs.size))
|
||||
}
|
||||
|
||||
@BindingAdapter("genreInfo")
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MusicViewModel.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -33,8 +32,8 @@ class MusicViewModel : ViewModel() {
|
|||
private var isBusy = false
|
||||
|
||||
/**
|
||||
* Initiate the loading process. This is done here since HomeFragment will be the first
|
||||
* fragment navigated to and because SnackBars will have the best UX here.
|
||||
* Initiate the loading process. This is done here since HomeFragment will be the first fragment
|
||||
* navigated to and because SnackBars will have the best UX here.
|
||||
*/
|
||||
fun loadMusic(context: Context) {
|
||||
if (mLoaderResponse.value != null || isBusy) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* BlacklistDatabase.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -28,9 +27,9 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.queryAll
|
||||
|
||||
/**
|
||||
* Database for storing excluded directories.
|
||||
* Note that the paths stored here will not work with MediaStore unless you append a "%" at the end.
|
||||
* Yes. I know Room exists. But that would needlessly bloat my app and has crippling bugs.
|
||||
* Database for storing excluded directories. Note that the paths stored here will not work with
|
||||
* MediaStore unless you append a "%" at the end. Yes. I know Room exists. But that would needlessly
|
||||
* bloat my app and has crippling bugs.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
||||
|
@ -47,9 +46,7 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
|
|||
onUpgrade(db, newVersion, oldVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a list of [paths] to the database.
|
||||
*/
|
||||
/** Write a list of [paths] to the database. */
|
||||
fun writePaths(paths: List<String>) {
|
||||
assertBackgroundThread()
|
||||
|
||||
|
@ -58,21 +55,14 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
|
|||
logD("Deleted paths db")
|
||||
|
||||
for (path in paths) {
|
||||
insert(
|
||||
TABLE_NAME, null,
|
||||
ContentValues(1).apply {
|
||||
put(COLUMN_PATH, path)
|
||||
}
|
||||
)
|
||||
insert(TABLE_NAME, null, ContentValues(1).apply { put(COLUMN_PATH, path) })
|
||||
}
|
||||
|
||||
logD("Successfully wrote ${paths.size} paths to db")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current list of paths from the database.
|
||||
*/
|
||||
/** Get the current list of paths from the database. */
|
||||
fun readPaths(): List<String> {
|
||||
assertBackgroundThread()
|
||||
|
||||
|
@ -97,12 +87,9 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
|
|||
const val TABLE_NAME = "blacklist_dirs_table"
|
||||
const val COLUMN_PATH = "COLUMN_PATH"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: ExcludedDatabase? = null
|
||||
@Volatile private var INSTANCE: ExcludedDatabase? = null
|
||||
|
||||
/**
|
||||
* Get/Instantiate the single instance of [ExcludedDatabase].
|
||||
*/
|
||||
/** Get/Instantiate the single instance of [ExcludedDatabase]. */
|
||||
fun getInstance(context: Context): ExcludedDatabase {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* BlacklistDialog.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -57,13 +56,10 @@ class ExcludedDialog : LifecycleDialog() {
|
|||
): View {
|
||||
val binding = DialogExcludedBinding.inflate(inflater)
|
||||
|
||||
val adapter = ExcludedEntryAdapter { path ->
|
||||
excludedModel.removePath(path)
|
||||
}
|
||||
val adapter = ExcludedEntryAdapter { path -> excludedModel.removePath(path) }
|
||||
|
||||
val launcher = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath
|
||||
)
|
||||
val launcher =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
|
||||
|
||||
// --- UI SETUP ---
|
||||
|
||||
|
@ -131,9 +127,9 @@ class ExcludedDialog : LifecycleDialog() {
|
|||
|
||||
private fun parseDocTreePath(uri: Uri): String? {
|
||||
// Turn the raw URI into a document tree URI
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(
|
||||
uri, DocumentsContract.getTreeDocumentId(uri)
|
||||
)
|
||||
val docUri =
|
||||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
uri, DocumentsContract.getTreeDocumentId(uri))
|
||||
|
||||
// Turn it into a semi-usable path
|
||||
val typeAndPath = DocumentsContract.getTreeDocumentId(docUri).split(":")
|
||||
|
@ -153,15 +149,11 @@ class ExcludedDialog : LifecycleDialog() {
|
|||
|
||||
private fun saveAndRestart() {
|
||||
excludedModel.save {
|
||||
playbackModel.savePlaybackState(requireContext()) {
|
||||
requireContext().hardRestart()
|
||||
}
|
||||
playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get *just* the root path, nothing else is really needed.
|
||||
*/
|
||||
/** Get *just* the root path, nothing else is really needed. */
|
||||
private fun getRootPath(): String {
|
||||
return Environment.getExternalStorageDirectory().absolutePath
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* BlacklistEntryAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -28,9 +27,8 @@ import org.oxycblt.auxio.util.inflater
|
|||
* Adapter that shows the excluded directories and their "Clear" button.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class ExcludedEntryAdapter(
|
||||
private val onClear: (String) -> Unit
|
||||
) : RecyclerView.Adapter<ExcludedEntryAdapter.ViewHolder>() {
|
||||
class ExcludedEntryAdapter(private val onClear: (String) -> Unit) :
|
||||
RecyclerView.Adapter<ExcludedEntryAdapter.ViewHolder>() {
|
||||
private var paths = mutableListOf<String>()
|
||||
|
||||
override fun getItemCount() = paths.size
|
||||
|
@ -49,21 +47,18 @@ class ExcludedEntryAdapter(
|
|||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
private val binding: ItemExcludedDirBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
inner class ViewHolder(private val binding: ItemExcludedDirBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
binding.root.layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
binding.root.layoutParams =
|
||||
RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
fun bind(path: String) {
|
||||
binding.excludedPath.text = path
|
||||
binding.excludedPath.requestLayout()
|
||||
binding.excludedClear.setOnClickListener {
|
||||
onClear(path)
|
||||
}
|
||||
binding.excludedClear.setOnClickListener { onClear(path) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* BlacklistViewModel.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -30,29 +29,28 @@ import kotlinx.coroutines.withContext
|
|||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal
|
||||
* of paths. Use [Factory] to instantiate this.
|
||||
* TODO: Unify with MusicViewModel
|
||||
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal of
|
||||
* paths. Use [Factory] to instantiate this. TODO: Unify with MusicViewModel
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
|
||||
private val mPaths = MutableLiveData(mutableListOf<String>())
|
||||
val paths: LiveData<MutableList<String>> get() = mPaths
|
||||
val paths: LiveData<MutableList<String>>
|
||||
get() = mPaths
|
||||
|
||||
private var dbPaths = listOf<String>()
|
||||
|
||||
/**
|
||||
* Check if changes have been made to the ViewModel's paths.
|
||||
*/
|
||||
val isModified: Boolean get() = dbPaths != paths.value
|
||||
/** Check if changes have been made to the ViewModel's paths. */
|
||||
val isModified: Boolean
|
||||
get() = dbPaths != paths.value
|
||||
|
||||
init {
|
||||
loadDatabasePaths()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a path to this ViewModel. It will not write the path to the database unless
|
||||
* [save] is called.
|
||||
* Add a path to this ViewModel. It will not write the path to the database unless [save] is
|
||||
* called.
|
||||
*/
|
||||
fun addPath(path: String) {
|
||||
if (!mPaths.value!!.contains(path)) {
|
||||
|
@ -70,9 +68,7 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
|
|||
mPaths.value = mPaths.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the pending paths to the database. [onDone] will be called on completion.
|
||||
*/
|
||||
/** Save the pending paths to the database. [onDone] will be called on completion. */
|
||||
fun save(onDone: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
|
@ -80,24 +76,18 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
|
|||
dbPaths = mPaths.value!!
|
||||
onDone()
|
||||
this@ExcludedViewModel.logD(
|
||||
"Path save completed successfully in ${System.currentTimeMillis() - start}ms"
|
||||
)
|
||||
"Path save completed successfully in ${System.currentTimeMillis() - start}ms")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the paths stored in the database to this ViewModel, will erase any pending changes.
|
||||
*/
|
||||
/** Load the paths stored in the database to this ViewModel, will erase any pending changes. */
|
||||
private fun loadDatabasePaths() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
dbPaths = excludedDatabase.readPaths()
|
||||
withContext(Dispatchers.Main) {
|
||||
mPaths.value = dbPaths.toMutableList()
|
||||
}
|
||||
withContext(Dispatchers.Main) { mPaths.value = dbPaths.toMutableList() }
|
||||
this@ExcludedViewModel.logD(
|
||||
"Path load completed successfully in ${System.currentTimeMillis() - start}ms"
|
||||
)
|
||||
"Path load completed successfully in ${System.currentTimeMillis() - start}ms")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* CompactPlaybackView.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -35,14 +34,13 @@ import org.oxycblt.auxio.util.inflater
|
|||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* A view displaying the playback state in a compact manner. This is only meant to be used
|
||||
* by [PlaybackLayout].
|
||||
* A view displaying the playback state in a compact manner. This is only meant to be used by
|
||||
* [PlaybackLayout].
|
||||
*/
|
||||
class PlaybackBarView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
class PlaybackBarView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
|
||||
ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
private val binding = ViewPlaybackBarBinding.inflate(context.inflater, this, true)
|
||||
|
||||
init {
|
||||
|
@ -52,35 +50,29 @@ class PlaybackBarView @JvmOverloads constructor(
|
|||
// we use colorSecondary instead of colorSurfaceVariant. This is because
|
||||
// colorSurfaceVariant is used with the assumption that the view that is using it is
|
||||
// not elevated and is therefore not colored. This view is elevated.
|
||||
binding.playbackProgressBar.trackColor = MaterialColors.compositeARGBWithAlpha(
|
||||
context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt()
|
||||
)
|
||||
binding.playbackProgressBar.trackColor =
|
||||
MaterialColors.compositeARGBWithAlpha(
|
||||
context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt())
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
// Since we swipe up this view, we need to make sure it does not collide with
|
||||
// any gesture events. So, apply the system gesture insets if present and then
|
||||
// only default to the system bar insets when there are no other options.
|
||||
val gesturePadding = when {
|
||||
val gesturePadding =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
insets.getInsets(WindowInsets.Type.systemGestures()).bottom
|
||||
}
|
||||
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
|
||||
@Suppress("DEPRECATION")
|
||||
insets.systemGestureInsets.bottom
|
||||
@Suppress("DEPRECATION") insets.systemGestureInsets.bottom
|
||||
}
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
updatePadding(
|
||||
bottom =
|
||||
if (gesturePadding != 0)
|
||||
gesturePadding
|
||||
else
|
||||
insets.systemBarInsetsCompat.bottom
|
||||
)
|
||||
if (gesturePadding != 0) gesturePadding else insets.systemBarInsetsCompat.bottom)
|
||||
|
||||
return insets
|
||||
}
|
||||
|
@ -91,23 +83,15 @@ class PlaybackBarView @JvmOverloads constructor(
|
|||
viewLifecycleOwner: LifecycleOwner
|
||||
) {
|
||||
setOnLongClickListener {
|
||||
playbackModel.song.value?.let { song ->
|
||||
detailModel.navToItem(song)
|
||||
}
|
||||
playbackModel.song.value?.let { song -> detailModel.navToItem(song) }
|
||||
true
|
||||
}
|
||||
|
||||
binding.playbackSkipPrev?.setOnClickListener {
|
||||
playbackModel.skipPrev()
|
||||
}
|
||||
binding.playbackSkipPrev?.setOnClickListener { playbackModel.skipPrev() }
|
||||
|
||||
binding.playbackPlayPause.setOnClickListener {
|
||||
playbackModel.invertPlayingStatus()
|
||||
}
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlayingStatus() }
|
||||
|
||||
binding.playbackSkipNext?.setOnClickListener {
|
||||
playbackModel.skipNext()
|
||||
}
|
||||
binding.playbackSkipNext?.setOnClickListener { playbackModel.skipNext() }
|
||||
|
||||
binding.playbackPlayPause.isActivated = playbackModel.isPlaying.value!!
|
||||
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback
|
||||
|
||||
import android.content.Context
|
||||
|
@ -14,20 +31,19 @@ import org.oxycblt.auxio.util.getDrawableSafe
|
|||
/**
|
||||
* An [AppCompatImageButton] designed for the buttons used in the playback display.
|
||||
*
|
||||
* Auxio's playback buttons have never followed the typical 24dp icon size that all
|
||||
* other UI elements do, mostly because those icons just look bad at that size with
|
||||
* all the gobs of whitespace surrounding them. So, this view resizes the icons to a
|
||||
* fixed 32dp in a way that doesn't require a whole new icon set.
|
||||
* Auxio's playback buttons have never followed the typical 24dp icon size that all other UI
|
||||
* elements do, mostly because those icons just look bad at that size with all the gobs of
|
||||
* whitespace surrounding them. So, this view resizes the icons to a fixed 32dp in a way that
|
||||
* doesn't require a whole new icon set.
|
||||
*
|
||||
* This view also enables use of an "indicator", which is a dot that can denote when a
|
||||
* button is active. This is useful for the shuffle/loop buttons, as at times highlighting
|
||||
* them is not enough to differentiate them.
|
||||
* This view also enables use of an "indicator", which is a dot that can denote when a button is
|
||||
* active. This is useful for the shuffle/loop buttons, as at times highlighting them is not enough
|
||||
* to differentiate them.
|
||||
*/
|
||||
class PlaybackButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : AppCompatImageButton(context, attrs, defStyleAttr) {
|
||||
class PlaybackButton
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AppCompatImageButton(context, attrs, defStyleAttr) {
|
||||
private val iconSize = context.getDimenSizeSafe(R.dimen.size_playback_icon)
|
||||
private val centerMatrix = Matrix()
|
||||
private val matrixSrc = RectF()
|
||||
|
@ -55,21 +71,26 @@ class PlaybackButton @JvmOverloads constructor(
|
|||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
imageMatrix = centerMatrix.apply {
|
||||
imageMatrix =
|
||||
centerMatrix.apply {
|
||||
reset()
|
||||
drawable?.let { drawable ->
|
||||
// Android is too good to allow us to set a fixed image size, so we instead need
|
||||
// to define a matrix to scale an image directly.
|
||||
|
||||
// First scale the icon up to the desired size.
|
||||
matrixSrc.set(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
|
||||
matrixSrc.set(
|
||||
0f,
|
||||
0f,
|
||||
drawable.intrinsicWidth.toFloat(),
|
||||
drawable.intrinsicHeight.toFloat())
|
||||
matrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat())
|
||||
centerMatrix.setRectToRect(matrixSrc, matrixDst, Matrix.ScaleToFit.CENTER)
|
||||
|
||||
// Then actually center it into the icon, which the previous call does not actually do.
|
||||
// Then actually center it into the icon, which the previous call does not
|
||||
// actually do.
|
||||
centerMatrix.postTranslate(
|
||||
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f
|
||||
)
|
||||
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -78,8 +99,7 @@ class PlaybackButton @JvmOverloads constructor(
|
|||
val y = ((measuredHeight - iconSize) / 2) + iconSize
|
||||
|
||||
indicatorDrawable.bounds.set(
|
||||
x, y, x + indicatorDrawable.intrinsicWidth, y + indicatorDrawable.intrinsicHeight
|
||||
)
|
||||
x, y, x + indicatorDrawable.intrinsicWidth, y + indicatorDrawable.intrinsicHeight)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -38,8 +37,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
/**
|
||||
* A [Fragment] that displays more information about the song, along with more media controls.
|
||||
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
|
||||
* @author OxygenCobalt
|
||||
* TODO: Handle RTL correctly in the playback buttons
|
||||
* @author OxygenCobalt TODO: Handle RTL correctly in the playback buttons
|
||||
*/
|
||||
class PlaybackFragment : Fragment() {
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
@ -66,18 +64,13 @@ class PlaybackFragment : Fragment() {
|
|||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
|
||||
binding.root.updatePadding(
|
||||
top = bars.top,
|
||||
bottom = bars.bottom
|
||||
)
|
||||
binding.root.updatePadding(top = bars.top, bottom = bars.bottom)
|
||||
|
||||
insets
|
||||
}
|
||||
|
||||
binding.playbackToolbar.apply {
|
||||
setNavigationOnClickListener {
|
||||
navigateUp()
|
||||
}
|
||||
setNavigationOnClickListener { navigateUp() }
|
||||
|
||||
setOnMenuItemClickListener { item ->
|
||||
if (item.itemId == R.id.action_queue) {
|
||||
|
@ -96,9 +89,7 @@ class PlaybackFragment : Fragment() {
|
|||
binding.playbackSeekBar.onConfirmListener = playbackModel::setPosition
|
||||
|
||||
// Abuse the play/pause FAB (see style definition for more info)
|
||||
binding.playbackPlayPause.post {
|
||||
binding.playbackPlayPause.stateListAnimator = null
|
||||
}
|
||||
binding.playbackPlayPause.post { binding.playbackPlayPause.stateListAnimator = null }
|
||||
|
||||
// --- VIEWMODEL SETUP --
|
||||
|
||||
|
@ -114,8 +105,8 @@ class PlaybackFragment : Fragment() {
|
|||
}
|
||||
|
||||
playbackModel.parent.observe(viewLifecycleOwner) { parent ->
|
||||
binding.playbackToolbar.subtitle = parent?.resolvedName
|
||||
?: getString(R.string.lbl_all_songs)
|
||||
binding.playbackToolbar.subtitle =
|
||||
parent?.resolvedName ?: getString(R.string.lbl_all_songs)
|
||||
}
|
||||
|
||||
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
|
||||
|
@ -123,7 +114,8 @@ class PlaybackFragment : Fragment() {
|
|||
}
|
||||
|
||||
playbackModel.loopMode.observe(viewLifecycleOwner) { loopMode ->
|
||||
val resId = when (loopMode) {
|
||||
val resId =
|
||||
when (loopMode) {
|
||||
LoopMode.NONE, null -> R.drawable.ic_loop
|
||||
LoopMode.ALL -> R.drawable.ic_loop_on
|
||||
LoopMode.TRACK -> R.drawable.ic_loop_one
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback
|
||||
|
||||
import android.content.Context
|
||||
|
@ -19,6 +36,9 @@ import androidx.core.view.isInvisible
|
|||
import androidx.customview.widget.ViewDragHelper
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
|
@ -32,31 +52,30 @@ import org.oxycblt.auxio.util.pxOfDp
|
|||
import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.stateList
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* This layout handles pretty much every aspect of the playback UI flow, notably the playback
|
||||
* bar and it's ability to slide up into the playback view. It's a blend of Hai Zhang's
|
||||
* This layout handles pretty much every aspect of the playback UI flow, notably the playback bar
|
||||
* and it's ability to slide up into the playback view. It's a blend of Hai Zhang's
|
||||
* PersistentBarLayout and Umano's SlidingUpPanelLayout, albeit heavily minified to remove
|
||||
* extraneous use cases and updated to support the latest SDK level and androidx tools.
|
||||
*
|
||||
* **Note:** If you want to adapt this layout into your own app. Good luck. This layout has been
|
||||
* reduced to Auxio's use case in particular and is really hard to understand since it has a ton
|
||||
* of state and view magic. I tried my best to document it, but it's probably not the most friendly
|
||||
* or extendable. You have been warned.
|
||||
* reduced to Auxio's use case in particular and is really hard to understand since it has a ton of
|
||||
* state and view magic. I tried my best to document it, but it's probably not the most friendly or
|
||||
* extendable. You have been warned.
|
||||
*
|
||||
* @author OxygenCobalt (With help from Umano and Hai Zhang)
|
||||
* TODO: Find a better way to handle PlaybackFragment in general (navigation, creation)
|
||||
* @author OxygenCobalt (With help from Umano and Hai Zhang) TODO: Find a better way to handle
|
||||
* PlaybackFragment in general (navigation, creation)
|
||||
*/
|
||||
class PlaybackLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0
|
||||
) : ViewGroup(context, attrs, defStyle) {
|
||||
class PlaybackLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
|
||||
ViewGroup(context, attrs, defStyle) {
|
||||
private enum class PanelState {
|
||||
EXPANDED, COLLAPSED, HIDDEN, DRAGGING
|
||||
EXPANDED,
|
||||
COLLAPSED,
|
||||
HIDDEN,
|
||||
DRAGGING
|
||||
}
|
||||
|
||||
private lateinit var contentView: View
|
||||
|
@ -67,20 +86,19 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
private val playbackContainerBg: MaterialShapeDrawable
|
||||
private val playbackFragment = PlaybackFragment()
|
||||
|
||||
/**
|
||||
* The drag helper that animates and dispatches drag events to the panels.
|
||||
*/
|
||||
private val dragHelper = ViewDragHelper.create(this, DragHelperCallback()).apply {
|
||||
/** The drag helper that animates and dispatches drag events to the panels. */
|
||||
private val dragHelper =
|
||||
ViewDragHelper.create(this, DragHelperCallback()).apply {
|
||||
minVelocity = MIN_FLING_VEL * resources.displayMetrics.density
|
||||
}
|
||||
|
||||
/**
|
||||
* The current window insets.
|
||||
* Important since this layout must play a long with Auxio's edge-to-edge functionality.
|
||||
* The current window insets. Important since this layout must play a long with Auxio's
|
||||
* edge-to-edge functionality.
|
||||
*/
|
||||
private var lastInsets: WindowInsets? = null
|
||||
|
||||
/** The current panel state. Can be [PanelState.DRAGGING]*/
|
||||
/** The current panel state. Can be [PanelState.DRAGGING] */
|
||||
private var panelState = INIT_PANEL_STATE
|
||||
|
||||
/** The last panel state before a drag event began. */
|
||||
|
@ -90,10 +108,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
private var panelRange = 0
|
||||
|
||||
/**
|
||||
* The relative offset of this panel as a percentage of [panelRange].
|
||||
* A value of 1 means a fully expanded panel.
|
||||
* A value of 0 means a collapsed panel.
|
||||
* A value below 0 means a hidden panel.
|
||||
* The relative offset of this panel as a percentage of [panelRange]. A value of 1 means a fully
|
||||
* expanded panel. A value of 0 means a collapsed panel. A value below 0 means a hidden panel.
|
||||
*/
|
||||
private var panelOffset = 0f
|
||||
|
||||
|
@ -105,39 +121,44 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
|
||||
|
||||
/** See [isDragging] */
|
||||
private val dragStateField = ViewDragHelper::class.java.getDeclaredField("mDragState").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
private val dragStateField =
|
||||
ViewDragHelper::class.java.getDeclaredField("mDragState").apply { isAccessible = true }
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
|
||||
// Set up our playback views. Doing this allows us to abstract away the implementation
|
||||
// of these views from the user of this layout [MainFragment].
|
||||
playbackContainerView = FrameLayout(context).apply {
|
||||
playbackContainerView =
|
||||
FrameLayout(context).apply {
|
||||
id = R.id.playback_container
|
||||
|
||||
isClickable = true
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
|
||||
playbackContainerBg = MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
playbackContainerBg =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
|
||||
elevation = context.pxOfDp(elevationNormal).toFloat()
|
||||
}
|
||||
|
||||
// The way we fade out the elevation overlay is not by actually reducing the elevation
|
||||
// The way we fade out the elevation overlay is not by actually reducing the
|
||||
// elevation
|
||||
// but by fading out the background drawable itself. To be safe, we apply this
|
||||
// background drawable to a layer list with another colorSurface shape drawable, just
|
||||
// background drawable to a layer list with another colorSurface shape drawable,
|
||||
// just
|
||||
// in case weird things happen if background drawable is completely transparent.
|
||||
background = (context.getDrawableSafe(R.drawable.ui_panel_bg) as LayerDrawable).apply {
|
||||
background =
|
||||
(context.getDrawableSafe(R.drawable.ui_panel_bg) as LayerDrawable).apply {
|
||||
setDrawableByLayerId(R.id.panel_overlay, playbackContainerBg)
|
||||
}
|
||||
|
||||
disableDropShadowCompat()
|
||||
}
|
||||
|
||||
playbackBarView = PlaybackBarView(context).apply {
|
||||
playbackBarView =
|
||||
PlaybackBarView(context).apply {
|
||||
id = R.id.playback_bar
|
||||
|
||||
playbackContainerView.addView(this)
|
||||
|
@ -156,7 +177,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
playbackPanelView = FrameLayout(context).apply {
|
||||
playbackPanelView =
|
||||
FrameLayout(context).apply {
|
||||
playbackContainerView.addView(this)
|
||||
|
||||
(layoutParams as FrameLayout.LayoutParams).apply {
|
||||
|
@ -171,7 +193,9 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
// since we don't want to stack fragments but we can't ensure that this view doesn't
|
||||
// already have a fragment attached.
|
||||
try {
|
||||
(context as AppCompatActivity).supportFragmentManager.beginTransaction()
|
||||
(context as AppCompatActivity)
|
||||
.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.playback_panel, playbackFragment)
|
||||
.commit()
|
||||
} catch (e: Exception) {
|
||||
|
@ -185,8 +209,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
// / --- CONTROL METHODS ---
|
||||
|
||||
/**
|
||||
* Update the song that this layout is showing. This will be reflected in the compact view
|
||||
* at the bottom of the screen.
|
||||
* Update the song that this layout is showing. This will be reflected in the compact view at
|
||||
* the bottom of the screen.
|
||||
*/
|
||||
fun setup(
|
||||
playbackModel: PlaybackViewModel,
|
||||
|
@ -195,9 +219,7 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
) {
|
||||
setSong(playbackModel.song.value)
|
||||
|
||||
playbackModel.song.observe(viewLifecycleOwner) { song ->
|
||||
setSong(song)
|
||||
}
|
||||
playbackModel.song.observe(viewLifecycleOwner) { song -> setSong(song) }
|
||||
|
||||
playbackBarView.setup(playbackModel, detailModel, viewLifecycleOwner)
|
||||
}
|
||||
|
@ -243,7 +265,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
if (!isLaidOut) {
|
||||
// Not laid out, just apply the state and let the measure + layout steps apply it for us.
|
||||
// Not laid out, just apply the state and let the measure + layout steps apply it for
|
||||
// us.
|
||||
setPanelStateInternal(state)
|
||||
} else {
|
||||
// We are laid out. In this case we actually animate to our desired target.
|
||||
|
@ -293,7 +316,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
if (!isLaidOut) {
|
||||
// This is our first layout, so make sure we know what offset we should work with
|
||||
// before we measure our content
|
||||
panelOffset = when (panelState) {
|
||||
panelOffset =
|
||||
when (panelState) {
|
||||
PanelState.EXPANDED -> 1.0f
|
||||
PanelState.HIDDEN -> computePanelOffset(measuredHeight)
|
||||
else -> 0f
|
||||
|
@ -315,9 +339,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
// Note that these views will always be a fixed MATCH_PARENT. This is intentional,
|
||||
// as it reduces the logic we have to deal with regarding WRAP_CONTENT views.
|
||||
val contentWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
|
||||
val contentHeightSpec = MeasureSpec.makeMeasureSpec(
|
||||
measuredHeight - barHeightAdjusted, MeasureSpec.EXACTLY
|
||||
)
|
||||
val contentHeightSpec =
|
||||
MeasureSpec.makeMeasureSpec(measuredHeight - barHeightAdjusted, MeasureSpec.EXACTLY)
|
||||
|
||||
contentView.measure(contentWidthSpec, contentHeightSpec)
|
||||
}
|
||||
|
@ -330,8 +353,7 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
0,
|
||||
panelTop,
|
||||
playbackContainerView.measuredWidth,
|
||||
playbackContainerView.measuredHeight + panelTop
|
||||
)
|
||||
playbackContainerView.measuredHeight + panelTop)
|
||||
|
||||
layoutContent()
|
||||
}
|
||||
|
@ -352,9 +374,7 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
canvas.clipRect(tRect)
|
||||
}
|
||||
|
||||
return super.drawChild(canvas, child, drawingTime).also {
|
||||
canvas.restoreToCount(save)
|
||||
}
|
||||
return super.drawChild(canvas, child, drawingTime).also { canvas.restoreToCount(save) }
|
||||
}
|
||||
|
||||
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
|
@ -369,8 +389,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Apply window insets to the content views in this layouts. This is done separately as at
|
||||
* times we want to re-inset the content views but not re-inset the bar view.
|
||||
* Apply window insets to the content views in this layouts. This is done separately as at times
|
||||
* we want to re-inset the content views but not re-inset the bar view.
|
||||
*/
|
||||
private fun applyContentWindowInsets() {
|
||||
val insets = lastInsets
|
||||
|
@ -379,9 +399,7 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust window insets to line up with the panel
|
||||
*/
|
||||
/** Adjust window insets to line up with the panel */
|
||||
private fun adjustInsets(insets: WindowInsets): WindowInsets {
|
||||
// We kind to do a reverse-measure to figure out how we should inset this view.
|
||||
// Find how much space is lost by the panel and then combine that with the
|
||||
|
@ -390,11 +408,11 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight
|
||||
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)
|
||||
return insets.replaceSystemBarInsetsCompat(
|
||||
bars.left, bars.top, bars.right, adjustedBottomInset
|
||||
)
|
||||
bars.left, bars.top, bars.right, adjustedBottomInset)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable = Bundle().apply {
|
||||
override fun onSaveInstanceState(): Parcelable =
|
||||
Bundle().apply {
|
||||
putParcelable("superState", super.onSaveInstanceState())
|
||||
putSerializable(
|
||||
KEY_PANEL_STATE,
|
||||
|
@ -402,8 +420,7 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
panelState
|
||||
} else {
|
||||
lastIdlePanelState
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable) {
|
||||
|
@ -425,7 +442,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
|
||||
return if (!canSlide) {
|
||||
super.onTouchEvent(ev)
|
||||
} else try {
|
||||
} else
|
||||
try {
|
||||
dragHelper.processTouchEvent(ev)
|
||||
true
|
||||
} catch (ex: Exception) {
|
||||
|
@ -454,10 +472,10 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val pointerUnder = playbackContainerView.isUnder(ev.x.toInt(), ev.y.toInt())
|
||||
val motionUnder = playbackContainerView.isUnder(initMotionX.toInt(), initMotionY.toInt())
|
||||
val motionUnder =
|
||||
playbackContainerView.isUnder(initMotionX.toInt(), initMotionY.toInt())
|
||||
|
||||
if (!(pointerUnder || motionUnder) || ady > dragSlop && adx > ady) {
|
||||
// Pointer has moved beyond our control, do not intercept this event
|
||||
|
@ -465,7 +483,6 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP ->
|
||||
if (dragHelper.isDragging) {
|
||||
// Stopped pressing while we were dragging, let the drag helper handle it
|
||||
|
@ -504,7 +521,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
get() {
|
||||
// We can't grab the drag state outside of a callback, but that's stupid and I don't
|
||||
// want to vendor ViewDragHelper so I just do reflection instead.
|
||||
val state = try {
|
||||
val state =
|
||||
try {
|
||||
dragStateField.get(this)
|
||||
} catch (e: Exception) {
|
||||
ViewDragHelper.STATE_IDLE
|
||||
|
@ -524,9 +542,9 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Do the nice view animations that occur whenever we slide up the playback panel.
|
||||
* The way I transition is largely inspired by Android 12's notification panel, with the
|
||||
* compact view fading out completely before the panel view fades in.
|
||||
* Do the nice view animations that occur whenever we slide up the playback panel. The way I
|
||||
* transition is largely inspired by Android 12's notification panel, with the compact view
|
||||
* fading out completely before the panel view fades in.
|
||||
*/
|
||||
private fun updatePanelTransition() {
|
||||
val ratio = max(panelOffset, 0f)
|
||||
|
@ -566,8 +584,7 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
params.leftMargin,
|
||||
(bars.top * halfOutRatio).toInt(),
|
||||
params.rightMargin,
|
||||
params.bottomMargin
|
||||
)
|
||||
params.bottomMargin)
|
||||
|
||||
// Poke the layout only when we changed something
|
||||
if (params.topMargin != oldTopMargin) {
|
||||
|
@ -592,9 +609,9 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
private fun smoothSlideTo(offset: Float) {
|
||||
logD("Smooth sliding to $offset")
|
||||
|
||||
val okay = dragHelper.smoothSlideViewTo(
|
||||
playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset)
|
||||
)
|
||||
val okay =
|
||||
dragHelper.smoothSlideViewTo(
|
||||
playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset))
|
||||
|
||||
if (okay) {
|
||||
postInvalidateOnAnimation()
|
||||
|
@ -621,7 +638,6 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
setPanelStateInternal(PanelState.HIDDEN)
|
||||
playbackContainerView.visibility = INVISIBLE
|
||||
}
|
||||
|
||||
else -> setPanelStateInternal(PanelState.EXPANDED)
|
||||
}
|
||||
}
|
||||
|
@ -658,7 +674,8 @@ class PlaybackLayout @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
|
||||
val newOffset = when {
|
||||
val newOffset =
|
||||
when {
|
||||
// Swipe Up -> Expand to top
|
||||
yvel < 0 -> 1f
|
||||
// Swipe down -> Collapse to bottom
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackSeeker.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -33,20 +32,22 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.stateList
|
||||
|
||||
/**
|
||||
* A custom view that bundles together a seekbar with a current duration and a total duration.
|
||||
* The sub-views are specifically laid out so that the seekbar has an adequate touch height while
|
||||
* still not having gobs of whitespace everywhere.
|
||||
* TODO: Add smooth seeking [i.e seeking in sub-second values]
|
||||
* A custom view that bundles together a seekbar with a current duration and a total duration. The
|
||||
* sub-views are specifically laid out so that the seekbar has an adequate touch height while still
|
||||
* not having gobs of whitespace everywhere. TODO: Add smooth seeking [i.e seeking in sub-second
|
||||
* values]
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
class PlaybackSeekBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleRes: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleRes), Slider.OnChangeListener, Slider.OnSliderTouchListener {
|
||||
class PlaybackSeekBar
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
|
||||
ConstraintLayout(context, attrs, defStyleRes),
|
||||
Slider.OnChangeListener,
|
||||
Slider.OnSliderTouchListener {
|
||||
private val binding = ViewSeekBarBinding.inflate(context.inflater, this, true)
|
||||
private val isSeeking: Boolean get() = binding.playbackDurationCurrent.isActivated
|
||||
private val isSeeking: Boolean
|
||||
get() = binding.playbackDurationCurrent.isActivated
|
||||
|
||||
var onConfirmListener: ((Long) -> Unit)? = null
|
||||
|
||||
|
@ -55,9 +56,10 @@ class PlaybackSeekBar @JvmOverloads constructor(
|
|||
binding.seekBar.addOnSliderTouchListener(this)
|
||||
|
||||
// Override the inactive color so that it lines up with the playback progress bar.
|
||||
binding.seekBar.trackInactiveTintList = MaterialColors.compositeARGBWithAlpha(
|
||||
context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt()
|
||||
).stateList
|
||||
binding.seekBar.trackInactiveTintList =
|
||||
MaterialColors.compositeARGBWithAlpha(
|
||||
context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt())
|
||||
.stateList
|
||||
}
|
||||
|
||||
fun setProgress(seconds: Long) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackViewModel.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -41,12 +40,13 @@ import org.oxycblt.auxio.util.logE
|
|||
/**
|
||||
* The ViewModel that provides a UI frontend for [PlaybackStateManager].
|
||||
*
|
||||
* **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this provides
|
||||
* an interface that properly sanitizes input and abstracts functions unlike the master class.**
|
||||
* **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this
|
||||
* provides an interface that properly sanitizes input and abstracts functions unlike the master
|
||||
* class.**
|
||||
* @author OxygenCobalt
|
||||
*
|
||||
* TODO: Completely rework this module to support the new music rescan system,
|
||||
* proper android auto and external exposing, and so on.
|
||||
* TODO: Completely rework this module to support the new music rescan system, proper android auto
|
||||
* and external exposing, and so on.
|
||||
* - DO NOT REWRITE IT! THAT'S BAD AND WILL PROBABLY RE-INTRODUCE A TON OF BUGS.
|
||||
*/
|
||||
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
||||
|
@ -68,21 +68,29 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
private var mIntentUri: Uri? = null
|
||||
|
||||
/** The current song. */
|
||||
val song: LiveData<Song?> get() = mSong
|
||||
val song: LiveData<Song?>
|
||||
get() = mSong
|
||||
/** The current model that is being played from, such as an [Album] or [Artist] */
|
||||
val parent: LiveData<MusicParent?> get() = mParent
|
||||
val parent: LiveData<MusicParent?>
|
||||
get() = mParent
|
||||
|
||||
val isPlaying: LiveData<Boolean> get() = mIsPlaying
|
||||
val isShuffling: LiveData<Boolean> get() = mIsShuffling
|
||||
val isPlaying: LiveData<Boolean>
|
||||
get() = mIsPlaying
|
||||
val isShuffling: LiveData<Boolean>
|
||||
get() = mIsShuffling
|
||||
/** The current repeat mode, see [LoopMode] for more information */
|
||||
val loopMode: LiveData<LoopMode> get() = mLoopMode
|
||||
val loopMode: LiveData<LoopMode>
|
||||
get() = mLoopMode
|
||||
/** The current playback position, in seconds */
|
||||
val position: LiveData<Long> get() = mPosition
|
||||
val position: LiveData<Long>
|
||||
get() = mPosition
|
||||
|
||||
/** The queue, without the previous items. */
|
||||
val nextUp: LiveData<List<Song>> get() = mNextUp
|
||||
val nextUp: LiveData<List<Song>>
|
||||
get() = mNextUp
|
||||
/** The current [PlaybackMode] that also determines the queue */
|
||||
val playbackMode: LiveData<PlaybackMode> get() = mMode
|
||||
val playbackMode: LiveData<PlaybackMode>
|
||||
get() = mMode
|
||||
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
@ -102,8 +110,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
// --- PLAYING FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Play a [song] with the [mode] specified. [mode] will default to the preferred song
|
||||
* playback mode of the user if not specified.
|
||||
* Play a [song] with the [mode] specified. [mode] will default to the preferred song playback
|
||||
* mode of the user if not specified.
|
||||
*/
|
||||
fun playSong(song: Song, mode: PlaybackMode = settingsManager.songPlaybackMode) {
|
||||
playbackManager.playSong(song, mode)
|
||||
|
@ -152,8 +160,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
}
|
||||
|
||||
/**
|
||||
* Play using a file [uri].
|
||||
* This will not play instantly during the initial startup sequence.
|
||||
* Play using a file [uri]. This will not play instantly during the initial startup sequence.
|
||||
*/
|
||||
fun playWithUri(uri: Uri, context: Context) {
|
||||
// Check if everything is already running to run the URI play
|
||||
|
@ -166,54 +173,41 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play with a file URI.
|
||||
* This is called after [playWithUri] once its deemed safe to do so.
|
||||
*/
|
||||
/** Play with a file URI. This is called after [playWithUri] once its deemed safe to do so. */
|
||||
private fun playWithUriInternal(uri: Uri, context: Context) {
|
||||
logD("Playing with uri $uri")
|
||||
|
||||
val musicStore = MusicStore.maybeGetInstance() ?: return
|
||||
musicStore.findSongForUri(uri, context.contentResolver)?.let { song ->
|
||||
playSong(song)
|
||||
}
|
||||
musicStore.findSongForUri(uri, context.contentResolver)?.let { song -> playSong(song) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle all songs
|
||||
*/
|
||||
/** Shuffle all songs */
|
||||
fun shuffleAll() {
|
||||
playbackManager.shuffleAll()
|
||||
}
|
||||
|
||||
// --- POSITION FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Update the position and push it to [PlaybackStateManager]
|
||||
*/
|
||||
/** Update the position and push it to [PlaybackStateManager] */
|
||||
fun setPosition(progress: Long) {
|
||||
playbackManager.seekTo((progress * 1000))
|
||||
}
|
||||
|
||||
// --- QUEUE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Skip to the next song.
|
||||
*/
|
||||
/** Skip to the next song. */
|
||||
fun skipNext() {
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip to the previous song.
|
||||
*/
|
||||
/** Skip to the previous song. */
|
||||
fun skipPrev() {
|
||||
playbackManager.prev()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a queue item using it's recyclerview adapter index. If the indices are valid,
|
||||
* [apply] is called just before the change is committed so that the adapter can be updated.
|
||||
* Remove a queue item using it's recyclerview adapter index. If the indices are valid, [apply]
|
||||
* is called just before the change is committed so that the adapter can be updated.
|
||||
*/
|
||||
fun removeQueueDataItem(adapterIndex: Int, apply: () -> Unit) {
|
||||
val index = adapterIndex + (playbackManager.queue.size - mNextUp.value!!.size)
|
||||
|
@ -223,8 +217,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
}
|
||||
}
|
||||
/**
|
||||
* Move queue items using their recyclerview adapter indices. If the indices are valid,
|
||||
* [apply] is called just before the change is committed so that the adapter can be updated.
|
||||
* Move queue items using their recyclerview adapter indices. If the indices are valid, [apply]
|
||||
* is called just before the change is committed so that the adapter can be updated.
|
||||
*/
|
||||
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int, apply: () -> Unit): Boolean {
|
||||
val delta = (playbackManager.queue.size - mNextUp.value!!.size)
|
||||
|
@ -239,53 +233,39 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Song] to the top of the queue.
|
||||
*/
|
||||
/** Add a [Song] to the top of the queue. */
|
||||
fun playNext(song: Song) {
|
||||
playbackManager.playNext(song)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an [Album] to the top of the queue.
|
||||
*/
|
||||
/** Add an [Album] to the top of the queue. */
|
||||
fun playNext(album: Album) {
|
||||
playbackManager.playNext(settingsManager.detailAlbumSort.sortAlbum(album))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Song] to the end of the queue.
|
||||
*/
|
||||
/** Add a [Song] to the end of the queue. */
|
||||
fun addToQueue(song: Song) {
|
||||
playbackManager.addToQueue(song)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an [Album] to the end of the queue.
|
||||
*/
|
||||
/** Add an [Album] to the end of the queue. */
|
||||
fun addToQueue(album: Album) {
|
||||
playbackManager.addToQueue(settingsManager.detailAlbumSort.sortAlbum(album))
|
||||
}
|
||||
|
||||
// --- STATUS FUNCTIONS ---
|
||||
// --- STATUS FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Flip the playing status, e.g from playing to paused
|
||||
*/
|
||||
/** Flip the playing status, e.g from playing to paused */
|
||||
fun invertPlayingStatus() {
|
||||
playbackManager.setPlaying(!playbackManager.isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the shuffle status, e.g from on to off. Will keep song by default.
|
||||
*/
|
||||
/** Flip the shuffle status, e.g from on to off. Will keep song by default. */
|
||||
fun invertShuffleStatus() {
|
||||
playbackManager.setShuffling(!playbackManager.isShuffling, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the loop status, e.g from off to loop once
|
||||
*/
|
||||
/** Increment the loop status, e.g from off to loop once */
|
||||
fun incrementLoopStatus() {
|
||||
playbackManager.setLoopMode(playbackManager.loopMode.increment())
|
||||
}
|
||||
|
@ -293,8 +273,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
// --- SAVE/RESTORE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Force save the current [PlaybackStateManager] state to the database.
|
||||
* Called by SettingsListFragment.
|
||||
* Force save the current [PlaybackStateManager] state to the database. Called by
|
||||
* SettingsListFragment.
|
||||
*/
|
||||
fun savePlaybackState(context: Context, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
|
@ -320,15 +300,13 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
playbackManager.markRestored()
|
||||
} else if (!playbackManager.isRestored) {
|
||||
// Otherwise just restore
|
||||
viewModelScope.launch {
|
||||
playbackManager.restoreFromDatabase(context)
|
||||
}
|
||||
viewModelScope.launch { playbackManager.restoreFromDatabase(context) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to restore the current playback state from an existing
|
||||
* [PlaybackStateManager] instance.
|
||||
* Attempt to restore the current playback state from an existing [PlaybackStateManager]
|
||||
* instance.
|
||||
*/
|
||||
private fun restorePlaybackState() {
|
||||
logD("Attempting to restore playback state")
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* QueueAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -47,9 +46,8 @@ import org.oxycblt.auxio.util.stateList
|
|||
* @param touchHelper The [ItemTouchHelper] ***containing*** [QueueDragCallback] to be used
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class QueueAdapter(
|
||||
private val touchHelper: ItemTouchHelper
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
class QueueAdapter(private val touchHelper: ItemTouchHelper) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private var data = mutableListOf<Item>()
|
||||
private var listDiffer = AsyncListDiffer(this, DiffCallback())
|
||||
|
||||
|
@ -60,16 +58,14 @@ class QueueAdapter(
|
|||
is Song -> QUEUE_SONG_ITEM_TYPE
|
||||
is Header -> HeaderViewHolder.ITEM_TYPE
|
||||
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
|
||||
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
QUEUE_SONG_ITEM_TYPE -> QueueSongViewHolder(
|
||||
ItemQueueSongBinding.inflate(parent.context.inflater)
|
||||
)
|
||||
QUEUE_SONG_ITEM_TYPE ->
|
||||
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
|
||||
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
|
||||
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
|
||||
else -> error("Invalid ViewHolder item type $viewType")
|
||||
|
@ -86,8 +82,8 @@ class QueueAdapter(
|
|||
}
|
||||
|
||||
/**
|
||||
* Submit data using [AsyncListDiffer].
|
||||
* **Only use this if you have no idea what changes occurred to the data**
|
||||
* Submit data using [AsyncListDiffer]. **Only use this if you have no idea what changes
|
||||
* occurred to the data**
|
||||
*/
|
||||
fun submitList(newData: MutableList<Item>) {
|
||||
if (data != newData) {
|
||||
|
@ -96,37 +92,30 @@ class QueueAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move Items.
|
||||
* Used since [submitList] will cause QueueAdapter to freak out.
|
||||
*/
|
||||
/** Move Items. Used since [submitList] will cause QueueAdapter to freak out. */
|
||||
fun moveItems(adapterFrom: Int, adapterTo: Int) {
|
||||
data.add(adapterTo, data.removeAt(adapterFrom))
|
||||
notifyItemMoved(adapterFrom, adapterTo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item.
|
||||
* Used since [submitList] will cause QueueAdapter to freak out.
|
||||
*/
|
||||
/** Remove an item. Used since [submitList] will cause QueueAdapter to freak out. */
|
||||
fun removeItem(adapterIndex: Int) {
|
||||
data.removeAt(adapterIndex)
|
||||
notifyItemRemoved(adapterIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic ViewHolder for a queue song
|
||||
*/
|
||||
/** Generic ViewHolder for a queue song */
|
||||
inner class QueueSongViewHolder(
|
||||
private val binding: ItemQueueSongBinding,
|
||||
) : BaseViewHolder<Song>(binding) {
|
||||
val bodyView: View get() = binding.body
|
||||
val backgroundView: View get() = binding.background
|
||||
val bodyView: View
|
||||
get() = binding.body
|
||||
val backgroundView: View
|
||||
get() = binding.background
|
||||
|
||||
init {
|
||||
binding.body.background = MaterialShapeDrawable.createWithElevationOverlay(
|
||||
binding.root.context
|
||||
).apply {
|
||||
binding.body.background =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
||||
fillColor = (binding.body.background as ColorDrawable).color.stateList
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* QueueDragCallback.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -24,19 +23,19 @@ import androidx.core.view.isInvisible
|
|||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.getDimenSafe
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sign
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.getDimenSafe
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A highly customized [ItemTouchHelper.Callback] that handles the queue system while basically
|
||||
* rebuilding most the "Material-y" aspects of an editable list because Google's implementations
|
||||
* are hot garbage. This shouldn't have *too many* UI bugs. I hope.
|
||||
* rebuilding most the "Material-y" aspects of an editable list because Google's implementations are
|
||||
* hot garbage. This shouldn't have *too many* UI bugs. I hope.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() {
|
||||
|
@ -59,17 +58,14 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
|
|||
): Int {
|
||||
// Fix to make QueueFragment scroll slower when an item is scrolled out of bounds.
|
||||
// Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe
|
||||
val standardSpeed = super.interpolateOutOfBoundsScroll(
|
||||
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll
|
||||
)
|
||||
val standardSpeed =
|
||||
super.interpolateOutOfBoundsScroll(
|
||||
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll)
|
||||
|
||||
val clampedAbsVelocity = max(
|
||||
val clampedAbsVelocity =
|
||||
max(
|
||||
MINIMUM_INITIAL_DRAG_VELOCITY,
|
||||
min(
|
||||
abs(standardSpeed),
|
||||
MAXIMUM_INITIAL_DRAG_VELOCITY
|
||||
)
|
||||
)
|
||||
min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY))
|
||||
|
||||
return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt()
|
||||
}
|
||||
|
@ -94,12 +90,12 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
|
|||
|
||||
val bg = holder.bodyView.background as MaterialShapeDrawable
|
||||
val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_small)
|
||||
holder.itemView.animate()
|
||||
holder
|
||||
.itemView
|
||||
.animate()
|
||||
.translationZ(elevation)
|
||||
.setDuration(100)
|
||||
.setUpdateListener {
|
||||
bg.elevation = holder.itemView.translationZ
|
||||
}
|
||||
.setUpdateListener { bg.elevation = holder.itemView.translationZ }
|
||||
.setInterpolator(AccelerateDecelerateInterpolator())
|
||||
.start()
|
||||
|
||||
|
@ -132,7 +128,9 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
|
|||
logD("Dropping queue item")
|
||||
|
||||
val bg = holder.bodyView.background as MaterialShapeDrawable
|
||||
holder.itemView.animate()
|
||||
holder
|
||||
.itemView
|
||||
.animate()
|
||||
.translationZ(0.0f)
|
||||
.setDuration(100)
|
||||
.setUpdateListener { bg.elevation = holder.itemView.translationZ }
|
||||
|
@ -154,9 +152,7 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
|
|||
val from = viewHolder.bindingAdapterPosition
|
||||
val to = target.bindingAdapterPosition
|
||||
|
||||
return playbackModel.moveQueueDataItems(from, to) {
|
||||
queueAdapter.moveItems(from, to)
|
||||
}
|
||||
return playbackModel.moveQueueDataItems(from, to) { queueAdapter.moveItems(from, to) }
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
|
@ -168,8 +164,8 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
|
|||
override fun isLongPressDragEnabled(): Boolean = false
|
||||
|
||||
/**
|
||||
* Add the queue adapter to this callback.
|
||||
* Done because there's a circular dependency between the two objects
|
||||
* Add the queue adapter to this callback. Done because there's a circular dependency between
|
||||
* the two objects
|
||||
*/
|
||||
fun addQueueAdapter(adapter: QueueAdapter) {
|
||||
queueAdapter = adapter
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* QueueFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -54,9 +53,7 @@ class QueueFragment : Fragment() {
|
|||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
binding.queueToolbar.setNavigationOnClickListener {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
binding.queueRecycler.apply {
|
||||
setHasFixedSize(true)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* LoopMode.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -23,11 +22,11 @@ package org.oxycblt.auxio.playback.state
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
enum class LoopMode {
|
||||
NONE, ALL, TRACK;
|
||||
NONE,
|
||||
ALL,
|
||||
TRACK;
|
||||
|
||||
/**
|
||||
* Increment the LoopMode, e.g from [NONE] to [ALL]
|
||||
*/
|
||||
/** Increment the LoopMode, e.g from [NONE] to [ALL] */
|
||||
fun increment(): LoopMode {
|
||||
return when (this) {
|
||||
NONE -> ALL
|
||||
|
@ -53,15 +52,12 @@ enum class LoopMode {
|
|||
private const val INT_ALL = 0xA101
|
||||
private const val INT_TRACK = 0xA102
|
||||
|
||||
/**
|
||||
* Convert an int [constant] into a LoopMode, or null if it isn't valid.
|
||||
*/
|
||||
/** Convert an int [constant] into a LoopMode, or null if it isn't valid. */
|
||||
fun fromInt(constant: Int): LoopMode? {
|
||||
return when (constant) {
|
||||
INT_NONE -> NONE
|
||||
INT_ALL -> ALL
|
||||
INT_TRACK -> TRACK
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackMode.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackStateDatabase.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -32,8 +31,8 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.queryAll
|
||||
|
||||
/**
|
||||
* A SQLite database for managing the persistent playback state and queue.
|
||||
* Yes. I know Room exists. But that would needlessly bloat my app and has crippling bugs.
|
||||
* A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists.
|
||||
* But that would needlessly bloat my app and has crippling bugs.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class PlaybackStateDatabase(context: Context) :
|
||||
|
@ -58,9 +57,7 @@ class PlaybackStateDatabase(context: Context) :
|
|||
|
||||
// --- DATABASE CONSTRUCTION FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Create a table for this database.
|
||||
*/
|
||||
/** Create a table for this database. */
|
||||
private fun createTable(database: SQLiteDatabase, tableName: String) {
|
||||
val command = StringBuilder()
|
||||
command.append("CREATE TABLE IF NOT EXISTS $tableName(")
|
||||
|
@ -74,11 +71,10 @@ class PlaybackStateDatabase(context: Context) :
|
|||
database.execSQL(command.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a [StateColumns] table
|
||||
*/
|
||||
/** Construct a [StateColumns] table */
|
||||
private fun constructStateTable(command: StringBuilder): StringBuilder {
|
||||
command.append("${StateColumns.COLUMN_ID} LONG PRIMARY KEY,")
|
||||
command
|
||||
.append("${StateColumns.COLUMN_ID} LONG PRIMARY KEY,")
|
||||
.append("${StateColumns.COLUMN_SONG_HASH} LONG,")
|
||||
.append("${StateColumns.COLUMN_POSITION} LONG NOT NULL,")
|
||||
.append("${StateColumns.COLUMN_PARENT_HASH} LONG,")
|
||||
|
@ -90,11 +86,10 @@ class PlaybackStateDatabase(context: Context) :
|
|||
return command
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a [QueueColumns] table
|
||||
*/
|
||||
/** Construct a [QueueColumns] table */
|
||||
private fun constructQueueTable(command: StringBuilder): StringBuilder {
|
||||
command.append("${QueueColumns.ID} LONG PRIMARY KEY,")
|
||||
command
|
||||
.append("${QueueColumns.ID} LONG PRIMARY KEY,")
|
||||
.append("${QueueColumns.SONG_HASH} INTEGER NOT NULL,")
|
||||
.append("${QueueColumns.ALBUM_HASH} INTEGER NOT NULL)")
|
||||
|
||||
|
@ -126,13 +121,13 @@ class PlaybackStateDatabase(context: Context) :
|
|||
|
||||
cursor.moveToFirst()
|
||||
|
||||
val song = cursor.getLongOrNull(songIndex)?.let { id ->
|
||||
musicStore.songs.find { it.id == id }
|
||||
}
|
||||
val song =
|
||||
cursor.getLongOrNull(songIndex)?.let { id -> musicStore.songs.find { it.id == id } }
|
||||
|
||||
val mode = PlaybackMode.fromInt(cursor.getInt(modeIndex)) ?: PlaybackMode.ALL_SONGS
|
||||
|
||||
val parent = cursor.getLongOrNull(parentIndex)?.let { id ->
|
||||
val parent =
|
||||
cursor.getLongOrNull(parentIndex)?.let { id ->
|
||||
when (mode) {
|
||||
PlaybackMode.IN_GENRE -> musicStore.genres.find { it.id == id }
|
||||
PlaybackMode.IN_ARTIST -> musicStore.artists.find { it.id == id }
|
||||
|
@ -141,7 +136,8 @@ class PlaybackStateDatabase(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
state = SavedState(
|
||||
state =
|
||||
SavedState(
|
||||
song = song,
|
||||
position = cursor.getLong(posIndex),
|
||||
parent = parent,
|
||||
|
@ -157,9 +153,7 @@ class PlaybackStateDatabase(context: Context) :
|
|||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the previously written [SavedState] and write a new one.
|
||||
*/
|
||||
/** Clear the previously written [SavedState] and write a new one. */
|
||||
fun writeState(state: SavedState) {
|
||||
assertBackgroundThread()
|
||||
|
||||
|
@ -168,7 +162,8 @@ class PlaybackStateDatabase(context: Context) :
|
|||
|
||||
this@PlaybackStateDatabase.logD("Wiped state db")
|
||||
|
||||
val stateData = ContentValues(10).apply {
|
||||
val stateData =
|
||||
ContentValues(10).apply {
|
||||
put(StateColumns.COLUMN_ID, 0)
|
||||
put(StateColumns.COLUMN_SONG_HASH, state.song?.id)
|
||||
put(StateColumns.COLUMN_POSITION, state.position)
|
||||
|
@ -202,9 +197,7 @@ class PlaybackStateDatabase(context: Context) :
|
|||
|
||||
while (cursor.moveToNext()) {
|
||||
musicStore.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))
|
||||
?.let { song ->
|
||||
queue.add(song)
|
||||
}
|
||||
?.let { song -> queue.add(song) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -213,16 +206,12 @@ class PlaybackStateDatabase(context: Context) :
|
|||
return queue
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a queue to the database.
|
||||
*/
|
||||
/** Write a queue to the database. */
|
||||
fun writeQueue(queue: MutableList<Song>) {
|
||||
assertBackgroundThread()
|
||||
|
||||
val database = writableDatabase
|
||||
database.transaction {
|
||||
delete(TABLE_NAME_QUEUE, null, null)
|
||||
}
|
||||
database.transaction { delete(TABLE_NAME_QUEUE, null, null) }
|
||||
|
||||
logD("Wiped queue db")
|
||||
|
||||
|
@ -243,7 +232,8 @@ class PlaybackStateDatabase(context: Context) :
|
|||
val song = queue[i]
|
||||
i++
|
||||
|
||||
val itemData = ContentValues(4).apply {
|
||||
val itemData =
|
||||
ContentValues(4).apply {
|
||||
put(QueueColumns.ID, idStart + i)
|
||||
put(QueueColumns.SONG_HASH, song.id)
|
||||
put(QueueColumns.ALBUM_HASH, song.album.id)
|
||||
|
@ -295,12 +285,9 @@ class PlaybackStateDatabase(context: Context) :
|
|||
const val TABLE_NAME_STATE = "playback_state_table"
|
||||
const val TABLE_NAME_QUEUE = "queue_table"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PlaybackStateDatabase? = null
|
||||
@Volatile private var INSTANCE: PlaybackStateDatabase? = null
|
||||
|
||||
/**
|
||||
* Get/Instantiate the single instance of [PlaybackStateDatabase].
|
||||
*/
|
||||
/** Get/Instantiate the single instance of [PlaybackStateDatabase]. */
|
||||
fun getInstance(context: Context): PlaybackStateDatabase {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackStateManager.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -35,8 +34,10 @@ import org.oxycblt.auxio.util.logE
|
|||
* Master class (and possible god object) for the playback state.
|
||||
*
|
||||
* This should ***NOT*** be used outside of the playback module.
|
||||
* - If you want to use the playback state in the UI, use [org.oxycblt.auxio.playback.PlaybackViewModel] as it can withstand volatile UIs.
|
||||
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use [org.oxycblt.auxio.playback.system.PlaybackService].
|
||||
* - If you want to use the playback state in the UI, use
|
||||
* [org.oxycblt.auxio.playback.PlaybackViewModel] as it can withstand volatile UIs.
|
||||
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use
|
||||
* [org.oxycblt.auxio.playback.system.PlaybackService].
|
||||
*
|
||||
* All access should be done with [PlaybackStateManager.getInstance].
|
||||
* @author OxygenCobalt
|
||||
|
@ -90,27 +91,38 @@ class PlaybackStateManager private constructor() {
|
|||
private var mHasPlayed = false
|
||||
|
||||
/** The currently playing song. Null if there isn't one */
|
||||
val song: Song? get() = mSong
|
||||
val song: Song?
|
||||
get() = mSong
|
||||
/** The parent the queue is based on, null if all_songs */
|
||||
val parent: MusicParent? get() = mParent
|
||||
val parent: MusicParent?
|
||||
get() = mParent
|
||||
/** The current playback progress */
|
||||
val position: Long get() = mPosition
|
||||
val position: Long
|
||||
get() = mPosition
|
||||
/** The current queue determined by [parent] and [playbackMode] */
|
||||
val queue: List<Song> get() = mQueue
|
||||
val queue: List<Song>
|
||||
get() = mQueue
|
||||
/** The current position in the queue */
|
||||
val index: Int get() = mIndex
|
||||
val index: Int
|
||||
get() = mIndex
|
||||
/** The current [PlaybackMode] */
|
||||
val playbackMode: PlaybackMode get() = mPlaybackMode
|
||||
val playbackMode: PlaybackMode
|
||||
get() = mPlaybackMode
|
||||
/** Whether playback is paused or not */
|
||||
val isPlaying: Boolean get() = mIsPlaying
|
||||
val isPlaying: Boolean
|
||||
get() = mIsPlaying
|
||||
/** Whether the queue is shuffled */
|
||||
val isShuffling: Boolean get() = mIsShuffling
|
||||
val isShuffling: Boolean
|
||||
get() = mIsShuffling
|
||||
/** The current [LoopMode] */
|
||||
val loopMode: LoopMode get() = mLoopMode
|
||||
val loopMode: LoopMode
|
||||
get() = mLoopMode
|
||||
/** Whether this instance has already been restored */
|
||||
val isRestored: Boolean get() = mIsRestored
|
||||
val isRestored: Boolean
|
||||
get() = mIsRestored
|
||||
/** Whether playback has begun in this instance during **PlaybackService's Lifecycle.** */
|
||||
val hasPlayed: Boolean get() = mHasPlayed
|
||||
val hasPlayed: Boolean
|
||||
get() = mHasPlayed
|
||||
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
|
@ -119,16 +131,14 @@ class PlaybackStateManager private constructor() {
|
|||
private val callbacks = mutableListOf<Callback>()
|
||||
|
||||
/**
|
||||
* Add a [PlaybackStateManager.Callback] to this instance.
|
||||
* Make sure to remove the callback with [removeCallback] when done.
|
||||
* Add a [PlaybackStateManager.Callback] to this instance. Make sure to remove the callback with
|
||||
* [removeCallback] when done.
|
||||
*/
|
||||
fun addCallback(callback: Callback) {
|
||||
callbacks.add(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a [PlaybackStateManager.Callback] bound to this instance.
|
||||
*/
|
||||
/** Remove a [PlaybackStateManager.Callback] bound to this instance. */
|
||||
fun removeCallback(callback: Callback) {
|
||||
callbacks.remove(callback)
|
||||
}
|
||||
|
@ -149,17 +159,14 @@ class PlaybackStateManager private constructor() {
|
|||
mParent = null
|
||||
mQueue = musicStore.songs.toMutableList()
|
||||
}
|
||||
|
||||
PlaybackMode.IN_GENRE -> {
|
||||
mParent = song.genre
|
||||
mQueue = song.genre.songs.toMutableList()
|
||||
}
|
||||
|
||||
PlaybackMode.IN_ARTIST -> {
|
||||
mParent = song.album.artist
|
||||
mQueue = song.album.artist.songs.toMutableList()
|
||||
}
|
||||
|
||||
PlaybackMode.IN_ALBUM -> {
|
||||
mParent = song.album
|
||||
mQueue = song.album.songs.toMutableList()
|
||||
|
@ -188,12 +195,10 @@ class PlaybackStateManager private constructor() {
|
|||
mQueue = parent.songs.toMutableList()
|
||||
mPlaybackMode = PlaybackMode.IN_ALBUM
|
||||
}
|
||||
|
||||
is Artist -> {
|
||||
mQueue = parent.songs.toMutableList()
|
||||
mPlaybackMode = PlaybackMode.IN_ARTIST
|
||||
}
|
||||
|
||||
is Genre -> {
|
||||
mQueue = parent.songs.toMutableList()
|
||||
mPlaybackMode = PlaybackMode.IN_GENRE
|
||||
|
@ -204,9 +209,7 @@ class PlaybackStateManager private constructor() {
|
|||
updatePlayback(mQueue[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle all songs.
|
||||
*/
|
||||
/** Shuffle all songs. */
|
||||
fun shuffleAll() {
|
||||
val musicStore = MusicStore.maybeGetInstance() ?: return
|
||||
|
||||
|
@ -218,9 +221,7 @@ class PlaybackStateManager private constructor() {
|
|||
updatePlayback(mQueue[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the playback to a new [song], doing all the required logic.
|
||||
*/
|
||||
/** Update the playback to a new [song], doing all the required logic. */
|
||||
private fun updatePlayback(song: Song, shouldPlay: Boolean = true) {
|
||||
mSong = song
|
||||
mPosition = 0
|
||||
|
@ -229,9 +230,7 @@ class PlaybackStateManager private constructor() {
|
|||
|
||||
// --- QUEUE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Go to the next song, along with doing all the checks that entails.
|
||||
*/
|
||||
/** Go to the next song, along with doing all the checks that entails. */
|
||||
fun next() {
|
||||
// Increment the index, if it cannot be incremented any further, then
|
||||
// loop and pause/resume playback depending on the setting
|
||||
|
@ -246,9 +245,7 @@ class PlaybackStateManager private constructor() {
|
|||
pushQueueUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the previous song, doing any checks that are needed.
|
||||
*/
|
||||
/** Go to the previous song, doing any checks that are needed. */
|
||||
fun prev() {
|
||||
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
|
||||
if (settingsManager.rewindWithPrev && mPosition >= REWIND_THRESHOLD) {
|
||||
|
@ -266,9 +263,7 @@ class PlaybackStateManager private constructor() {
|
|||
|
||||
// --- QUEUE EDITING FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Remove a queue item at [index]. Will ignore invalid indexes.
|
||||
*/
|
||||
/** Remove a queue item at [index]. Will ignore invalid indexes. */
|
||||
fun removeQueueItem(index: Int): Boolean {
|
||||
if (index > mQueue.size || index < 0) {
|
||||
logE("Index is out of bounds, did not remove queue item")
|
||||
|
@ -281,9 +276,7 @@ class PlaybackStateManager private constructor() {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a queue item at [from] to a position at [to]. Will ignore invalid indexes.
|
||||
*/
|
||||
/** Move a queue item at [from] to a position at [to]. Will ignore invalid indexes. */
|
||||
fun moveQueueItems(from: Int, to: Int): Boolean {
|
||||
if (from > mQueue.size || from < 0 || to > mQueue.size || to < 0) {
|
||||
logE("Indices were out of bounds, did not move queue item")
|
||||
|
@ -296,9 +289,7 @@ class PlaybackStateManager private constructor() {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [song] to the top of the queue.
|
||||
*/
|
||||
/** Add a [song] to the top of the queue. */
|
||||
fun playNext(song: Song) {
|
||||
if (mQueue.isEmpty()) {
|
||||
return
|
||||
|
@ -308,9 +299,7 @@ class PlaybackStateManager private constructor() {
|
|||
pushQueueUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a list of [songs] to the top of the queue.
|
||||
*/
|
||||
/** Add a list of [songs] to the top of the queue. */
|
||||
fun playNext(songs: List<Song>) {
|
||||
if (mQueue.isEmpty()) {
|
||||
return
|
||||
|
@ -320,29 +309,21 @@ class PlaybackStateManager private constructor() {
|
|||
pushQueueUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [song] to the end of the queue.
|
||||
*/
|
||||
/** Add a [song] to the end of the queue. */
|
||||
fun addToQueue(song: Song) {
|
||||
mQueue.add(song)
|
||||
pushQueueUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a list of [songs] to the end of the queue.
|
||||
*/
|
||||
/** Add a list of [songs] to the end of the queue. */
|
||||
fun addToQueue(songs: List<Song>) {
|
||||
mQueue.addAll(songs)
|
||||
pushQueueUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force any callbacks to receive a queue update.
|
||||
*/
|
||||
/** Force any callbacks to receive a queue update. */
|
||||
private fun pushQueueUpdate() {
|
||||
callbacks.forEach {
|
||||
it.onQueueUpdate(mQueue, mIndex)
|
||||
}
|
||||
callbacks.forEach { it.onQueueUpdate(mQueue, mIndex) }
|
||||
}
|
||||
|
||||
// --- SHUFFLE FUNCTIONS ---
|
||||
|
@ -392,7 +373,8 @@ class PlaybackStateManager private constructor() {
|
|||
val musicStore = MusicStore.maybeGetInstance() ?: return
|
||||
val lastSong = mSong
|
||||
|
||||
mQueue = when (mPlaybackMode) {
|
||||
mQueue =
|
||||
when (mPlaybackMode) {
|
||||
PlaybackMode.ALL_SONGS ->
|
||||
settingsManager.libSongSort.sortSongs(musicStore.songs).toMutableList()
|
||||
PlaybackMode.IN_ALBUM ->
|
||||
|
@ -412,9 +394,7 @@ class PlaybackStateManager private constructor() {
|
|||
|
||||
// --- STATE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Set whether this instance is currently [playing].
|
||||
*/
|
||||
/** Set whether this instance is currently [playing]. */
|
||||
fun setPlaying(playing: Boolean) {
|
||||
if (mIsPlaying != playing) {
|
||||
if (playing) {
|
||||
|
@ -449,39 +429,29 @@ class PlaybackStateManager private constructor() {
|
|||
callbacks.forEach { it.onSeek(position) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind to the beginning of a song.
|
||||
*/
|
||||
/** Rewind to the beginning of a song. */
|
||||
fun rewind() {
|
||||
seekTo(0)
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop playback around to the beginning.
|
||||
*/
|
||||
/** Loop playback around to the beginning. */
|
||||
fun loop() {
|
||||
seekTo(0)
|
||||
setPlaying(!settingsManager.pauseOnLoop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the [LoopMode] to [mode].
|
||||
*/
|
||||
/** Set the [LoopMode] to [mode]. */
|
||||
fun setLoopMode(mode: LoopMode) {
|
||||
mLoopMode = mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whether this instance has played or not
|
||||
*/
|
||||
/** Mark whether this instance has played or not */
|
||||
fun setHasPlayed(hasPlayed: Boolean) {
|
||||
mHasPlayed = hasPlayed
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this instance as restored.
|
||||
*/
|
||||
/** Mark this instance as restored. */
|
||||
fun markRestored() {
|
||||
mIsRestored = true
|
||||
}
|
||||
|
@ -503,16 +473,19 @@ class PlaybackStateManager private constructor() {
|
|||
|
||||
database.writeState(
|
||||
PlaybackStateDatabase.SavedState(
|
||||
mSong, mPosition, mParent, mIndex,
|
||||
mPlaybackMode, mIsShuffling, mLoopMode,
|
||||
)
|
||||
)
|
||||
mSong,
|
||||
mPosition,
|
||||
mParent,
|
||||
mIndex,
|
||||
mPlaybackMode,
|
||||
mIsShuffling,
|
||||
mLoopMode,
|
||||
))
|
||||
|
||||
database.writeQueue(mQueue)
|
||||
|
||||
this@PlaybackStateManager.logD(
|
||||
"State save completed successfully in ${System.currentTimeMillis() - start}ms"
|
||||
)
|
||||
"State save completed successfully in ${System.currentTimeMillis() - start}ms")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -549,9 +522,7 @@ class PlaybackStateManager private constructor() {
|
|||
markRestored()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack a [playbackState] into this instance.
|
||||
*/
|
||||
/** Unpack a [playbackState] into this instance. */
|
||||
private fun unpackFromPlaybackState(playbackState: PlaybackStateDatabase.SavedState) {
|
||||
// Turn the simplified information from PlaybackState into usable data.
|
||||
|
||||
|
@ -573,15 +544,14 @@ class PlaybackStateManager private constructor() {
|
|||
pushQueueUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Do a sanity check to make sure the parent was not lost in the restore process.
|
||||
*/
|
||||
/** Do a sanity check to make sure the parent was not lost in the restore process. */
|
||||
private fun doParentSanityCheck() {
|
||||
// Check if the parent was lost while in the DB.
|
||||
if (mSong != null && mParent == null && mPlaybackMode != PlaybackMode.ALL_SONGS) {
|
||||
logD("Parent lost, attempting restore")
|
||||
|
||||
mParent = when (mPlaybackMode) {
|
||||
mParent =
|
||||
when (mPlaybackMode) {
|
||||
PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album
|
||||
PlaybackMode.IN_ARTIST -> mQueue.firstOrNull()?.album?.artist
|
||||
PlaybackMode.IN_GENRE -> mQueue.firstOrNull()?.genre
|
||||
|
@ -590,9 +560,7 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do a sanity check to make sure that the index lines up with the current song.
|
||||
*/
|
||||
/** Do a sanity check to make sure that the index lines up with the current song. */
|
||||
private fun doIndexSanityCheck() {
|
||||
// Be careful with how we handle the queue since a possible index de-sync
|
||||
// could easily result in an OOB crash.
|
||||
|
@ -639,9 +607,8 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* The interface for receiving updates from [PlaybackStateManager].
|
||||
* Add the callback to [PlaybackStateManager] using [addCallback],
|
||||
* remove them on destruction with [removeCallback].
|
||||
* The interface for receiving updates from [PlaybackStateManager]. Add the callback to
|
||||
* [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
|
||||
*/
|
||||
interface Callback {
|
||||
fun onSongUpdate(song: Song?) {}
|
||||
|
@ -658,12 +625,9 @@ class PlaybackStateManager private constructor() {
|
|||
companion object {
|
||||
private const val REWIND_THRESHOLD = 3000L
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PlaybackStateManager? = null
|
||||
@Volatile private var INSTANCE: PlaybackStateManager? = null
|
||||
|
||||
/**
|
||||
* Get/Instantiate the single instance of [PlaybackStateManager].
|
||||
*/
|
||||
/** Get/Instantiate the single instance of [PlaybackStateManager]. */
|
||||
fun getInstance(): PlaybackStateManager {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AudioReactor.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -28,22 +27,20 @@ import androidx.media.AudioManagerCompat
|
|||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import kotlin.math.pow
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.util.getSystemServiceSafe
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Manages the current volume and playback state across ReplayGain and AudioFocus events.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class AudioReactor(
|
||||
context: Context,
|
||||
private val callback: (Float) -> Unit
|
||||
) : AudioManager.OnAudioFocusChangeListener, SettingsManager.Callback {
|
||||
class AudioReactor(context: Context, private val callback: (Float) -> Unit) :
|
||||
AudioManager.OnAudioFocusChangeListener, SettingsManager.Callback {
|
||||
private data class Gain(val track: Float, val album: Float)
|
||||
private data class GainTag(val key: String, val value: Float)
|
||||
|
||||
|
@ -51,14 +48,14 @@ class AudioReactor(
|
|||
private val settingsManager = SettingsManager.getInstance()
|
||||
private val audioManager = context.getSystemServiceSafe(AudioManager::class)
|
||||
|
||||
private val request = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
|
||||
private val request =
|
||||
AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
|
||||
.setWillPauseWhenDucked(false)
|
||||
.setAudioAttributes(
|
||||
AudioAttributesCompat.Builder()
|
||||
.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
|
||||
.setUsage(AudioAttributesCompat.USAGE_MEDIA)
|
||||
.build()
|
||||
)
|
||||
.build())
|
||||
.setOnAudioFocusChangeListener(this)
|
||||
.build()
|
||||
|
||||
|
@ -82,19 +79,16 @@ class AudioReactor(
|
|||
settingsManager.addCallback(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the android system for audio focus
|
||||
*/
|
||||
/** Request the android system for audio focus */
|
||||
fun requestFocus() {
|
||||
logD("Requesting audio focus")
|
||||
AudioManagerCompat.requestAudioFocus(audioManager, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the rough volume adjustment for [Metadata] with ReplayGain tags.
|
||||
* This is based off Vanilla Music's implementation.
|
||||
* TODO: Add ReplayGain pre-amp
|
||||
* TODO: Add positive ReplayGain values
|
||||
* Updates the rough volume adjustment for [Metadata] with ReplayGain tags. This is based off
|
||||
* Vanilla Music's implementation. TODO: Add ReplayGain pre-amp TODO: Add positive ReplayGain
|
||||
* values
|
||||
*/
|
||||
fun applyReplayGain(metadata: Metadata?) {
|
||||
if (metadata == null) {
|
||||
|
@ -104,7 +98,8 @@ class AudioReactor(
|
|||
}
|
||||
|
||||
// ReplayGain is configurable, so determine what to do based off of the mode.
|
||||
val useAlbumGain: (Gain) -> Boolean = when (settingsManager.replayGainMode) {
|
||||
val useAlbumGain: (Gain) -> Boolean =
|
||||
when (settingsManager.replayGainMode) {
|
||||
ReplayGainMode.OFF -> {
|
||||
logD("ReplayGain is off")
|
||||
volume = 1f
|
||||
|
@ -113,21 +108,14 @@ class AudioReactor(
|
|||
|
||||
// User wants track gain to be preferred. Default to album gain only if there
|
||||
// is no track gain.
|
||||
ReplayGainMode.TRACK ->
|
||||
{ gain ->
|
||||
gain.track == 0f
|
||||
}
|
||||
ReplayGainMode.TRACK -> { gain -> gain.track == 0f }
|
||||
|
||||
// User wants album gain to be preferred. Default to track gain only if there
|
||||
// is no album gain.
|
||||
ReplayGainMode.ALBUM ->
|
||||
{ gain ->
|
||||
gain.album != 0f
|
||||
}
|
||||
ReplayGainMode.ALBUM -> { gain -> gain.album != 0f }
|
||||
|
||||
// User wants album gain to be used when in an album, track gain otherwise.
|
||||
ReplayGainMode.DYNAMIC ->
|
||||
{ _ ->
|
||||
ReplayGainMode.DYNAMIC -> { _ ->
|
||||
playbackManager.parent is Album &&
|
||||
playbackManager.song?.album == playbackManager.parent
|
||||
}
|
||||
|
@ -135,7 +123,8 @@ class AudioReactor(
|
|||
|
||||
val gain = parseReplayGain(metadata)
|
||||
|
||||
val adjust = if (gain != null) {
|
||||
val adjust =
|
||||
if (gain != null) {
|
||||
if (useAlbumGain(gain)) {
|
||||
logD("Using album gain")
|
||||
gain.album
|
||||
|
@ -171,12 +160,10 @@ class AudioReactor(
|
|||
key = entry.description?.uppercase()
|
||||
value = entry.value
|
||||
}
|
||||
|
||||
is VorbisComment -> {
|
||||
key = entry.key
|
||||
value = entry.value
|
||||
}
|
||||
|
||||
else -> continue
|
||||
}
|
||||
|
||||
|
@ -226,9 +213,7 @@ class AudioReactor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandon the current focus request and any callbacks
|
||||
*/
|
||||
/** Abandon the current focus request and any callbacks */
|
||||
fun release() {
|
||||
AudioManagerCompat.abandonAudioFocusRequest(audioManager, request)
|
||||
settingsManager.removeCallback(this)
|
||||
|
@ -302,11 +287,6 @@ class AudioReactor(
|
|||
const val R128_TRACK = "R128_TRACK_GAIN"
|
||||
const val R128_ALBUM = "R128_ALBUM_GAIN"
|
||||
|
||||
val REPLAY_GAIN_TAGS = arrayOf(
|
||||
RG_TRACK,
|
||||
RG_ALBUM,
|
||||
R128_ALBUM,
|
||||
R128_TRACK
|
||||
)
|
||||
val REPLAY_GAIN_TAGS = arrayOf(RG_TRACK, RG_ALBUM, R128_ALBUM, R128_TRACK)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.system
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
|
@ -8,14 +25,14 @@ import androidx.core.content.ContextCompat
|
|||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* Some apps like to party like it's 2011 and just blindly query for the ACTION_MEDIA_BUTTON
|
||||
* intent to determine the media apps on a system. *Auxio does not expose this.* Auxio exposes
|
||||
* a MediaSession that an app should control instead through the much better MediaController API.
|
||||
* But who cares about that, we need to make sure the 3% of barely functioning TouchWiz devices
|
||||
* running KitKat don't break! To prevent Auxio from not showing up at all in these apps, we
|
||||
* declare a BroadcastReceiver that deliberately handles this event. This also means that Auxio
|
||||
* will start without warning if you use the media buttons while the app exists, because I guess
|
||||
* we just have to deal with this.
|
||||
* Some apps like to party like it's 2011 and just blindly query for the ACTION_MEDIA_BUTTON intent
|
||||
* to determine the media apps on a system. *Auxio does not expose this.* Auxio exposes a
|
||||
* MediaSession that an app should control instead through the much better MediaController API. But
|
||||
* who cares about that, we need to make sure the 3% of barely functioning TouchWiz devices running
|
||||
* KitKat don't break! To prevent Auxio from not showing up at all in these apps, we declare a
|
||||
* BroadcastReceiver that deliberately handles this event. This also means that Auxio will start
|
||||
* without warning if you use the media buttons while the app exists, because I guess we just have
|
||||
* to deal with this.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class MediaButtonReceiver : BroadcastReceiver() {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackNotification.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -37,15 +36,14 @@ import org.oxycblt.auxio.util.newBroadcastIntent
|
|||
import org.oxycblt.auxio.util.newMainIntent
|
||||
|
||||
/**
|
||||
* The unified notification for [PlaybackService]. This is not self-sufficient, updates have
|
||||
* to be delivered manually.
|
||||
* The unified notification for [PlaybackService]. This is not self-sufficient, updates have to be
|
||||
* delivered manually.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
class PlaybackNotification private constructor(
|
||||
private val context: Context,
|
||||
mediaToken: MediaSessionCompat.Token
|
||||
) : NotificationCompat.Builder(context, CHANNEL_ID) {
|
||||
class PlaybackNotification
|
||||
private constructor(private val context: Context, mediaToken: MediaSessionCompat.Token) :
|
||||
NotificationCompat.Builder(context, CHANNEL_ID) {
|
||||
init {
|
||||
setSmallIcon(R.drawable.ic_auxio)
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
|
@ -61,11 +59,7 @@ class PlaybackNotification private constructor(
|
|||
addAction(buildAction(context, PlaybackService.ACTION_SKIP_NEXT, R.drawable.ic_skip_next))
|
||||
addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_exit))
|
||||
|
||||
setStyle(
|
||||
MediaStyle()
|
||||
.setMediaSession(mediaToken)
|
||||
.setShowActionsInCompactView(1, 2, 3)
|
||||
)
|
||||
setStyle(MediaStyle().setMediaSession(mediaToken).setShowActionsInCompactView(1, 2, 3))
|
||||
|
||||
// Don't connect to PlaybackStateManager here. This is because it's possible for this
|
||||
// notification to not be updated by PlaybackStateManager before PlaybackService pushes
|
||||
|
@ -96,30 +90,22 @@ class PlaybackNotification private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the playing icon on the notification
|
||||
*/
|
||||
/** Set the playing icon on the notification */
|
||||
fun setPlaying(isPlaying: Boolean) {
|
||||
mActions[2] = buildPlayPauseAction(context, isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the first action to reflect the [loopMode] given.
|
||||
*/
|
||||
/** Update the first action to reflect the [loopMode] given. */
|
||||
fun setLoop(loopMode: LoopMode) {
|
||||
mActions[0] = buildLoopAction(context, loopMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the first action to reflect whether the queue is shuffled or not
|
||||
*/
|
||||
/** Update the first action to reflect whether the queue is shuffled or not */
|
||||
fun setShuffle(isShuffling: Boolean) {
|
||||
mActions[0] = buildShuffleAction(context, isShuffling)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the current [parent] to the header of the notification.
|
||||
*/
|
||||
/** Apply the current [parent] to the header of the notification. */
|
||||
fun setParent(parent: MusicParent?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
|
||||
|
@ -138,11 +124,9 @@ class PlaybackNotification private constructor(
|
|||
return buildAction(context, PlaybackService.ACTION_PLAY_PAUSE, drawableRes)
|
||||
}
|
||||
|
||||
private fun buildLoopAction(
|
||||
context: Context,
|
||||
loopMode: LoopMode
|
||||
): NotificationCompat.Action {
|
||||
val drawableRes = when (loopMode) {
|
||||
private fun buildLoopAction(context: Context, loopMode: LoopMode): NotificationCompat.Action {
|
||||
val drawableRes =
|
||||
when (loopMode) {
|
||||
LoopMode.NONE -> R.drawable.ic_remote_loop_off
|
||||
LoopMode.ALL -> R.drawable.ic_loop
|
||||
LoopMode.TRACK -> R.drawable.ic_loop_one
|
||||
|
@ -155,7 +139,8 @@ class PlaybackNotification private constructor(
|
|||
context: Context,
|
||||
isShuffled: Boolean
|
||||
): NotificationCompat.Action {
|
||||
val drawableRes = if (isShuffled) R.drawable.ic_shuffle else R.drawable.ic_remote_shuffle_off
|
||||
val drawableRes =
|
||||
if (isShuffled) R.drawable.ic_shuffle else R.drawable.ic_remote_shuffle_off
|
||||
|
||||
return buildAction(context, PlaybackService.ACTION_SHUFFLE, drawableRes)
|
||||
}
|
||||
|
@ -165,10 +150,9 @@ class PlaybackNotification private constructor(
|
|||
actionName: String,
|
||||
@DrawableRes iconRes: Int
|
||||
): NotificationCompat.Action {
|
||||
val action = NotificationCompat.Action.Builder(
|
||||
iconRes, actionName,
|
||||
context.newBroadcastIntent(actionName)
|
||||
)
|
||||
val action =
|
||||
NotificationCompat.Action.Builder(
|
||||
iconRes, actionName, context.newBroadcastIntent(actionName))
|
||||
|
||||
return action.build()
|
||||
}
|
||||
|
@ -177,19 +161,18 @@ class PlaybackNotification private constructor(
|
|||
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK"
|
||||
const val NOTIFICATION_ID = 0xA0A0
|
||||
|
||||
/**
|
||||
* Build a new instance of [PlaybackNotification].
|
||||
*/
|
||||
/** Build a new instance of [PlaybackNotification]. */
|
||||
fun from(
|
||||
context: Context,
|
||||
notificationManager: NotificationManager,
|
||||
mediaSession: MediaSessionCompat
|
||||
): PlaybackNotification {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, context.getString(R.string.info_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.info_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT)
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackService.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -69,11 +68,12 @@ import org.oxycblt.auxio.widgets.WidgetProvider
|
|||
* - Headset management
|
||||
* - Widgets
|
||||
*
|
||||
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback],
|
||||
* so therefore there's no need to bind to it to deliver commands.
|
||||
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], so
|
||||
* therefore there's no need to bind to it to deliver commands.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
|
||||
class PlaybackService :
|
||||
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
|
||||
// Player components
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
|
@ -126,10 +126,10 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
false
|
||||
)
|
||||
false)
|
||||
|
||||
audioReactor = AudioReactor(this) { volume ->
|
||||
audioReactor =
|
||||
AudioReactor(this) { volume ->
|
||||
logD("Updating player volume to $volume")
|
||||
player.volume = volume
|
||||
}
|
||||
|
@ -139,9 +139,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
widgets = WidgetController(this)
|
||||
|
||||
// Set up the media button callbacks
|
||||
mediaSession = MediaSessionCompat(this, packageName).apply {
|
||||
isActive = true
|
||||
}
|
||||
mediaSession = MediaSessionCompat(this, packageName).apply { isActive = true }
|
||||
|
||||
connector = PlaybackSessionConnector(this, player, mediaSession)
|
||||
|
||||
|
@ -215,7 +213,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
override fun onPlaybackStateChanged(state: Int) {
|
||||
when (state) {
|
||||
Player.STATE_READY -> startPolling()
|
||||
|
||||
Player.STATE_ENDED -> {
|
||||
if (playbackManager.loopMode == LoopMode.TRACK) {
|
||||
playbackManager.loop()
|
||||
|
@ -223,7 +220,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
playbackManager.next()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
@ -347,17 +343,14 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
|
||||
// --- OTHER FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Create the [ExoPlayer] instance.
|
||||
*/
|
||||
/** Create the [ExoPlayer] instance. */
|
||||
private fun newPlayer(): ExoPlayer {
|
||||
// Since Auxio is a music player, only specify an audio renderer to save
|
||||
// battery/apk size/cache size
|
||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||
arrayOf(
|
||||
MediaCodecAudioRenderer(this, MediaCodecSelector.DEFAULT, handler, audioListener),
|
||||
LibflacAudioRenderer(handler, audioListener)
|
||||
)
|
||||
LibflacAudioRenderer(handler, audioListener))
|
||||
}
|
||||
|
||||
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
|
||||
|
@ -369,9 +362,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully restore the notification and playback state
|
||||
*/
|
||||
/** Fully restore the notification and playback state */
|
||||
private fun restore() {
|
||||
logD("Restoring the service state")
|
||||
|
||||
|
@ -387,16 +378,16 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
widgets.update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling the position on a coroutine.
|
||||
*/
|
||||
/** Start polling the position on a coroutine. */
|
||||
private fun startPolling() {
|
||||
val pollFlow = flow {
|
||||
val pollFlow =
|
||||
flow {
|
||||
while (true) {
|
||||
emit(player.currentPosition)
|
||||
delay(POS_POLL_INTERVAL)
|
||||
}
|
||||
}.conflate()
|
||||
}
|
||||
.conflate()
|
||||
|
||||
serviceScope.launch {
|
||||
pollFlow.takeWhile { player.isPlaying }.collect { pos ->
|
||||
|
@ -416,9 +407,9 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
// Specify that this is a media service, if supported.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
PlaybackNotification.NOTIFICATION_ID, notification.build(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||
)
|
||||
PlaybackNotification.NOTIFICATION_ID,
|
||||
notification.build(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
} else {
|
||||
startForeground(PlaybackNotification.NOTIFICATION_ID, notification.build())
|
||||
}
|
||||
|
@ -427,24 +418,19 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
} else {
|
||||
// If we are already in foreground just update the notification
|
||||
notificationManager.notify(
|
||||
PlaybackNotification.NOTIFICATION_ID, notification.build()
|
||||
)
|
||||
PlaybackNotification.NOTIFICATION_ID, notification.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the foreground state and hide the notification
|
||||
*/
|
||||
/** Stop the foreground state and hide the notification */
|
||||
private fun stopForegroundAndNotification() {
|
||||
stopForeground(true)
|
||||
notificationManager.cancel(PlaybackNotification.NOTIFICATION_ID)
|
||||
isForeground = false
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] for receiving general playback events from the system.
|
||||
*/
|
||||
/** A [BroadcastReceiver] for receiving general playback events from the system. */
|
||||
private inner class PlaybackReceiver : BroadcastReceiver() {
|
||||
private var initialHeadsetPlugEventHandled = false
|
||||
|
||||
|
@ -477,56 +463,44 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
|||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
|
||||
|
||||
// --- AUXIO EVENTS ---
|
||||
ACTION_PLAY_PAUSE -> playbackManager.setPlaying(
|
||||
!playbackManager.isPlaying
|
||||
)
|
||||
|
||||
ACTION_LOOP -> playbackManager.setLoopMode(
|
||||
playbackManager.loopMode.increment()
|
||||
)
|
||||
|
||||
ACTION_SHUFFLE -> playbackManager.setShuffling(
|
||||
!playbackManager.isShuffling, keepSong = true
|
||||
)
|
||||
|
||||
ACTION_PLAY_PAUSE -> playbackManager.setPlaying(!playbackManager.isPlaying)
|
||||
ACTION_LOOP -> playbackManager.setLoopMode(playbackManager.loopMode.increment())
|
||||
ACTION_SHUFFLE ->
|
||||
playbackManager.setShuffling(!playbackManager.isShuffling, keepSong = true)
|
||||
ACTION_SKIP_PREV -> playbackManager.prev()
|
||||
ACTION_SKIP_NEXT -> playbackManager.next()
|
||||
|
||||
ACTION_EXIT -> {
|
||||
playbackManager.setPlaying(false)
|
||||
stopForegroundAndNotification()
|
||||
}
|
||||
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume from a headset plug event in the case that the quirk is enabled.
|
||||
* This functionality remains a quirk for two reasons:
|
||||
* 1. Automatically resuming more or less overrides all other audio streams, which
|
||||
* is not that friendly
|
||||
* 2. There is a bug where playback will always start when this service starts, mostly
|
||||
* due to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but
|
||||
* I fear that it may not work on OEM skins that for whatever reason don't make this
|
||||
* action fire.
|
||||
* TODO: Figure out how players like Retro are able to get autoplay working with
|
||||
* bluetooth headsets
|
||||
* Resume from a headset plug event in the case that the quirk is enabled. This
|
||||
* functionality remains a quirk for two reasons:
|
||||
* 1. Automatically resuming more or less overrides all other audio streams, which is not
|
||||
* that friendly
|
||||
* 2. There is a bug where playback will always start when this service starts, mostly due
|
||||
* to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but I fear
|
||||
* that it may not work on OEM skins that for whatever reason don't make this action fire.
|
||||
* TODO: Figure out how players like Retro are able to get autoplay working with bluetooth
|
||||
* headsets
|
||||
*/
|
||||
private fun maybeResumeFromPlug() {
|
||||
if (playbackManager.song != null &&
|
||||
settingsManager.headsetAutoplay &&
|
||||
initialHeadsetPlugEventHandled
|
||||
) {
|
||||
initialHeadsetPlugEventHandled) {
|
||||
logD("Device connected, resuming")
|
||||
playbackManager.setPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause from a headset plug.
|
||||
* TODO: Find a way to centralize this stuff into a single BroadcastReciever instead
|
||||
* of the weird disjointed arrangement between MediaSession and this.
|
||||
* Pause from a headset plug. TODO: Find a way to centralize this stuff into a single
|
||||
* BroadcastReciever instead of the weird disjointed arrangement between MediaSession and
|
||||
* this.
|
||||
*/
|
||||
private fun pauseFromPlug() {
|
||||
if (playbackManager.song != null) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackSessionConnector.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -32,8 +31,8 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* Nightmarish class that coordinates communication between [MediaSessionCompat], [Player],
|
||||
* and [PlaybackStateManager].
|
||||
* Nightmarish class that coordinates communication between [MediaSessionCompat], [Player], and
|
||||
* [PlaybackStateManager].
|
||||
*/
|
||||
class PlaybackSessionConnector(
|
||||
private val context: Context,
|
||||
|
@ -84,7 +83,8 @@ class PlaybackSessionConnector(
|
|||
}
|
||||
|
||||
override fun onSetRepeatMode(repeatMode: Int) {
|
||||
val mode = when (repeatMode) {
|
||||
val mode =
|
||||
when (repeatMode) {
|
||||
PlaybackStateCompat.REPEAT_MODE_ALL -> LoopMode.ALL
|
||||
PlaybackStateCompat.REPEAT_MODE_GROUP -> LoopMode.ALL
|
||||
PlaybackStateCompat.REPEAT_MODE_ONE -> LoopMode.TRACK
|
||||
|
@ -98,8 +98,7 @@ class PlaybackSessionConnector(
|
|||
playbackManager.setShuffling(
|
||||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
|
||||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP,
|
||||
true
|
||||
)
|
||||
true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
@ -117,7 +116,8 @@ class PlaybackSessionConnector(
|
|||
|
||||
val artistName = song.resolvedArtistName
|
||||
|
||||
val builder = MediaMetadataCompat.Builder()
|
||||
val builder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName)
|
||||
|
@ -149,9 +149,7 @@ class PlaybackSessionConnector(
|
|||
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||
Player.EVENT_IS_PLAYING_CHANGED,
|
||||
Player.EVENT_REPEAT_MODE_CHANGED,
|
||||
Player.EVENT_PLAYBACK_PARAMETERS_CHANGED
|
||||
)
|
||||
) {
|
||||
Player.EVENT_PLAYBACK_PARAMETERS_CHANGED)) {
|
||||
invalidateSessionState()
|
||||
}
|
||||
}
|
||||
|
@ -162,24 +160,20 @@ class PlaybackSessionConnector(
|
|||
logD("Updating media session state")
|
||||
|
||||
// Position updates arrive faster when you upload STATE_PAUSED for some insane reason.
|
||||
val state = PlaybackStateCompat.Builder()
|
||||
val state =
|
||||
PlaybackStateCompat.Builder()
|
||||
.setActions(ACTIONS)
|
||||
.setBufferedPosition(player.bufferedPosition)
|
||||
.setState(
|
||||
PlaybackStateCompat.STATE_PAUSED,
|
||||
player.currentPosition,
|
||||
1.0f,
|
||||
SystemClock.elapsedRealtime()
|
||||
)
|
||||
SystemClock.elapsedRealtime())
|
||||
|
||||
mediaSession.setPlaybackState(state.build())
|
||||
|
||||
state.setState(
|
||||
getPlayerState(),
|
||||
player.currentPosition,
|
||||
1.0f,
|
||||
SystemClock.elapsedRealtime()
|
||||
)
|
||||
getPlayerState(), player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
|
||||
|
||||
mediaSession.setPlaybackState(state.build())
|
||||
}
|
||||
|
@ -199,7 +193,8 @@ class PlaybackSessionConnector(
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val ACTIONS = PlaybackStateCompat.ACTION_PLAY or
|
||||
const val ACTIONS =
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SET_REPEAT_MODE or
|
||||
|
|
|
@ -1,8 +1,23 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.system
|
||||
|
||||
/**
|
||||
* Represents the current setting for ReplayGain.
|
||||
*/
|
||||
/** Represents the current setting for ReplayGain. */
|
||||
enum class ReplayGainMode {
|
||||
/** Do not apply ReplayGain. */
|
||||
OFF,
|
||||
|
@ -13,9 +28,7 @@ enum class ReplayGainMode {
|
|||
/** Apply the album gain only when playing from an album, defaulting to track gain otherwise. */
|
||||
DYNAMIC;
|
||||
|
||||
/**
|
||||
* Converts this type to an integer constant.
|
||||
*/
|
||||
/** Converts this type to an integer constant. */
|
||||
fun toInt(): Int {
|
||||
return when (this) {
|
||||
OFF -> INT_OFF
|
||||
|
@ -31,9 +44,7 @@ enum class ReplayGainMode {
|
|||
private const val INT_ALBUM = 0xA112
|
||||
private const val INT_DYNAMIC = 0xA113
|
||||
|
||||
/**
|
||||
* Converts an integer constant to this type.
|
||||
*/
|
||||
/** Converts an integer constant to this type. */
|
||||
fun fromInt(value: Int): ReplayGainMode? {
|
||||
return when (value) {
|
||||
INT_OFF -> OFF
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SearchAdapter.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -58,24 +57,15 @@ class SearchAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
GenreViewHolder.ITEM_TYPE -> GenreViewHolder.from(
|
||||
parent.context, doOnClick, doOnLongClick
|
||||
)
|
||||
|
||||
ArtistViewHolder.ITEM_TYPE -> ArtistViewHolder.from(
|
||||
parent.context, doOnClick, doOnLongClick
|
||||
)
|
||||
|
||||
AlbumViewHolder.ITEM_TYPE -> AlbumViewHolder.from(
|
||||
parent.context, doOnClick, doOnLongClick
|
||||
)
|
||||
|
||||
SongViewHolder.ITEM_TYPE -> SongViewHolder.from(
|
||||
parent.context, doOnClick, doOnLongClick
|
||||
)
|
||||
|
||||
GenreViewHolder.ITEM_TYPE ->
|
||||
GenreViewHolder.from(parent.context, doOnClick, doOnLongClick)
|
||||
ArtistViewHolder.ITEM_TYPE ->
|
||||
ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick)
|
||||
AlbumViewHolder.ITEM_TYPE ->
|
||||
AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick)
|
||||
SongViewHolder.ITEM_TYPE ->
|
||||
SongViewHolder.from(parent.context, doOnClick, doOnLongClick)
|
||||
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
|
||||
|
||||
else -> error("Invalid ViewHolder item type")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SearchFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -67,18 +66,15 @@ class SearchFragment : Fragment() {
|
|||
|
||||
val imm = requireContext().getSystemServiceSafe(InputMethodManager::class)
|
||||
|
||||
val searchAdapter = SearchAdapter(
|
||||
doOnClick = { item ->
|
||||
onItemSelection(item, imm)
|
||||
},
|
||||
::newMenu
|
||||
)
|
||||
val searchAdapter =
|
||||
SearchAdapter(doOnClick = { item -> onItemSelection(item, imm) }, ::newMenu)
|
||||
// --- UI SETUP --
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
binding.searchToolbar.apply {
|
||||
val itemId = when (searchModel.filterMode) {
|
||||
val itemId =
|
||||
when (searchModel.filterMode) {
|
||||
DisplayMode.SHOW_SONGS -> R.id.option_filter_songs
|
||||
DisplayMode.SHOW_ALBUMS -> R.id.option_filter_albums
|
||||
DisplayMode.SHOW_ARTISTS -> R.id.option_filter_artists
|
||||
|
@ -114,9 +110,7 @@ class SearchFragment : Fragment() {
|
|||
if (!launchedKeyboard) {
|
||||
// Auto-open the keyboard when this view is shown
|
||||
requestFocus()
|
||||
postDelayed(200) {
|
||||
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
postDelayed(200) { imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) }
|
||||
|
||||
launchedKeyboard = true
|
||||
}
|
||||
|
@ -125,9 +119,7 @@ class SearchFragment : Fragment() {
|
|||
binding.searchRecycler.apply {
|
||||
adapter = searchAdapter
|
||||
|
||||
applySpans { pos ->
|
||||
searchAdapter.currentList[pos] is Header
|
||||
}
|
||||
applySpans { pos -> searchAdapter.currentList[pos] is Header }
|
||||
}
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
@ -148,15 +140,14 @@ class SearchFragment : Fragment() {
|
|||
}
|
||||
|
||||
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
|
||||
findNavController().navigate(
|
||||
findNavController()
|
||||
.navigate(
|
||||
when (item) {
|
||||
is Song -> SearchFragmentDirections.actionShowAlbum(item.album.id)
|
||||
is Album -> SearchFragmentDirections.actionShowAlbum(item.id)
|
||||
is Artist -> SearchFragmentDirections.actionShowArtist(item.id)
|
||||
|
||||
else -> return@observe
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
imm.hide()
|
||||
}
|
||||
|
@ -177,8 +168,7 @@ class SearchFragment : Fragment() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Function that handles when an [item] is selected.
|
||||
* Handles all datatypes that are selectable.
|
||||
* Function that handles when an [item] is selected. Handles all datatypes that are selectable.
|
||||
*/
|
||||
private fun onItemSelection(item: Music, imm: InputMethodManager) {
|
||||
if (item is Song) {
|
||||
|
@ -192,7 +182,8 @@ class SearchFragment : Fragment() {
|
|||
|
||||
logD("Navigating to the detail fragment for ${item.name}")
|
||||
|
||||
findNavController().navigate(
|
||||
findNavController()
|
||||
.navigate(
|
||||
when (item) {
|
||||
is Genre -> SearchFragmentDirections.actionShowGenre(item.id)
|
||||
is Artist -> SearchFragmentDirections.actionShowArtist(item.id)
|
||||
|
@ -204,8 +195,7 @@ class SearchFragment : Fragment() {
|
|||
searchModel.setNavigating(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
imm.hide()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SearchViewModel.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -23,6 +22,7 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.text.Normalizer
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Header
|
||||
|
@ -34,7 +34,6 @@ import org.oxycblt.auxio.settings.SettingsManager
|
|||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import java.text.Normalizer
|
||||
|
||||
/**
|
||||
* The [ViewModel] for the search functionality
|
||||
|
@ -47,9 +46,12 @@ class SearchViewModel : ViewModel() {
|
|||
private var mLastQuery = ""
|
||||
|
||||
/** Current search results from the last [search] call. */
|
||||
val searchResults: LiveData<List<Item>> get() = mSearchResults
|
||||
val isNavigating: Boolean get() = mIsNavigating
|
||||
val filterMode: DisplayMode? get() = mFilterMode
|
||||
val searchResults: LiveData<List<Item>>
|
||||
get() = mSearchResults
|
||||
val isNavigating: Boolean
|
||||
get() = mIsNavigating
|
||||
val filterMode: DisplayMode?
|
||||
get() = mFilterMode
|
||||
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
|
@ -63,8 +65,7 @@ class SearchViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Use [query] to perform a search of the music library.
|
||||
* Will push results to [searchResults].
|
||||
* Use [query] to perform a search of the music library. Will push results to [searchResults].
|
||||
*/
|
||||
fun search(query: String) {
|
||||
val musicStore = MusicStore.maybeGetInstance()
|
||||
|
@ -118,16 +119,15 @@ class SearchViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the current filter mode with a menu [id].
|
||||
* New value will be pushed to [filterMode].
|
||||
* Update the current filter mode with a menu [id]. New value will be pushed to [filterMode].
|
||||
*/
|
||||
fun updateFilterModeWithId(@IdRes id: Int) {
|
||||
mFilterMode = when (id) {
|
||||
mFilterMode =
|
||||
when (id) {
|
||||
R.id.option_filter_songs -> DisplayMode.SHOW_SONGS
|
||||
R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS
|
||||
R.id.option_filter_artists -> DisplayMode.SHOW_ARTISTS
|
||||
R.id.option_filter_genres -> DisplayMode.SHOW_GENRES
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
@ -139,13 +139,14 @@ class SearchViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Shortcut that will run a ignoreCase filter on a list and only return
|
||||
* a value if the resulting list is empty.
|
||||
* Shortcut that will run a ignoreCase filter on a list and only return a value if the resulting
|
||||
* list is empty.
|
||||
*/
|
||||
private fun <T : Music> List<T>.filterByOrNull(value: String): List<T>? {
|
||||
val filtered = filter {
|
||||
// Ensure the name we match with is correct.
|
||||
val name = if (it is MusicParent) {
|
||||
val name =
|
||||
if (it is MusicParent) {
|
||||
it.resolvedName
|
||||
} else {
|
||||
it.name
|
||||
|
@ -182,8 +183,8 @@ class SearchViewModel : ViewModel() {
|
|||
when (Character.getType(cp)) {
|
||||
// Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK were added
|
||||
// by normalizer
|
||||
6, 8 -> continue
|
||||
|
||||
6,
|
||||
8 -> continue
|
||||
else -> sb.appendCodePoint(cp)
|
||||
}
|
||||
}
|
||||
|
@ -191,9 +192,7 @@ class SearchViewModel : ViewModel() {
|
|||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current navigation status to [isNavigating]
|
||||
*/
|
||||
/** Update the current navigation status to [isNavigating] */
|
||||
fun setNavigating(isNavigating: Boolean) {
|
||||
mIsNavigating = isNavigating
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* AboutFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -59,9 +58,7 @@ class AboutFragment : Fragment() {
|
|||
insets
|
||||
}
|
||||
|
||||
binding.aboutToolbar.setNavigationOnClickListener {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
binding.aboutToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
binding.aboutVersion.text = BuildConfig.VERSION_NAME
|
||||
binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_CODEBASE) }
|
||||
|
@ -69,9 +66,7 @@ class AboutFragment : Fragment() {
|
|||
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
|
||||
|
||||
homeModel.songs.observe(viewLifecycleOwner) { songs ->
|
||||
binding.aboutSongCount.text = getString(
|
||||
R.string.fmt_songs_loaded, songs.size
|
||||
)
|
||||
binding.aboutSongCount.text = getString(R.string.fmt_songs_loaded, songs.size)
|
||||
}
|
||||
|
||||
logD("Dialog created")
|
||||
|
@ -79,15 +74,12 @@ class AboutFragment : Fragment() {
|
|||
return binding.root
|
||||
}
|
||||
|
||||
/**
|
||||
* Go through the process of opening a [link] in a browser.
|
||||
*/
|
||||
/** Go through the process of opening a [link] in a browser. */
|
||||
private fun openLinkInBrowser(link: String) {
|
||||
logD("Opening $link")
|
||||
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, link.toUri()).setFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
)
|
||||
val browserIntent =
|
||||
Intent(Intent.ACTION_VIEW, link.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Android 11 seems to now handle the app chooser situations on its own now
|
||||
|
@ -104,9 +96,12 @@ class AboutFragment : Fragment() {
|
|||
// not work in all cases, especially when no default app was set. If that is the
|
||||
// case, we will try to manually handle these cases before we try to launch the
|
||||
// browser.
|
||||
val pkgName = requireContext().packageManager.resolveActivity(
|
||||
browserIntent, PackageManager.MATCH_DEFAULT_ONLY
|
||||
)?.activityInfo?.packageName
|
||||
val pkgName =
|
||||
requireContext()
|
||||
.packageManager
|
||||
.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
?.activityInfo
|
||||
?.packageName
|
||||
|
||||
if (pkgName != null) {
|
||||
if (pkgName == "android") {
|
||||
|
@ -130,7 +125,8 @@ class AboutFragment : Fragment() {
|
|||
}
|
||||
|
||||
private fun openAppChooser(intent: Intent) {
|
||||
val chooserIntent = Intent(Intent.ACTION_CHOOSER)
|
||||
val chooserIntent =
|
||||
Intent(Intent.ACTION_CHOOSER)
|
||||
.putExtra(Intent.EXTRA_INTENT, intent)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SettingsCompat.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -53,9 +52,7 @@ fun handleAccentCompat(prefs: SharedPreferences): Accent {
|
|||
return Accent(prefs.getInt(SettingsManager.KEY_ACCENT, 5))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache of the old keys used in Auxio.
|
||||
*/
|
||||
/** Cache of the old keys used in Auxio. */
|
||||
private object OldKeys {
|
||||
const val KEY_ACCENT2 = "KEY_ACCENT2"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SettingsFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -39,9 +38,7 @@ class SettingsFragment : Fragment() {
|
|||
val binding = FragmentSettingsBinding.inflate(inflater)
|
||||
|
||||
binding.settingsToolbar.apply {
|
||||
setNavigationOnClickListener {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
}
|
||||
|
||||
binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SettingsListFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -32,8 +31,8 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import coil.Coil
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.accent.AccentCustomizeDialog
|
||||
import org.oxycblt.auxio.music.excluded.ExcludedDialog
|
||||
import org.oxycblt.auxio.home.tabs.TabCustomizeDialog
|
||||
import org.oxycblt.auxio.music.excluded.ExcludedDialog
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.settings.pref.IntListPrefDialog
|
||||
import org.oxycblt.auxio.settings.pref.IntListPreference
|
||||
|
@ -83,9 +82,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively handle a preference, doing any specific actions on it.
|
||||
*/
|
||||
/** Recursively handle a preference, doing any specific actions on it. */
|
||||
private fun recursivelyHandlePreference(preference: Preference) {
|
||||
if (!preference.isVisible) return
|
||||
|
||||
|
@ -100,15 +97,16 @@ class SettingsListFragment : PreferenceFragmentCompat() {
|
|||
SettingsManager.KEY_THEME -> {
|
||||
setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon())
|
||||
|
||||
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, value ->
|
||||
onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, value ->
|
||||
AppCompatDelegate.setDefaultNightMode(value as Int)
|
||||
setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon())
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
SettingsManager.KEY_BLACK_THEME -> {
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
if (requireContext().isNight) {
|
||||
requireActivity().recreate()
|
||||
}
|
||||
|
@ -116,35 +114,34 @@ class SettingsListFragment : PreferenceFragmentCompat() {
|
|||
true
|
||||
}
|
||||
}
|
||||
|
||||
SettingsManager.KEY_ACCENT -> {
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
AccentCustomizeDialog().show(childFragmentManager, AccentCustomizeDialog.TAG)
|
||||
onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
AccentCustomizeDialog()
|
||||
.show(childFragmentManager, AccentCustomizeDialog.TAG)
|
||||
true
|
||||
}
|
||||
|
||||
summary = context.getString(settingsManager.accent.name)
|
||||
}
|
||||
|
||||
SettingsManager.KEY_LIB_TABS -> {
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
TabCustomizeDialog().show(childFragmentManager, TabCustomizeDialog.TAG)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
SettingsManager.KEY_SHOW_COVERS, SettingsManager.KEY_QUALITY_COVERS -> {
|
||||
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ ->
|
||||
Coil.imageLoader(requireContext()).apply {
|
||||
this.memoryCache?.clear()
|
||||
}
|
||||
onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, _ ->
|
||||
Coil.imageLoader(requireContext()).apply { this.memoryCache?.clear() }
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
SettingsManager.KEY_SAVE_STATE -> {
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
playbackModel.savePlaybackState(requireContext()) {
|
||||
requireContext().showToast(R.string.lbl_state_saved)
|
||||
}
|
||||
|
@ -152,9 +149,9 @@ class SettingsListFragment : PreferenceFragmentCompat() {
|
|||
true
|
||||
}
|
||||
}
|
||||
|
||||
SettingsManager.KEY_RELOAD -> {
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
playbackModel.savePlaybackState(requireContext()) {
|
||||
requireContext().hardRestart()
|
||||
}
|
||||
|
@ -162,9 +159,9 @@ class SettingsListFragment : PreferenceFragmentCompat() {
|
|||
true
|
||||
}
|
||||
}
|
||||
|
||||
SettingsManager.KEY_EXCLUDED -> {
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
ExcludedDialog().show(childFragmentManager, ExcludedDialog.TAG)
|
||||
true
|
||||
}
|
||||
|
@ -173,9 +170,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an theme integer into an icon that can be used.
|
||||
*/
|
||||
/** Convert an theme integer into an icon that can be used. */
|
||||
@DrawableRes
|
||||
private fun Int.toThemeIcon(): Int {
|
||||
return when (this) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SettingsManager.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -64,15 +63,16 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether to display the LoopMode or the shuffle status on the notification.
|
||||
* False if loop, true if shuffle.
|
||||
* Whether to display the LoopMode or the shuffle status on the notification. False if loop,
|
||||
* true if shuffle.
|
||||
*/
|
||||
val useAltNotifAction: Boolean
|
||||
get() = prefs.getBoolean(KEY_USE_ALT_NOTIFICATION_ACTION, false)
|
||||
|
||||
/** The current library tabs preferred by the user. */
|
||||
var libTabs: Array<Tab>
|
||||
get() = Tab.fromSequence(prefs.getInt(KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||
get() =
|
||||
Tab.fromSequence(prefs.getInt(KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||
?: Tab.fromSequence(Tab.SEQUENCE_DEFAULT)!!
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
|
@ -103,12 +103,14 @@ class SettingsManager private constructor(context: Context) :
|
|||
|
||||
/** The current ReplayGain configuration */
|
||||
val replayGainMode: ReplayGainMode
|
||||
get() = ReplayGainMode.fromInt(prefs.getInt(KEY_REPLAY_GAIN, Int.MIN_VALUE))
|
||||
get() =
|
||||
ReplayGainMode.fromInt(prefs.getInt(KEY_REPLAY_GAIN, Int.MIN_VALUE))
|
||||
?: ReplayGainMode.OFF
|
||||
|
||||
/** What queue to create when a song is selected (ex. From All Songs or Search) */
|
||||
val songPlaybackMode: PlaybackMode
|
||||
get() = PlaybackMode.fromInt(prefs.getInt(KEY_SONG_PLAYBACK_MODE, Int.MIN_VALUE))
|
||||
get() =
|
||||
PlaybackMode.fromInt(prefs.getInt(KEY_SONG_PLAYBACK_MODE, Int.MIN_VALUE))
|
||||
?: PlaybackMode.ALL_SONGS
|
||||
|
||||
/** Whether shuffle should stay on when a new song is selected. */
|
||||
|
@ -119,7 +121,9 @@ class SettingsManager private constructor(context: Context) :
|
|||
val rewindWithPrev: Boolean
|
||||
get() = prefs.getBoolean(KEY_PREV_REWIND, true)
|
||||
|
||||
/** Whether [org.oxycblt.auxio.playback.state.LoopMode.TRACK] should pause when the track repeats */
|
||||
/**
|
||||
* Whether [org.oxycblt.auxio.playback.state.LoopMode.TRACK] should pause when the track repeats
|
||||
*/
|
||||
val pauseOnLoop: Boolean
|
||||
get() = prefs.getBoolean(KEY_LOOP_PAUSE, false)
|
||||
|
||||
|
@ -133,10 +137,9 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** The song sort mode on HomeFragment **/
|
||||
/** The song sort mode on HomeFragment */
|
||||
var libSongSort: Sort
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE))
|
||||
?: Sort.ByName(true)
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
putInt(KEY_LIB_SONGS_SORT, value.toInt())
|
||||
|
@ -144,10 +147,9 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** The album sort mode on HomeFragment **/
|
||||
/** The album sort mode on HomeFragment */
|
||||
var libAlbumSort: Sort
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE))
|
||||
?: Sort.ByName(true)
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
putInt(KEY_LIB_ALBUMS_SORT, value.toInt())
|
||||
|
@ -155,10 +157,9 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** The artist sort mode on HomeFragment **/
|
||||
/** The artist sort mode on HomeFragment */
|
||||
var libArtistSort: Sort
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE))
|
||||
?: Sort.ByName(true)
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
putInt(KEY_LIB_ARTISTS_SORT, value.toInt())
|
||||
|
@ -166,10 +167,9 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** The genre sort mode on HomeFragment **/
|
||||
/** The genre sort mode on HomeFragment */
|
||||
var libGenreSort: Sort
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE))
|
||||
?: Sort.ByName(true)
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
putInt(KEY_LIB_GENRES_SORT, value.toInt())
|
||||
|
@ -177,10 +177,10 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** The detail album sort mode **/
|
||||
/** The detail album sort mode */
|
||||
var detailAlbumSort: Sort
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE))
|
||||
?: Sort.ByName(true)
|
||||
get() =
|
||||
Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
putInt(KEY_DETAIL_ALBUM_SORT, value.toInt())
|
||||
|
@ -188,10 +188,10 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** The detail artist sort mode **/
|
||||
/** The detail artist sort mode */
|
||||
var detailArtistSort: Sort
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE))
|
||||
?: Sort.ByYear(false)
|
||||
get() =
|
||||
Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE)) ?: Sort.ByYear(false)
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
putInt(KEY_DETAIL_ARTIST_SORT, value.toInt())
|
||||
|
@ -199,10 +199,10 @@ class SettingsManager private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** The detail genre sort mode **/
|
||||
/** The detail genre sort mode */
|
||||
var detailGenreSort: Sort
|
||||
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE))
|
||||
?: Sort.ByName(true)
|
||||
get() =
|
||||
Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
putInt(KEY_DETAIL_GENRE_SORT, value.toInt())
|
||||
|
@ -226,29 +226,13 @@ class SettingsManager private constructor(context: Context) :
|
|||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
KEY_USE_ALT_NOTIFICATION_ACTION -> callbacks.forEach {
|
||||
it.onNotifActionUpdate(useAltNotifAction)
|
||||
}
|
||||
|
||||
KEY_SHOW_COVERS -> callbacks.forEach {
|
||||
it.onShowCoverUpdate(showCovers)
|
||||
}
|
||||
|
||||
KEY_QUALITY_COVERS -> callbacks.forEach {
|
||||
it.onQualityCoverUpdate(useQualityCovers)
|
||||
}
|
||||
|
||||
KEY_LIB_TABS -> callbacks.forEach {
|
||||
it.onLibTabsUpdate(libTabs)
|
||||
}
|
||||
|
||||
KEY_AUDIO_FOCUS -> callbacks.forEach {
|
||||
it.onAudioFocusUpdate(doAudioFocus)
|
||||
}
|
||||
|
||||
KEY_REPLAY_GAIN -> callbacks.forEach {
|
||||
it.onReplayGainUpdate(replayGainMode)
|
||||
}
|
||||
KEY_USE_ALT_NOTIFICATION_ACTION ->
|
||||
callbacks.forEach { it.onNotifActionUpdate(useAltNotifAction) }
|
||||
KEY_SHOW_COVERS -> callbacks.forEach { it.onShowCoverUpdate(showCovers) }
|
||||
KEY_QUALITY_COVERS -> callbacks.forEach { it.onQualityCoverUpdate(useQualityCovers) }
|
||||
KEY_LIB_TABS -> callbacks.forEach { it.onLibTabsUpdate(libTabs) }
|
||||
KEY_AUDIO_FOCUS -> callbacks.forEach { it.onAudioFocusUpdate(doAudioFocus) }
|
||||
KEY_REPLAY_GAIN -> callbacks.forEach { it.onReplayGainUpdate(replayGainMode) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -304,26 +288,21 @@ class SettingsManager private constructor(context: Context) :
|
|||
const val KEY_DETAIL_ARTIST_SORT = "auxio_artist_sort"
|
||||
const val KEY_DETAIL_GENRE_SORT = "auxio_genre_sort"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: SettingsManager? = null
|
||||
@Volatile private var INSTANCE: SettingsManager? = null
|
||||
|
||||
/**
|
||||
* Init the single instance of [SettingsManager]. Done so that every object
|
||||
* can have access to it regardless of if it has a context.
|
||||
* Init the single instance of [SettingsManager]. Done so that every object can have access
|
||||
* to it regardless of if it has a context.
|
||||
*/
|
||||
fun init(context: Context): SettingsManager {
|
||||
if (INSTANCE == null) {
|
||||
synchronized(this) {
|
||||
INSTANCE = SettingsManager(context)
|
||||
}
|
||||
synchronized(this) { INSTANCE = SettingsManager(context) }
|
||||
}
|
||||
|
||||
return getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the single instance of [SettingsManager].
|
||||
*/
|
||||
/** Get the single instance of [SettingsManager]. */
|
||||
fun getInstance(): SettingsManager {
|
||||
val instance = INSTANCE
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* IntListPrefDialog.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -24,17 +23,15 @@ import androidx.preference.PreferenceFragmentCompat
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.ui.LifecycleDialog
|
||||
|
||||
/**
|
||||
* The dialog shown whenever an [IntListPreference] is shown.
|
||||
*/
|
||||
/** The dialog shown whenever an [IntListPreference] is shown. */
|
||||
class IntListPrefDialog : LifecycleDialog() {
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
// Since we have to store the preference key as an argument, we have to find the
|
||||
// preference we need to use manually.
|
||||
val pref = requireNotNull(
|
||||
(parentFragment as PreferenceFragmentCompat).preferenceManager
|
||||
.findPreference<IntListPreference>(requireArguments().getString(ARG_KEY, null))
|
||||
)
|
||||
val pref =
|
||||
requireNotNull(
|
||||
(parentFragment as PreferenceFragmentCompat).preferenceManager.findPreference<
|
||||
IntListPreference>(requireArguments().getString(ARG_KEY, null)))
|
||||
|
||||
builder.setTitle(pref.title)
|
||||
|
||||
|
@ -52,9 +49,7 @@ class IntListPrefDialog : LifecycleDialog() {
|
|||
|
||||
fun from(pref: IntListPreference): IntListPrefDialog {
|
||||
return IntListPrefDialog().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_KEY, pref.key)
|
||||
}
|
||||
arguments = Bundle().apply { putString(ARG_KEY, pref.key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* IntListPreference.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -25,32 +24,34 @@ import androidx.preference.DialogPreference
|
|||
import androidx.preference.Preference
|
||||
import org.oxycblt.auxio.R
|
||||
|
||||
class IntListPreference @JvmOverloads constructor(
|
||||
class IntListPreference
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
|
||||
defStyleRes: Int = 0
|
||||
) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) {
|
||||
// Reflect into Preference to get the (normally inaccessible) default value.
|
||||
private val defValueField = Preference::class.java.getDeclaredField("mDefaultValue").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
private val defValueField =
|
||||
Preference::class.java.getDeclaredField("mDefaultValue").apply { isAccessible = true }
|
||||
|
||||
val entries: Array<CharSequence>
|
||||
val values: IntArray
|
||||
private var currentValue: Int? = null
|
||||
private val defValue: Int get() = defValueField.get(this) as Int
|
||||
private val defValue: Int
|
||||
get() = defValueField.get(this) as Int
|
||||
|
||||
init {
|
||||
val prefAttrs = context.obtainStyledAttributes(
|
||||
attrs, R.styleable.IntListPreference, defStyleAttr, defStyleRes
|
||||
)
|
||||
val prefAttrs =
|
||||
context.obtainStyledAttributes(
|
||||
attrs, R.styleable.IntListPreference, defStyleAttr, defStyleRes)
|
||||
|
||||
entries = prefAttrs.getTextArray(R.styleable.IntListPreference_entries)
|
||||
|
||||
values = context.resources.getIntArray(
|
||||
prefAttrs.getResourceId(R.styleable.IntListPreference_entryValues, -1)
|
||||
)
|
||||
values =
|
||||
context.resources.getIntArray(
|
||||
prefAttrs.getResourceId(R.styleable.IntListPreference_entryValues, -1))
|
||||
|
||||
prefAttrs.recycle()
|
||||
|
||||
|
@ -80,9 +81,7 @@ class IntListPreference @JvmOverloads constructor(
|
|||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value using the index of it in [values]
|
||||
*/
|
||||
/** Set a value using the index of it in [values] */
|
||||
fun setValueIndex(index: Int) {
|
||||
setValue(values[index])
|
||||
}
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.settings.pref
|
||||
|
||||
import android.content.Context
|
||||
|
@ -11,10 +28,12 @@ import org.oxycblt.auxio.util.getColorStateListSafe
|
|||
import org.oxycblt.auxio.util.getDrawableSafe
|
||||
|
||||
/**
|
||||
* A [SwitchPreferenceCompat] that emulates the M3 switches until the design team
|
||||
* actually bothers to add them to MDC.
|
||||
* A [SwitchPreferenceCompat] that emulates the M3 switches until the design team actually bothers
|
||||
* to add them to MDC.
|
||||
*/
|
||||
class M3SwitchPreference @JvmOverloads constructor(
|
||||
class M3SwitchPreference
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.switchPreferenceCompatStyle,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* ActionMenu.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -52,12 +51,11 @@ fun Fragment.newMenu(anchor: View, data: Item, flag: Int = ActionMenu.FLAG_NONE)
|
|||
* @param activity [AppCompatActivity] required as both a context and ViewModelStore owner.
|
||||
* @param anchor [View] This should be centered around
|
||||
* @param data [Item] this menu corresponds to
|
||||
* @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM], [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
|
||||
* @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM],
|
||||
* [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
|
||||
* @throws IllegalStateException When there is no menu for this specific datatype/flag
|
||||
* @author OxygenCobalt
|
||||
* TODO: Stop scrolling when a menu is open
|
||||
* TODO: Prevent duplicate menus from showing up
|
||||
* TODO: Maybe replace this with a bottom sheet?
|
||||
* @author OxygenCobalt TODO: Stop scrolling when a menu is open TODO: Prevent duplicate menus from
|
||||
* showing up TODO: Maybe replace this with a bottom sheet?
|
||||
*/
|
||||
class ActionMenu(
|
||||
activity: AppCompatActivity,
|
||||
|
@ -98,9 +96,7 @@ class ActionMenu(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out what menu to use here, based on the data & flags
|
||||
*/
|
||||
/** Figure out what menu to use here, based on the data & flags */
|
||||
@MenuRes
|
||||
private fun determineMenu(): Int {
|
||||
return when (data) {
|
||||
|
@ -109,31 +105,23 @@ class ActionMenu(
|
|||
FLAG_NONE, FLAG_IN_GENRE -> R.menu.menu_song_actions
|
||||
FLAG_IN_ALBUM -> R.menu.menu_album_song_actions
|
||||
FLAG_IN_ARTIST -> R.menu.menu_artist_song_actions
|
||||
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
is Album -> {
|
||||
when (flag) {
|
||||
FLAG_NONE -> R.menu.menu_album_actions
|
||||
FLAG_IN_ARTIST -> R.menu.menu_artist_album_actions
|
||||
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
is Artist -> R.menu.menu_artist_actions
|
||||
|
||||
is Genre -> R.menu.menu_genre_actions
|
||||
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what to do when a MenuItem is clicked.
|
||||
*/
|
||||
/** Determine what to do when a MenuItem is clicked. */
|
||||
private fun onMenuClick(@IdRes id: Int) {
|
||||
when (id) {
|
||||
R.id.action_play -> {
|
||||
|
@ -141,59 +129,48 @@ class ActionMenu(
|
|||
is Album -> playbackModel.playAlbum(data, false)
|
||||
is Artist -> playbackModel.playArtist(data, false)
|
||||
is Genre -> playbackModel.playGenre(data, false)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_shuffle -> {
|
||||
when (data) {
|
||||
is Album -> playbackModel.playAlbum(data, true)
|
||||
is Artist -> playbackModel.playArtist(data, true)
|
||||
is Genre -> playbackModel.playGenre(data, true)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_play_next -> {
|
||||
when (data) {
|
||||
is Song -> {
|
||||
playbackModel.playNext(data)
|
||||
context.showToast(R.string.lbl_queue_added)
|
||||
}
|
||||
|
||||
is Album -> {
|
||||
playbackModel.playNext(data)
|
||||
context.showToast(R.string.lbl_queue_added)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_queue_add -> {
|
||||
when (data) {
|
||||
is Song -> {
|
||||
playbackModel.addToQueue(data)
|
||||
context.showToast(R.string.lbl_queue_added)
|
||||
}
|
||||
|
||||
is Album -> {
|
||||
playbackModel.addToQueue(data)
|
||||
context.showToast(R.string.lbl_queue_added)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_go_album -> {
|
||||
if (data is Song) {
|
||||
detailModel.navToItem(data.album)
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_go_artist -> {
|
||||
if (data is Song) {
|
||||
detailModel.navToItem(data.album.artist)
|
||||
|
@ -205,13 +182,22 @@ class ActionMenu(
|
|||
}
|
||||
|
||||
companion object {
|
||||
/** No Flags **/
|
||||
/** No Flags */
|
||||
const val FLAG_NONE = -1
|
||||
/** Flag for when a menu is opened from an artist (See [org.oxycblt.auxio.detail.ArtistDetailFragment]) **/
|
||||
/**
|
||||
* Flag for when a menu is opened from an artist (See
|
||||
* [org.oxycblt.auxio.detail.ArtistDetailFragment])
|
||||
*/
|
||||
const val FLAG_IN_ARTIST = 0
|
||||
/** Flag for when a menu is opened from an album (See [org.oxycblt.auxio.detail.AlbumDetailFragment]) **/
|
||||
/**
|
||||
* Flag for when a menu is opened from an album (See
|
||||
* [org.oxycblt.auxio.detail.AlbumDetailFragment])
|
||||
*/
|
||||
const val FLAG_IN_ALBUM = 1
|
||||
/** Flag for when a menu is opened from a genre (See [org.oxycblt.auxio.detail.GenreDetailFragment]) **/
|
||||
/**
|
||||
* Flag for when a menu is opened from a genre (See
|
||||
* [org.oxycblt.auxio.detail.GenreDetailFragment])
|
||||
*/
|
||||
const val FLAG_IN_GENRE = 2
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* DiffCallback.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -22,8 +21,8 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import org.oxycblt.auxio.music.Item
|
||||
|
||||
/**
|
||||
* A re-usable diff callback for all [Item] implementations.
|
||||
* **Use this instead of creating a DiffCallback for each adapter.**
|
||||
* A re-usable diff callback for all [Item] implementations. **Use this instead of creating a
|
||||
* DiffCallback for each adapter.**
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* DisplayMode.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -21,9 +20,9 @@ package org.oxycblt.auxio.ui
|
|||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* An enum for determining what items to show in a given list.
|
||||
* Note: **DO NOT RE-ARRANGE THE ENUM**. The ordinals are used to store library tabs, so doing
|
||||
* changing them would also change the meaning of tab instances.
|
||||
* An enum for determining what items to show in a given list. Note: **DO NOT RE-ARRANGE THE ENUM**.
|
||||
* The ordinals are used to store library tabs, so doing changing them would also change the meaning
|
||||
* of tab instances.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
enum class DisplayMode {
|
||||
|
@ -32,14 +31,18 @@ enum class DisplayMode {
|
|||
SHOW_ARTISTS,
|
||||
SHOW_GENRES;
|
||||
|
||||
val string: Int get() = when (this) {
|
||||
val string: Int
|
||||
get() =
|
||||
when (this) {
|
||||
SHOW_SONGS -> R.string.lbl_songs
|
||||
SHOW_ALBUMS -> R.string.lbl_albums
|
||||
SHOW_ARTISTS -> R.string.lbl_artists
|
||||
SHOW_GENRES -> R.string.lbl_genres
|
||||
}
|
||||
|
||||
val icon: Int get() = when (this) {
|
||||
val icon: Int
|
||||
get() =
|
||||
when (this) {
|
||||
SHOW_SONGS -> R.drawable.ic_song
|
||||
SHOW_ALBUMS -> R.drawable.ic_album
|
||||
SHOW_ARTISTS -> R.drawable.ic_artist
|
||||
|
@ -54,8 +57,8 @@ enum class DisplayMode {
|
|||
private const val INT_SHOW_SONGS = 0xA10B
|
||||
|
||||
/**
|
||||
* Convert this enum into an integer for filtering.
|
||||
* In this context, a null value means to filter nothing.
|
||||
* Convert this enum into an integer for filtering. In this context, a null value means to
|
||||
* filter nothing.
|
||||
* @return An integer constant for that display mode, or a constant for a null [DisplayMode]
|
||||
*/
|
||||
fun toFilterInt(value: DisplayMode?): Int {
|
||||
|
@ -69,8 +72,8 @@ enum class DisplayMode {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert a filtering integer to a [DisplayMode].
|
||||
* In this context, a null value means to filter nothing.
|
||||
* Convert a filtering integer to a [DisplayMode]. In this context, a null value means to
|
||||
* filter nothing.
|
||||
* @return A [DisplayMode] for this constant (including null)
|
||||
*/
|
||||
fun fromFilterInt(value: Int): DisplayMode? {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* LiftAppBarLayout.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -33,27 +32,26 @@ import org.oxycblt.auxio.util.logW
|
|||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* An [AppBarLayout] that fixes a bug with the default implementation where the lifted state
|
||||
* will not properly respond to RecyclerView events.
|
||||
* **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what
|
||||
* scrolling view to use. Failure to specify this will result in the layout not working.
|
||||
* An [AppBarLayout] that fixes a bug with the default implementation where the lifted state will
|
||||
* not properly respond to RecyclerView events. **Note:** This layout relies on
|
||||
* [AppBarLayout.liftOnScrollTargetViewId] to figure out what scrolling view to use. Failure to
|
||||
* specify this will result in the layout not working.
|
||||
*/
|
||||
open class EdgeAppBarLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : AppBarLayout(context, attrs, defStyleAttr) {
|
||||
open class EdgeAppBarLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AppBarLayout(context, attrs, defStyleAttr) {
|
||||
private var scrollingChild: View? = null
|
||||
private val tConsumed = IntArray(2)
|
||||
|
||||
private val onPreDraw = ViewTreeObserver.OnPreDrawListener {
|
||||
private val onPreDraw =
|
||||
ViewTreeObserver.OnPreDrawListener {
|
||||
val child = findScrollingChild()
|
||||
|
||||
if (child != null) {
|
||||
val coordinator = parent as CoordinatorLayout
|
||||
(layoutParams as CoordinatorLayout.LayoutParams).behavior?.onNestedPreScroll(
|
||||
coordinator, this, coordinator, 0, 0, tConsumed, 0
|
||||
)
|
||||
coordinator, this, coordinator, 0, 0, tConsumed, 0)
|
||||
}
|
||||
|
||||
true
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* FuckedCoordinatorLayout.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -26,16 +25,15 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|||
import androidx.core.view.children
|
||||
|
||||
/**
|
||||
* Class that fixes an issue where [CoordinatorLayout] will override [onApplyWindowInsets]
|
||||
* and delegate the job to ***LAYOUT BEHAVIOR INSTANCES*** instead of the actual views.
|
||||
* Class that fixes an issue where [CoordinatorLayout] will override [onApplyWindowInsets] and
|
||||
* delegate the job to ***LAYOUT BEHAVIOR INSTANCES*** instead of the actual views.
|
||||
*
|
||||
* I can't believe I have to do this.
|
||||
*/
|
||||
class EdgeCoordinatorLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : CoordinatorLayout(context, attrs, defStyleAttr) {
|
||||
class EdgeCoordinatorLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
CoordinatorLayout(context, attrs, defStyleAttr) {
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
for (child in children) {
|
||||
child.onApplyWindowInsets(insets)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* EdgeRecyclerView.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -26,14 +25,11 @@ import androidx.core.view.updatePadding
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* A [RecyclerView] that automatically applies insets to itself.
|
||||
*/
|
||||
class EdgeRecyclerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : RecyclerView(context, attrs, defStyleAttr) {
|
||||
/** A [RecyclerView] that automatically applies insets to itself. */
|
||||
class EdgeRecyclerView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
RecyclerView(context, attrs, defStyleAttr) {
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
|
||||
return insets
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* LifecycleDialog.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -27,8 +26,8 @@ import androidx.fragment.app.DialogFragment
|
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
/**
|
||||
* A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle
|
||||
* override [onCreateView] and [onDestroyView], but with a proper dialog being created.
|
||||
* A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle override
|
||||
* [onCreateView] and [onDestroyView], but with a proper dialog being created.
|
||||
*/
|
||||
abstract class LifecycleDialog : AppCompatDialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Sort.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -53,10 +52,10 @@ sealed class Sort(open val isAscending: Boolean) {
|
|||
/** Sort by the year of an item, only supported by [Album] and [Song] */
|
||||
class ByYear(override val isAscending: Boolean) : Sort(isAscending)
|
||||
|
||||
/**
|
||||
* Get the corresponding item id for this sort.
|
||||
*/
|
||||
val itemId: Int get() = when (this) {
|
||||
/** Get the corresponding item id for this sort. */
|
||||
val itemId: Int
|
||||
get() =
|
||||
when (this) {
|
||||
is ByName -> R.id.option_sort_name
|
||||
is ByArtist -> R.id.option_sort_artist
|
||||
is ByAlbum -> R.id.option_sort_album
|
||||
|
@ -100,8 +99,8 @@ sealed class Sort(open val isAscending: Boolean) {
|
|||
fun sortSongs(songs: Collection<Song>): List<Song> {
|
||||
return when (this) {
|
||||
is ByName -> songs.stringSort { it.name }
|
||||
|
||||
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
|
||||
else ->
|
||||
sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
|
||||
album.songs.intSort(true) { it.track ?: 0 }
|
||||
}
|
||||
}
|
||||
|
@ -117,10 +116,10 @@ sealed class Sort(open val isAscending: Boolean) {
|
|||
fun sortAlbums(albums: Collection<Album>): List<Album> {
|
||||
return when (this) {
|
||||
is ByName, is ByAlbum -> albums.stringSort { it.resolvedName }
|
||||
|
||||
is ByArtist -> sortParents(albums.groupBy { it.artist }.keys)
|
||||
.flatMap { ByYear(false).sortAlbums(it.albums) }
|
||||
|
||||
is ByArtist ->
|
||||
sortParents(albums.groupBy { it.artist }.keys).flatMap {
|
||||
ByYear(false).sortAlbums(it.albums)
|
||||
}
|
||||
is ByYear -> albums.intSort { it.year ?: 0 }
|
||||
}
|
||||
}
|
||||
|
@ -158,9 +157,7 @@ sealed class Sort(open val isAscending: Boolean) {
|
|||
return sortSongs(genre.songs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this sort to it's integer representation.
|
||||
*/
|
||||
/** Convert this sort to it's integer representation. */
|
||||
fun toInt(): Int {
|
||||
return when (this) {
|
||||
is ByName -> INT_NAME
|
||||
|
@ -175,11 +172,10 @@ sealed class Sort(open val isAscending: Boolean) {
|
|||
selector: (T) -> String
|
||||
): List<T> {
|
||||
// Chain whatever item call with sliceArticle for correctness
|
||||
val chained: (T) -> String = {
|
||||
selector(it).sliceArticle()
|
||||
}
|
||||
val chained: (T) -> String = { selector(it).sliceArticle() }
|
||||
|
||||
val comparator = if (asc) {
|
||||
val comparator =
|
||||
if (asc) {
|
||||
compareBy(String.CASE_INSENSITIVE_ORDER, chained)
|
||||
} else {
|
||||
compareByDescending(String.CASE_INSENSITIVE_ORDER, chained)
|
||||
|
@ -192,7 +188,8 @@ sealed class Sort(open val isAscending: Boolean) {
|
|||
asc: Boolean = isAscending,
|
||||
selector: (T) -> Int,
|
||||
): List<T> {
|
||||
val comparator = if (asc) {
|
||||
val comparator =
|
||||
if (asc) {
|
||||
compareBy(selector)
|
||||
} else {
|
||||
compareByDescending(selector)
|
||||
|
@ -227,9 +224,9 @@ sealed class Sort(open val isAscending: Boolean) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Slice a string so that any preceding articles like The/A(n) are truncated.
|
||||
* This is hilariously anglo-centric, but its mostly for MediaStore compat and hopefully
|
||||
* shouldn't run with other languages.
|
||||
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
|
||||
* anglo-centric, but its mostly for MediaStore compat and hopefully shouldn't run with other
|
||||
* languages.
|
||||
*/
|
||||
fun String.sliceArticle(): String {
|
||||
if (length > 5 && startsWith("the ", true)) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* SortHeaderViewHolder.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -53,22 +52,18 @@ abstract class BaseViewHolder<T : Item>(
|
|||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
// Force the layout to *actually* be the screen width
|
||||
binding.root.layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
binding.root.layoutParams =
|
||||
RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the viewholder with whatever [Item] instance that has been specified.
|
||||
* Will call [onBind] on the inheriting ViewHolder.
|
||||
* Bind the viewholder with whatever [Item] instance that has been specified. Will call [onBind]
|
||||
* on the inheriting ViewHolder.
|
||||
* @param data Data that the viewholder should be bound with
|
||||
*/
|
||||
fun bind(data: T) {
|
||||
doOnClick?.let { onClick ->
|
||||
binding.root.setOnClickListener {
|
||||
onClick(data)
|
||||
}
|
||||
}
|
||||
doOnClick?.let { onClick -> binding.root.setOnClickListener { onClick(data) } }
|
||||
|
||||
doOnLongClick?.let { onLongClick ->
|
||||
binding.root.setOnLongClickListener { view ->
|
||||
|
@ -84,16 +79,15 @@ abstract class BaseViewHolder<T : Item>(
|
|||
}
|
||||
|
||||
/**
|
||||
* Function that performs binding operations unique to the inheriting viewholder.
|
||||
* Add any specialized code to an override of this instead of [BaseViewHolder] itself.
|
||||
* Function that performs binding operations unique to the inheriting viewholder. Add any
|
||||
* specialized code to an override of this instead of [BaseViewHolder] itself.
|
||||
*/
|
||||
protected abstract fun onBind(data: T)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shared ViewHolder for a [Song]. Instantiation should be done with [from].
|
||||
*/
|
||||
class SongViewHolder private constructor(
|
||||
/** The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. */
|
||||
class SongViewHolder
|
||||
private constructor(
|
||||
private val binding: ItemSongBinding,
|
||||
doOnClick: (data: Song) -> Unit,
|
||||
doOnLongClick: (view: View, data: Song) -> Unit
|
||||
|
@ -109,26 +103,21 @@ class SongViewHolder private constructor(
|
|||
companion object {
|
||||
const val ITEM_TYPE = 0xA000
|
||||
|
||||
/**
|
||||
* Create an instance of [SongViewHolder]
|
||||
*/
|
||||
/** Create an instance of [SongViewHolder] */
|
||||
fun from(
|
||||
context: Context,
|
||||
doOnClick: (data: Song) -> Unit,
|
||||
doOnLongClick: (view: View, data: Song) -> Unit
|
||||
): SongViewHolder {
|
||||
return SongViewHolder(
|
||||
ItemSongBinding.inflate(context.inflater),
|
||||
doOnClick, doOnLongClick
|
||||
)
|
||||
ItemSongBinding.inflate(context.inflater), doOnClick, doOnLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shared ViewHolder for a [Album]. Instantiation should be done with [from].
|
||||
*/
|
||||
class AlbumViewHolder private constructor(
|
||||
/** The Shared ViewHolder for a [Album]. Instantiation should be done with [from]. */
|
||||
class AlbumViewHolder
|
||||
private constructor(
|
||||
private val binding: ItemAlbumBinding,
|
||||
doOnClick: (data: Album) -> Unit,
|
||||
doOnLongClick: (view: View, data: Album) -> Unit
|
||||
|
@ -142,26 +131,21 @@ class AlbumViewHolder private constructor(
|
|||
companion object {
|
||||
const val ITEM_TYPE = 0xA001
|
||||
|
||||
/**
|
||||
* Create an instance of [AlbumViewHolder]
|
||||
*/
|
||||
/** Create an instance of [AlbumViewHolder] */
|
||||
fun from(
|
||||
context: Context,
|
||||
doOnClick: (data: Album) -> Unit,
|
||||
doOnLongClick: (view: View, data: Album) -> Unit
|
||||
): AlbumViewHolder {
|
||||
return AlbumViewHolder(
|
||||
ItemAlbumBinding.inflate(context.inflater),
|
||||
doOnClick, doOnLongClick
|
||||
)
|
||||
ItemAlbumBinding.inflate(context.inflater), doOnClick, doOnLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shared ViewHolder for a [Artist]. Instantiation should be done with [from].
|
||||
*/
|
||||
class ArtistViewHolder private constructor(
|
||||
/** The Shared ViewHolder for a [Artist]. Instantiation should be done with [from]. */
|
||||
class ArtistViewHolder
|
||||
private constructor(
|
||||
private val binding: ItemArtistBinding,
|
||||
doOnClick: (Artist) -> Unit,
|
||||
doOnLongClick: (view: View, data: Artist) -> Unit
|
||||
|
@ -175,26 +159,21 @@ class ArtistViewHolder private constructor(
|
|||
companion object {
|
||||
const val ITEM_TYPE = 0xA002
|
||||
|
||||
/**
|
||||
* Create an instance of [ArtistViewHolder]
|
||||
*/
|
||||
/** Create an instance of [ArtistViewHolder] */
|
||||
fun from(
|
||||
context: Context,
|
||||
doOnClick: (Artist) -> Unit,
|
||||
doOnLongClick: (view: View, data: Artist) -> Unit
|
||||
): ArtistViewHolder {
|
||||
return ArtistViewHolder(
|
||||
ItemArtistBinding.inflate(context.inflater),
|
||||
doOnClick, doOnLongClick
|
||||
)
|
||||
ItemArtistBinding.inflate(context.inflater), doOnClick, doOnLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shared ViewHolder for a [Genre]. Instantiation should be done with [from].
|
||||
*/
|
||||
class GenreViewHolder private constructor(
|
||||
/** The Shared ViewHolder for a [Genre]. Instantiation should be done with [from]. */
|
||||
class GenreViewHolder
|
||||
private constructor(
|
||||
private val binding: ItemGenreBinding,
|
||||
doOnClick: (Genre) -> Unit,
|
||||
doOnLongClick: (view: View, data: Genre) -> Unit
|
||||
|
@ -208,28 +187,21 @@ class GenreViewHolder private constructor(
|
|||
companion object {
|
||||
const val ITEM_TYPE = 0xA003
|
||||
|
||||
/**
|
||||
* Create an instance of [GenreViewHolder]
|
||||
*/
|
||||
/** Create an instance of [GenreViewHolder] */
|
||||
fun from(
|
||||
context: Context,
|
||||
doOnClick: (Genre) -> Unit,
|
||||
doOnLongClick: (view: View, data: Genre) -> Unit
|
||||
): GenreViewHolder {
|
||||
return GenreViewHolder(
|
||||
ItemGenreBinding.inflate(context.inflater),
|
||||
doOnClick, doOnLongClick
|
||||
)
|
||||
ItemGenreBinding.inflate(context.inflater), doOnClick, doOnLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shared ViewHolder for a [Header]. Instantiation should be done with [from]
|
||||
*/
|
||||
class HeaderViewHolder private constructor(
|
||||
private val binding: ItemHeaderBinding
|
||||
) : BaseViewHolder<Header>(binding) {
|
||||
/** The Shared ViewHolder for a [Header]. Instantiation should be done with [from] */
|
||||
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
|
||||
BaseViewHolder<Header>(binding) {
|
||||
|
||||
override fun onBind(data: Header) {
|
||||
binding.header = data
|
||||
|
@ -238,23 +210,16 @@ class HeaderViewHolder private constructor(
|
|||
companion object {
|
||||
const val ITEM_TYPE = 0xA004
|
||||
|
||||
/**
|
||||
* Create an instance of [HeaderViewHolder]
|
||||
*/
|
||||
/** Create an instance of [HeaderViewHolder] */
|
||||
fun from(context: Context): HeaderViewHolder {
|
||||
return HeaderViewHolder(
|
||||
ItemHeaderBinding.inflate(context.inflater)
|
||||
)
|
||||
return HeaderViewHolder(ItemHeaderBinding.inflate(context.inflater))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shared ViewHolder for an [ActionHeader]. Instantiation should be done with [from]
|
||||
*/
|
||||
class ActionHeaderViewHolder private constructor(
|
||||
private val binding: ItemActionHeaderBinding
|
||||
) : BaseViewHolder<ActionHeader>(binding) {
|
||||
/** The Shared ViewHolder for an [ActionHeader]. Instantiation should be done with [from] */
|
||||
class ActionHeaderViewHolder private constructor(private val binding: ItemActionHeaderBinding) :
|
||||
BaseViewHolder<ActionHeader>(binding) {
|
||||
|
||||
override fun onBind(data: ActionHeader) {
|
||||
binding.header = data
|
||||
|
@ -271,9 +236,7 @@ class ActionHeaderViewHolder private constructor(
|
|||
companion object {
|
||||
const val ITEM_TYPE = 0xA005
|
||||
|
||||
/**
|
||||
* Create an instance of [ActionHeaderViewHolder]
|
||||
*/
|
||||
/** Create an instance of [ActionHeaderViewHolder] */
|
||||
fun from(context: Context): ActionHeaderViewHolder {
|
||||
return ActionHeaderViewHolder(ItemActionHeaderBinding.inflate(context.inflater))
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* ContextUtil.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -39,30 +38,28 @@ import androidx.annotation.PluralsRes
|
|||
import androidx.annotation.Px
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.oxycblt.auxio.MainActivity
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.system.exitProcess
|
||||
import org.oxycblt.auxio.MainActivity
|
||||
|
||||
const val INTENT_REQUEST_CODE = 0xA0A0
|
||||
|
||||
/**
|
||||
* Shortcut to get a [LayoutInflater] from a [Context]
|
||||
*/
|
||||
val Context.inflater: LayoutInflater get() = LayoutInflater.from(this)
|
||||
/** Shortcut to get a [LayoutInflater] from a [Context] */
|
||||
val Context.inflater: LayoutInflater
|
||||
get() = LayoutInflater.from(this)
|
||||
|
||||
/**
|
||||
* Returns whether the current UI is in night mode or not. This will work if the theme is
|
||||
* automatic as well.
|
||||
* Returns whether the current UI is in night mode or not. This will work if the theme is automatic
|
||||
* as well.
|
||||
*/
|
||||
val Context.isNight: Boolean get() =
|
||||
val Context.isNight: Boolean
|
||||
get() =
|
||||
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
/**
|
||||
* Returns if this device is in landscape.
|
||||
*/
|
||||
val Context.isLandscape get() =
|
||||
resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
/** Returns if this device is in landscape. */
|
||||
val Context.isLandscape
|
||||
get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
/**
|
||||
* Convenience method for getting a plural.
|
||||
|
@ -117,7 +114,8 @@ fun Context.getAttrColorSafe(@AttrRes attr: Int): Int {
|
|||
theme.resolveAttribute(attr, resolvedAttr, true)
|
||||
|
||||
// Then convert it to a proper color
|
||||
val color = if (resolvedAttr.resourceId != 0) {
|
||||
val color =
|
||||
if (resolvedAttr.resourceId != 0) {
|
||||
resolvedAttr.resourceId
|
||||
} else {
|
||||
resolvedAttr.data
|
||||
|
@ -183,9 +181,8 @@ fun Context.getDimenOffsetSafe(@DimenRes dimen: Int): Int {
|
|||
|
||||
@Px
|
||||
fun Context.pxOfDp(@Dimension dp: Float): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics
|
||||
).toInt()
|
||||
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
|
||||
.toInt()
|
||||
}
|
||||
|
||||
private fun <T> Context.handleResourceFailure(e: Exception, what: String, default: T): T {
|
||||
|
@ -207,45 +204,36 @@ fun <T : Any> Context.getSystemServiceSafe(serviceClass: KClass<T>): T {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a toast using the provided string resource.
|
||||
*/
|
||||
/** Create a toast using the provided string resource. */
|
||||
fun Context.showToast(@StringRes str: Int) {
|
||||
Toast.makeText(applicationContext, getString(str), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a [PendingIntent] that leads to Auxio's [MainActivity]
|
||||
*/
|
||||
/** Create a [PendingIntent] that leads to Auxio's [MainActivity] */
|
||||
fun Context.newMainIntent(): PendingIntent {
|
||||
return PendingIntent.getActivity(
|
||||
this, INTENT_REQUEST_CODE, Intent(this, MainActivity::class.java),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
else 0
|
||||
)
|
||||
this,
|
||||
INTENT_REQUEST_CODE,
|
||||
Intent(this, MainActivity::class.java),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a broadcast [PendingIntent]
|
||||
*/
|
||||
/** Create a broadcast [PendingIntent] */
|
||||
fun Context.newBroadcastIntent(what: String): PendingIntent {
|
||||
return PendingIntent.getBroadcast(
|
||||
this, INTENT_REQUEST_CODE, Intent(what),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
else 0
|
||||
)
|
||||
this,
|
||||
INTENT_REQUEST_CODE,
|
||||
Intent(what),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-restarts the app. Useful for forcing the app to reload music.
|
||||
*/
|
||||
/** Hard-restarts the app. Useful for forcing the app to reload music. */
|
||||
fun Context.hardRestart() {
|
||||
// Instead of having to do a ton of cleanup and horrible code changes
|
||||
// to restart this application non-destructively, I just restart the UI task [There is only
|
||||
// one, after all] and then kill the application using exitProcess. Works well enough.
|
||||
val intent = Intent(applicationContext, MainActivity::class.java)
|
||||
val intent =
|
||||
Intent(applicationContext, MainActivity::class.java)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
startActivity(intent)
|
||||
exitProcess(0)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* DbUtil.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -23,15 +22,13 @@ import android.database.sqlite.SQLiteDatabase
|
|||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Shortcut for querying all items in a database and running [block] with the cursor returned.
|
||||
* Will not run if the cursor is null.
|
||||
* Shortcut for querying all items in a database and running [block] with the cursor returned. Will
|
||||
* not run if the cursor is null.
|
||||
*/
|
||||
fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
|
||||
query(tableName, null, null, null, null, null, null)?.use(block)
|
||||
|
||||
/**
|
||||
* Assert that we are on a background thread.
|
||||
*/
|
||||
/** Assert that we are on a background thread. */
|
||||
fun assertBackgroundThread() {
|
||||
check(Looper.myLooper() != Looper.getMainLooper()) {
|
||||
"This operation must be ran on a background thread"
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* LogUtil.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -25,14 +24,16 @@ import org.oxycblt.auxio.BuildConfig
|
|||
// Yes, I know timber exists but this does what I need.
|
||||
|
||||
/**
|
||||
* Shortcut method for logging a non-string [obj] to debug. Should only be used for debug preferably.
|
||||
* Shortcut method for logging a non-string [obj] to debug. Should only be used for debug
|
||||
* preferably.
|
||||
*/
|
||||
fun Any.logD(obj: Any) {
|
||||
logD(obj.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut method for logging [msg] to the debug console. Handles debug builds and anonymous objects
|
||||
* Shortcut method for logging [msg] to the debug console. Handles debug builds and anonymous
|
||||
* objects
|
||||
*/
|
||||
fun Any.logD(msg: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
@ -41,16 +42,12 @@ fun Any.logD(msg: String) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut method for logging [msg] as a warning to the console. Handles anonymous objects
|
||||
*/
|
||||
/** Shortcut method for logging [msg] as a warning to the console. Handles anonymous objects */
|
||||
fun Any.logW(msg: String) {
|
||||
Log.w(getName(), msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut method for logging [msg] as an error to the console. Handles anonymous objects
|
||||
*/
|
||||
/** Shortcut method for logging [msg] as an error to the console. Handles anonymous objects */
|
||||
fun Any.logE(msg: String) {
|
||||
Log.e(getName(), msg)
|
||||
}
|
||||
|
@ -77,18 +74,16 @@ private fun Any.getName(): String = "Auxio.${this::class.simpleName ?: "Anonymou
|
|||
* I know that this will not stop you, but consider what you are doing with your life, plagiarizers.
|
||||
* Do you want to live a fulfilling existence on this planet? Or do you want to spend your life
|
||||
* taking work others did and making it objectively worse so you could arbitrage a fraction of a
|
||||
* penny on every AdMob impression you get? You could do so many great things if you simply had
|
||||
* the courage to come up with an idea of your own. If you still want to go on, I guess the only
|
||||
* thing I can say is this: JUNE 1989 TIANAMEN SQUARE PROTESTS AND MASSACRE 六四事件
|
||||
* penny on every AdMob impression you get? You could do so many great things if you simply had the
|
||||
* courage to come up with an idea of your own. If you still want to go on, I guess the only thing I
|
||||
* can say is this: JUNE 1989 TIANAMEN SQUARE PROTESTS AND MASSACRE 六四事件
|
||||
*/
|
||||
private fun basedCopyleftNotice() {
|
||||
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
|
||||
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug"
|
||||
) {
|
||||
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
|
||||
Log.d(
|
||||
"Auxio Project",
|
||||
"Friendly reminder: Auxio is licensed under the " +
|
||||
"GPLv3 and all modifications must be made open source!"
|
||||
)
|
||||
"GPLv3 and all modifications must be made open source!")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* ViewUtil.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -29,10 +28,9 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* Converts this color to a single-color [ColorStateList].
|
||||
*/
|
||||
val @receiver:ColorRes Int.stateList get() = ColorStateList.valueOf(this)
|
||||
/** Converts this color to a single-color [ColorStateList]. */
|
||||
val @receiver:ColorRes Int.stateList
|
||||
get() = ColorStateList.valueOf(this)
|
||||
|
||||
/**
|
||||
* Apply the recommended spans for a [RecyclerView].
|
||||
|
@ -47,7 +45,8 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
|
|||
val mgr = GridLayoutManager(context, spans)
|
||||
|
||||
if (shouldBeFullWidth != null) {
|
||||
mgr.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
mgr.spanSizeLookup =
|
||||
object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
return if (shouldBeFullWidth(position)) spans else 1
|
||||
}
|
||||
|
@ -58,14 +57,12 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a recyclerview can scroll.
|
||||
*/
|
||||
/** Returns whether a recyclerview can scroll. */
|
||||
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height
|
||||
|
||||
/**
|
||||
* Disables drop shadows on a view programmatically in a version-compatible manner.
|
||||
* This only works on Android 9 and above. Below that version, shadows will remain visible.
|
||||
* Disables drop shadows on a view programmatically in a version-compatible manner. This only works
|
||||
* on Android 9 and above. Below that version, shadows will remain visible.
|
||||
*/
|
||||
fun View.disableDropShadowCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
|
@ -77,49 +74,44 @@ fun View.disableDropShadowCompat() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve system bar insets in a version-aware manner. This can be used to apply padding to
|
||||
* a view that properly follows all the frustrating changes that were made between 8-11.
|
||||
* Resolve system bar insets in a version-aware manner. This can be used to apply padding to a view
|
||||
* that properly follows all the frustrating changes that were made between 8-11.
|
||||
*/
|
||||
val WindowInsets.systemBarInsetsCompat: Rect get() {
|
||||
val WindowInsets.systemBarInsetsCompat: Rect
|
||||
get() {
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
getInsets(WindowInsets.Type.systemBars()).run {
|
||||
Rect(left, top, right, bottom)
|
||||
getInsets(WindowInsets.Type.systemBars()).run { Rect(left, top, right, bottom) }
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
@Suppress("DEPRECATION")
|
||||
Rect(
|
||||
systemWindowInsetLeft,
|
||||
systemWindowInsetTop,
|
||||
systemWindowInsetRight,
|
||||
systemWindowInsetBottom
|
||||
)
|
||||
systemWindowInsetBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the system bar insets in a version-aware manner. This can be used to modify the insets
|
||||
* for child views in a way that follows all of the frustrating changes that were made between 8-11.
|
||||
*/
|
||||
fun WindowInsets.replaceSystemBarInsetsCompat(left: Int, top: Int, right: Int, bottom: Int): WindowInsets {
|
||||
fun WindowInsets.replaceSystemBarInsetsCompat(
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int
|
||||
): WindowInsets {
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
WindowInsets.Builder(this)
|
||||
.setInsets(
|
||||
WindowInsets.Type.systemBars(),
|
||||
Insets.of(left, top, right, bottom)
|
||||
)
|
||||
.setInsets(WindowInsets.Type.systemBars(), Insets.of(left, top, right, bottom))
|
||||
.build()
|
||||
}
|
||||
|
||||
else -> {
|
||||
@Suppress("DEPRECATION")
|
||||
replaceSystemWindowInsets(
|
||||
left, top, right, bottom
|
||||
)
|
||||
@Suppress("DEPRECATION") replaceSystemWindowInsets(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Forms.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -28,8 +27,8 @@ import org.oxycblt.auxio.util.newBroadcastIntent
|
|||
import org.oxycblt.auxio.util.newMainIntent
|
||||
|
||||
/**
|
||||
* The default widget is displayed whenever there is no music playing. It just shows the
|
||||
* message "No music playing".
|
||||
* The default widget is displayed whenever there is no music playing. It just shows the message "No
|
||||
* music playing".
|
||||
*/
|
||||
fun createDefaultWidget(context: Context): RemoteViews {
|
||||
return createViews(context, R.layout.widget_default)
|
||||
|
@ -46,9 +45,9 @@ fun createTinyWidget(context: Context, state: WidgetState): RemoteViews {
|
|||
}
|
||||
|
||||
/**
|
||||
* The small widget is for 2x2 widgets and just shows the cover art and playback controls.
|
||||
* This is generally because a Medium widget is too large for this widget size and a text-only
|
||||
* widget is too small for this widget size.
|
||||
* The small widget is for 2x2 widgets and just shows the cover art and playback controls. This is
|
||||
* generally because a Medium widget is too large for this widget size and a text-only widget is too
|
||||
* small for this widget size.
|
||||
*/
|
||||
fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
|
||||
return createViews(context, R.layout.widget_small)
|
||||
|
@ -57,8 +56,8 @@ fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
|
|||
}
|
||||
|
||||
/**
|
||||
* The medium widget is for 2x3 widgets and shows the cover art, title/artist, and three
|
||||
* controls. This is the default widget configuration.
|
||||
* The medium widget is for 2x3 widgets and shows the cover art, title/artist, and three controls.
|
||||
* This is the default widget configuration.
|
||||
*/
|
||||
fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
|
||||
return createViews(context, R.layout.widget_medium)
|
||||
|
@ -66,34 +65,24 @@ fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
|
|||
.applyBasicControls(context, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* The wide widget is for Nx2 widgets and is like the small widget but with more controls.
|
||||
*/
|
||||
/** The wide widget is for Nx2 widgets and is like the small widget but with more controls. */
|
||||
fun createWideWidget(context: Context, state: WidgetState): RemoteViews {
|
||||
return createViews(context, R.layout.widget_wide)
|
||||
.applyCover(context, state)
|
||||
.applyFullControls(context, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* The large widget is for 3x4 widgets and shows all metadata and controls.
|
||||
*/
|
||||
/** The large widget is for 3x4 widgets and shows all metadata and controls. */
|
||||
fun createLargeWidget(context: Context, state: WidgetState): RemoteViews {
|
||||
return createViews(context, R.layout.widget_large)
|
||||
.applyMeta(context, state)
|
||||
.applyFullControls(context, state)
|
||||
}
|
||||
|
||||
private fun createViews(
|
||||
context: Context,
|
||||
@LayoutRes layout: Int
|
||||
): RemoteViews {
|
||||
private fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, layout)
|
||||
|
||||
views.setOnClickPendingIntent(
|
||||
android.R.id.background,
|
||||
context.newMainIntent()
|
||||
)
|
||||
views.setOnClickPendingIntent(android.R.id.background, context.newMainIntent())
|
||||
|
||||
return views
|
||||
}
|
||||
|
@ -112,8 +101,7 @@ private fun RemoteViews.applyCover(context: Context, state: WidgetState): Remote
|
|||
setImageViewBitmap(R.id.widget_cover, state.albumArt)
|
||||
setContentDescription(
|
||||
R.id.widget_cover,
|
||||
context.getString(R.string.desc_album_cover, state.song.resolvedAlbumName)
|
||||
)
|
||||
context.getString(R.string.desc_album_cover, state.song.resolvedAlbumName))
|
||||
} else {
|
||||
setImageViewResource(R.id.widget_cover, R.drawable.ic_widget_album)
|
||||
setContentDescription(R.id.widget_cover, context.getString(R.string.desc_no_cover))
|
||||
|
@ -124,11 +112,7 @@ private fun RemoteViews.applyCover(context: Context, state: WidgetState): Remote
|
|||
|
||||
private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState): RemoteViews {
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_play_pause,
|
||||
context.newBroadcastIntent(
|
||||
PlaybackService.ACTION_PLAY_PAUSE
|
||||
)
|
||||
)
|
||||
R.id.widget_play_pause, context.newBroadcastIntent(PlaybackService.ACTION_PLAY_PAUSE))
|
||||
|
||||
setImageViewResource(
|
||||
R.id.widget_play_pause,
|
||||
|
@ -136,8 +120,7 @@ private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState):
|
|||
R.drawable.ic_pause
|
||||
} else {
|
||||
R.drawable.ic_play
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
@ -146,18 +129,10 @@ private fun RemoteViews.applyBasicControls(context: Context, state: WidgetState)
|
|||
applyPlayControls(context, state)
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_prev,
|
||||
context.newBroadcastIntent(
|
||||
PlaybackService.ACTION_SKIP_PREV
|
||||
)
|
||||
)
|
||||
R.id.widget_skip_prev, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_PREV))
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_next,
|
||||
context.newBroadcastIntent(
|
||||
PlaybackService.ACTION_SKIP_NEXT
|
||||
)
|
||||
)
|
||||
R.id.widget_skip_next, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_NEXT))
|
||||
|
||||
return this
|
||||
}
|
||||
|
@ -166,27 +141,21 @@ private fun RemoteViews.applyFullControls(context: Context, state: WidgetState):
|
|||
applyBasicControls(context, state)
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_loop,
|
||||
context.newBroadcastIntent(
|
||||
PlaybackService.ACTION_LOOP
|
||||
)
|
||||
)
|
||||
R.id.widget_loop, context.newBroadcastIntent(PlaybackService.ACTION_LOOP))
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_shuffle,
|
||||
context.newBroadcastIntent(
|
||||
PlaybackService.ACTION_SHUFFLE
|
||||
)
|
||||
)
|
||||
R.id.widget_shuffle, context.newBroadcastIntent(PlaybackService.ACTION_SHUFFLE))
|
||||
|
||||
// Like notifications, use the remote variants of icons since we really don't want to hack
|
||||
// indicators.
|
||||
val shuffleRes = when {
|
||||
val shuffleRes =
|
||||
when {
|
||||
state.isShuffled -> R.drawable.ic_remote_shuffle_on
|
||||
else -> R.drawable.ic_remote_shuffle_off
|
||||
}
|
||||
|
||||
val loopRes = when (state.loopMode) {
|
||||
val loopRes =
|
||||
when (state.loopMode) {
|
||||
LoopMode.NONE -> R.drawable.ic_remote_loop_off
|
||||
LoopMode.ALL -> R.drawable.ic_loop_on
|
||||
LoopMode.TRACK -> R.drawable.ic_loop_one
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* WidgetController.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -28,12 +27,11 @@ import org.oxycblt.auxio.util.logD
|
|||
/**
|
||||
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
|
||||
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may
|
||||
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound
|
||||
* to without being released.
|
||||
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound to
|
||||
* without being released.
|
||||
*/
|
||||
class WidgetController(private val context: Context) :
|
||||
PlaybackStateManager.Callback,
|
||||
SettingsManager.Callback {
|
||||
PlaybackStateManager.Callback, SettingsManager.Callback {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
private val widget = WidgetProvider()
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* WidgetProvider.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -33,6 +32,7 @@ import androidx.core.graphics.drawable.toBitmap
|
|||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.coil.SquareFrameTransform
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
@ -41,7 +41,6 @@ import org.oxycblt.auxio.util.getDimenSizeSafe
|
|||
import org.oxycblt.auxio.util.isLandscape
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Auxio's one and only appwidget. This widget follows a more unorthodox approach, effectively
|
||||
|
@ -67,38 +66,36 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
}
|
||||
|
||||
loadWidgetBitmap(context, song) { bitmap ->
|
||||
val state = WidgetState(
|
||||
val state =
|
||||
WidgetState(
|
||||
song,
|
||||
bitmap,
|
||||
playbackManager.isPlaying,
|
||||
playbackManager.isShuffling,
|
||||
playbackManager.loopMode
|
||||
)
|
||||
playbackManager.loopMode)
|
||||
|
||||
// Map each widget form to the cells where it would look at least okay.
|
||||
val views = mapOf(
|
||||
val views =
|
||||
mapOf(
|
||||
SizeF(180f, 100f) to createTinyWidget(context, state),
|
||||
SizeF(180f, 152f) to createSmallWidget(context, state),
|
||||
SizeF(272f, 152f) to createWideWidget(context, state),
|
||||
SizeF(180f, 270f) to createMediumWidget(context, state),
|
||||
SizeF(272f, 270f) to createLargeWidget(context, state)
|
||||
)
|
||||
SizeF(272f, 270f) to createLargeWidget(context, state))
|
||||
|
||||
appWidgetManager.applyViewsCompat(context, views)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom function for loading bitmaps to the widget in a way that works with the
|
||||
* widget ImageView instances.
|
||||
* Custom function for loading bitmaps to the widget in a way that works with the widget
|
||||
* ImageView instances.
|
||||
*/
|
||||
private fun loadWidgetBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
|
||||
val coverRequest = ImageRequest.Builder(context)
|
||||
val coverRequest =
|
||||
ImageRequest.Builder(context)
|
||||
.data(song.album)
|
||||
.target(
|
||||
onError = { onDone(null) },
|
||||
onSuccess = { onDone(it.toBitmap()) }
|
||||
)
|
||||
.target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
|
||||
|
||||
// The widget has two distinct styles that we must transform the album art to accommodate:
|
||||
// - Before Android 12, the widget has hard edges, so we don't need to round out the album
|
||||
|
@ -109,15 +106,17 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
// Use RoundedCornersTransformation. This is because our hack to get a 1:1 aspect
|
||||
// ratio on widget ImageViews doesn't actually result in a square ImageView, so
|
||||
// clipToOutline won't work.
|
||||
val transform = RoundedCornersTransformation(
|
||||
context.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius)
|
||||
.toFloat()
|
||||
)
|
||||
val transform =
|
||||
RoundedCornersTransformation(
|
||||
context
|
||||
.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius)
|
||||
.toFloat())
|
||||
|
||||
// The output of RoundedCornersTransformation is dimension-dependent, so scale up the
|
||||
// image to the screen size to ensure consistent radii.
|
||||
val metrics = context.resources.displayMetrics
|
||||
coverRequest.transformations(SquareFrameTransform(), transform)
|
||||
coverRequest
|
||||
.transformations(SquareFrameTransform(), transform)
|
||||
.size(min(metrics.widthPixels, metrics.heightPixels))
|
||||
} else {
|
||||
coverRequest.transformations(SquareFrameTransform())
|
||||
|
@ -132,9 +131,8 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
fun reset(context: Context) {
|
||||
logD("Resetting widget")
|
||||
|
||||
AppWidgetManager.getInstance(context).updateAppWidget(
|
||||
ComponentName(context, this::class.java), createDefaultWidget(context)
|
||||
)
|
||||
AppWidgetManager.getInstance(context)
|
||||
.updateAppWidget(ComponentName(context, this::class.java), createDefaultWidget(context))
|
||||
}
|
||||
|
||||
// --- OVERRIDES ---
|
||||
|
@ -170,8 +168,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
private fun requestUpdate(context: Context) {
|
||||
logD("Sending update intent to PlaybackService")
|
||||
|
||||
val intent = Intent(ACTION_WIDGET_UPDATE)
|
||||
.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
|
||||
val intent = Intent(ACTION_WIDGET_UPDATE).addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
|
||||
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
@ -243,9 +240,8 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
// Default to the smallest view if no layout fits
|
||||
logW("No good widget layout found")
|
||||
|
||||
val minimum = requireNotNull(
|
||||
views.minByOrNull { it.key.width * it.key.height }?.value
|
||||
)
|
||||
val minimum =
|
||||
requireNotNull(views.minByOrNull { it.key.width * it.key.height }?.value)
|
||||
|
||||
updateAppWidget(id, minimum)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* WidgetState.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -12,6 +12,7 @@ buildscript {
|
|||
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
|
||||
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.3.0"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
|
Loading…
Reference in a new issue