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:
OxygenCobalt 2022-03-13 17:32:28 -06:00
parent 627ab97948
commit 33da09a08a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
99 changed files with 2829 additions and 3094 deletions

View file

@ -2,6 +2,9 @@
## dev [v2.2.3, v2.3.0, or v3.0.0] ## dev [v2.2.3, v2.3.0, or v3.0.0]
#### Dev/Meta
- Switched to spotless and ktfmt instead of ktlint
## v2.2.2 ## v2.2.2
#### What's New #### What's New
- New spanish translations and metadata [courtesy of n-berenice] - New spanish translations and metadata [courtesy of n-berenice]

17
app/NOTICE Normal file
View 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/>.
*/

View file

@ -2,6 +2,7 @@ apply plugin: "com.android.application"
apply plugin: "kotlin-android" apply plugin: "kotlin-android"
apply plugin: "kotlin-kapt" apply plugin: "kotlin-kapt"
apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: "androidx.navigation.safeargs.kotlin"
apply plugin: "com.diffplug.spotless"
android { android {
compileSdkVersion 32 compileSdkVersion 32
@ -45,12 +46,8 @@ android {
} }
} }
configurations {
ktlint
}
afterEvaluate { afterEvaluate {
preDebugBuild.dependsOn ktlintFormat preDebugBuild.dependsOn spotlessApply
} }
dependencies { dependencies {
@ -104,24 +101,13 @@ dependencies {
// Material // Material
implementation "com.google.android.material:material:1.6.0-alpha03" 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") { spotless {
description = "Check Kotlin code style." kotlin {
mainClass.set("com.pinterest.ktlint.Main") target "src/**/*.kt"
classpath = configurations.ktlint
args "src/**/*.kt"
}
check.dependsOn ktlint
task ktlintFormat(type: JavaExec, group: "formatting") { ktfmt('0.30').dropboxStyle()
description = "Fix Kotlin code style deviations." licenseHeaderFile("NOTICE")
mainClass.set("com.pinterest.ktlint.Main") }
classpath = configurations.ktlint
args "-F", "src/**/*.kt"
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AuxioApp.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * TODO: Plan for a general UI rework
* ```
* - Refactor fragment class * - Refactor fragment class
* - Remove databinding and dedup layouts * - Remove databinding and dedup layouts
* - Rework RecyclerView management and item dragging * - Rework RecyclerView management and item dragging
* - Rework sealed classes to minimize whens and maximize overrides * - Rework sealed classes to minimize whens and maximize overrides
* ```
*/ */
@Suppress("UNUSED") @Suppress("UNUSED")
class AuxioApp : Application(), ImageLoaderFactory { class AuxioApp : Application(), ImageLoaderFactory {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MainActivity.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* The single [AppCompatActivity] for Auxio. * The single [AppCompatActivity] for Auxio. TODO: Add a new view for crashes with a stack trace
* TODO: Add a new view for crashes with a stack trace * TODO: Custom language support TODO: Rework menus [perhaps add multi-select]
* TODO: Custom language support
* TODO: Rework menus [perhaps add multi-select]
*/ */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by viewModels() private val playbackModel: PlaybackViewModel by viewModels()
@ -52,9 +49,8 @@ class MainActivity : AppCompatActivity() {
setupTheme() setupTheme()
val binding = DataBindingUtil.setContentView<ActivityMainBinding>( val binding =
this, R.layout.activity_main DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
)
applyEdgeToEdgeWindow(binding) applyEdgeToEdgeWindow(binding)
@ -82,9 +78,7 @@ class MainActivity : AppCompatActivity() {
if (action == Intent.ACTION_VIEW && !isConsumed) { if (action == Intent.ACTION_VIEW && !isConsumed) {
// Mark the intent as used so this does not fire again // Mark the intent as used so this does not fire again
intent.putExtra(KEY_INTENT_USED, true) intent.putExtra(KEY_INTENT_USED, true)
intent.data?.let { fileUri -> intent.data?.let { fileUri -> playbackModel.playWithUri(fileUri, this) }
playbackModel.playWithUri(fileUri, this)
}
} }
} }
} }
@ -129,12 +123,10 @@ class MainActivity : AppCompatActivity() {
WindowInsets.Builder() WindowInsets.Builder()
.setInsets( .setInsets(
WindowInsets.Type.systemBars(), WindowInsets.Type.systemBars(),
insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()))
)
.setInsets( .setInsets(
WindowInsets.Type.systemGestures(), WindowInsets.Type.systemGestures(),
insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemGestures()) insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemGestures()))
)
.build() .build()
.applyLeftRightInsets(binding) .applyLeftRightInsets(binding)
} }
@ -144,12 +136,10 @@ class MainActivity : AppCompatActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
binding.root.apply { binding.root.apply {
systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_STABLE View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
setOnApplyWindowInsetsListener { _, insets -> setOnApplyWindowInsetsListener { _, insets -> insets.applyLeftRightInsets(binding) }
insets.applyLeftRightInsets(binding)
}
} }
} }
} }
@ -157,10 +147,7 @@ class MainActivity : AppCompatActivity() {
private fun WindowInsets.applyLeftRightInsets(binding: ViewBinding): WindowInsets { private fun WindowInsets.applyLeftRightInsets(binding: ViewBinding): WindowInsets {
val bars = systemBarInsetsCompat val bars = systemBarInsetsCompat
binding.root.updatePadding( binding.root.updatePadding(left = bars.left, right = bars.right)
left = bars.left,
right = bars.right
)
return replaceSystemBarInsetsCompat(0, bars.top, 0, bars.bottom) return replaceSystemBarInsetsCompat(0, bars.top, 0, bars.bottom)
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MainFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.logW
/** /**
* A wrapper around the home fragment that shows the playback fragment and controls * A wrapper around the home fragment that shows the playback fragment and controls the more
* the more high-level navigation features. * high-level navigation features.
* @author OxygenCobalt * @author OxygenCobalt TODO: Add a new view with a stack trace whenever the music loading process
* TODO: Add a new view with a stack trace whenever the music loading process fails. * fails.
*/ */
class MainFragment : Fragment() { class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
@ -58,9 +57,8 @@ class MainFragment : Fragment() {
val binding = FragmentMainBinding.inflate(inflater) val binding = FragmentMainBinding.inflate(inflater)
// Build the permission launcher here as you can only do it in onCreateView/onCreate // Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher = registerForActivityResult( val permLauncher =
ActivityResultContracts.RequestPermission() registerForActivityResult(ActivityResultContracts.RequestPermission()) {
) {
musicModel.reloadMusic(requireContext()) musicModel.reloadMusic(requireContext())
} }
@ -68,12 +66,9 @@ class MainFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
requireActivity().onBackPressedDispatcher.addCallback( requireActivity()
viewLifecycleOwner, .onBackPressedDispatcher
Callback(binding).also { .addCallback(viewLifecycleOwner, Callback(binding).also { callback = it })
callback = it
}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Auxio's layout completely breaks down when it's window is resized too small, // 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 -> { is MusicStore.Response.Err -> {
logW("Received Error") 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_MUSIC -> R.string.err_no_music
MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms
MusicStore.ErrorKind.FAILED -> R.string.err_load_failed MusicStore.ErrorKind.FAILED -> R.string.err_load_failed
} }
val snackbar = Snackbar.make( val snackbar =
binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE Snackbar.make(binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE)
)
when (response.kind) { when (response.kind) {
MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> { MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> {
@ -126,7 +121,6 @@ class MainFragment : Fragment() {
musicModel.reloadMusic(requireContext()) musicModel.reloadMusic(requireContext())
} }
} }
MusicStore.ErrorKind.NO_PERMS -> { MusicStore.ErrorKind.NO_PERMS -> {
snackbar.setAction(R.string.lbl_grant) { snackbar.setAction(R.string.lbl_grant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
@ -164,7 +158,8 @@ class MainFragment : Fragment() {
if (!binding.playbackLayout.collapse()) { if (!binding.playbackLayout.collapse()) {
val navController = binding.exploreNavHost.findNavController() val navController = binding.exploreNavHost.findNavController()
if (navController.currentDestination?.id == navController.graph.startDestinationId) { if (navController.currentDestination?.id ==
navController.graph.startDestinationId) {
isEnabled = false isEnabled = false
requireActivity().onBackPressed() requireActivity().onBackPressed()
isEnabled = true isEnabled = true

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Accent.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 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_red,
R.string.clr_pink, R.string.clr_pink,
R.string.clr_purple, R.string.clr_purple,
@ -39,9 +40,10 @@ private val ACCENT_NAMES = arrayOf(
R.string.clr_orange, R.string.clr_orange,
R.string.clr_brown, R.string.clr_brown,
R.string.clr_grey, R.string.clr_grey,
) )
private val ACCENT_THEMES = arrayOf( private val ACCENT_THEMES =
arrayOf(
R.style.Theme_Auxio_Red, R.style.Theme_Auxio_Red,
R.style.Theme_Auxio_Pink, R.style.Theme_Auxio_Pink,
R.style.Theme_Auxio_Purple, R.style.Theme_Auxio_Purple,
@ -58,9 +60,10 @@ private val ACCENT_THEMES = arrayOf(
R.style.Theme_Auxio_Orange, R.style.Theme_Auxio_Orange,
R.style.Theme_Auxio_Brown, R.style.Theme_Auxio_Brown,
R.style.Theme_Auxio_Grey, 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_Red,
R.style.Theme_Auxio_Black_Pink, R.style.Theme_Auxio_Black_Pink,
R.style.Theme_Auxio_Black_Purple, 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_Orange,
R.style.Theme_Auxio_Black_Brown, R.style.Theme_Auxio_Black_Brown,
R.style.Theme_Auxio_Black_Grey, R.style.Theme_Auxio_Black_Grey,
) )
private val ACCENT_PRIMARY_COLORS = arrayOf( private val ACCENT_PRIMARY_COLORS =
arrayOf(
R.color.red_primary, R.color.red_primary,
R.color.pink_primary, R.color.pink_primary,
R.color.purple_primary, R.color.purple_primary,
@ -96,12 +100,12 @@ private val ACCENT_PRIMARY_COLORS = arrayOf(
R.color.orange_primary, R.color.orange_primary,
R.color.brown_primary, R.color.brown_primary,
R.color.grey_primary, R.color.grey_primary,
) )
/** /**
* The data object for an accent. In the UI this is known as a "Color Scheme." * The data object for an accent. In the UI this is known as a "Color Scheme." This can be nominally
* This can be nominally used to gleam some attributes about a given color scheme, but this * used to gleam some attributes about a given color scheme, but this is not recommended. Attributes
* is not recommended. Attributes are the better option in nearly all cases. * are the better option in nearly all cases.
* *
* @property name The name of this accent * @property name The name of this accent
* @property theme The theme resource for this accent * @property theme The theme resource for this accent
@ -110,8 +114,12 @@ private val ACCENT_PRIMARY_COLORS = arrayOf(
* @author OxygenCobalt * @author OxygenCobalt
*/ */
data class Accent(val index: Int) { data class Accent(val index: Int) {
val name: Int get() = ACCENT_NAMES[index] val name: Int
val theme: Int get() = ACCENT_THEMES[index] get() = ACCENT_NAMES[index]
val blackTheme: Int get() = ACCENT_BLACK_THEMES[index] val theme: Int
val primary: Int get() = ACCENT_PRIMARY_COLORS[index] get() = ACCENT_THEMES[index]
val blackTheme: Int
get() = ACCENT_BLACK_THEMES[index]
val primary: Int
get() = ACCENT_PRIMARY_COLORS[index]
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AccentAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * @author OxygenCobalt
* @param onSelect What to do when an accent is selected. * @param onSelect What to do when an accent is selected.
*/ */
class AccentAdapter( class AccentAdapter(private var curAccent: Accent, private val onSelect: (accent: Accent) -> Unit) :
private var curAccent: Accent, RecyclerView.Adapter<AccentAdapter.ViewHolder>() {
private val onSelect: (accent: Accent) -> Unit
) : RecyclerView.Adapter<AccentAdapter.ViewHolder>() {
private var selectedViewHolder: ViewHolder? = null private var selectedViewHolder: ViewHolder? = null
override fun getItemCount(): Int = ACCENT_COUNT override fun getItemCount(): Int = ACCENT_COUNT
@ -54,9 +51,8 @@ class AccentAdapter(
onSelect(accent) onSelect(accent)
} }
inner class ViewHolder( inner class ViewHolder(private val binding: ItemAccentBinding) :
private val binding: ItemAccentBinding RecyclerView.ViewHolder(binding.root) {
) : RecyclerView.ViewHolder(binding.root) {
fun bind(accent: Accent) { fun bind(accent: Accent) {
setSelected(accent == curAccent) setSelected(accent == curAccent)
@ -77,7 +73,8 @@ class AccentAdapter(
val context = binding.accent.context val context = binding.accent.context
binding.accent.isEnabled = !isSelected binding.accent.isEnabled = !isSelected
binding.accent.imageTintList = if (isSelected) { binding.accent.imageTintList =
if (isSelected) {
// Switch out the currently selected ViewHolder with this one. // Switch out the currently selected ViewHolder with this one.
selectedViewHolder?.setSelected(false) selectedViewHolder?.setSelected(false)
selectedViewHolder = this selectedViewHolder = this

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AccentDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -52,7 +51,8 @@ class AccentCustomizeDialog : LifecycleDialog() {
// --- UI SETUP --- // --- UI SETUP ---
binding.accentRecycler.apply { binding.accentRecycler.apply {
adapter = AccentAdapter(pendingAccent) { accent -> adapter =
AccentAdapter(pendingAccent) { accent ->
logD("Switching selected accent to $accent") logD("Switching selected accent to $accent")
pendingAccent = accent pendingAccent = accent
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AutoGridLayoutManager.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 android.util.AttributeSet
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.pxOfDp
import kotlin.math.max 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 * A sub-class of [GridLayoutManager] that automatically sets the spans so that they fit the width
* of the RecyclerView. * of the RecyclerView. Adapted from this StackOverflow answer:
* Adapted from this StackOverflow answer: https://stackoverflow.com/a/30256880/14143986 * https://stackoverflow.com/a/30256880/14143986
*/ */
class AccentGridLayoutManager( class AccentGridLayoutManager(
context: Context, context: Context,

View file

@ -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 package org.oxycblt.auxio.coil
import android.content.Context import android.content.Context
@ -5,6 +22,7 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas import android.graphics.Canvas
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import coil.decode.DataSource import coil.decode.DataSource
import coil.decode.ImageSource 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.MetadataRetriever
import com.google.android.exoplayer2.metadata.flac.PictureFrame import com.google.android.exoplayer2.metadata.flac.PictureFrame
import com.google.android.exoplayer2.metadata.id3.ApicFrame import com.google.android.exoplayer2.metadata.id3.ApicFrame
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.buffer import okio.buffer
@ -28,22 +48,17 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW 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. * The base implementation for all image fetchers in Auxio.
* @author OxygenCobalt * @author OxygenCobalt TODO: Artist images
* TODO: Artist images
*/ */
abstract class BaseFetcher : Fetcher { abstract class BaseFetcher : Fetcher {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
/** /**
* Fetch the artwork of an [album]. * Fetch the artwork of an [album]. This call respects user configuration and has proper
* This call respects user configuration and has proper redundancy in the case that * redundancy in the case that an API fails to load.
* an API fails to load.
*/ */
protected suspend fun fetchArt(context: Context, album: Album): InputStream? { protected suspend fun fetchArt(context: Context, album: Album): InputStream? {
if (!settingsManager.showCovers) { if (!settingsManager.showCovers) {
@ -67,9 +82,7 @@ abstract class BaseFetcher : Fetcher {
val uri = data.albumCoverUri val uri = data.albumCoverUri
// Eliminate any chance that this blocking call might mess up the cancellation process // Eliminate any chance that this blocking call might mess up the cancellation process
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
context.contentResolver.openInputStream(uri)
}
} }
private suspend fun fetchQualityCovers(context: Context, album: Album): InputStream? { 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 // Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts. // 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 // If its null [i.e there is no embedded cover], than just ignore it and move on
return ext.embeddedPicture?.let { coverBytes -> return ext.embeddedPicture?.let { coverBytes -> ByteArrayInputStream(coverBytes) }
ByteArrayInputStream(coverBytes)
}
} }
} }
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? { private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri val uri = album.songs[0].uri
val future = MetadataRetriever.retrieveMetadata( val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri))
context, MediaItem.fromUri(uri)
)
// future.get is a blocking call that makes us spin until the future is done. // 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 // 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 // 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. // sure that the runner can do other coroutines.
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
val tracks = withContext(Dispatchers.IO) { val tracks =
withContext(Dispatchers.IO) {
try { try {
future.get() future.get()
} catch (e: Exception) { } 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 * Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
* https://github.com/kabouzeid/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) { if (streams.size < 4) {
return streams.firstOrNull()?.let { stream -> return streams.firstOrNull()?.let { stream ->
return SourceResult( return SourceResult(
source = ImageSource(stream.source().buffer(), context), source = ImageSource(stream.source().buffer(), context),
mimeType = null, 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 // get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a
// 512x512 mosaic. // 512x512 mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize()) val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize = Size( val mosaicFrameSize =
Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2) Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
)
val mosaicBitmap = Bitmap.createBitmap( val mosaicBitmap =
mosaicSize.width, Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
mosaicSize.height,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(mosaicBitmap) val canvas = Canvas(mosaicBitmap)
var x = 0 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 // Run the bitmap through a transform to make sure it's a square of the desired
// resolution. // resolution.
val bitmap = SquareFrameTransform.INSTANCE val bitmap =
.transform( SquareFrameTransform.INSTANCE.transform(
BitmapFactory.decodeStream(stream), BitmapFactory.decodeStream(stream), mosaicFrameSize)
mosaicFrameSize
)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
@ -261,8 +268,7 @@ abstract class BaseFetcher : Fetcher {
return DrawableResult( return DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources), drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true, isSampled = true,
dataSource = DataSource.DISK dataSource = DataSource.DISK)
)
} }
private fun Dimension.mosaicSize(): Int { private fun Dimension.mosaicSize(): Int {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* CoilUtils.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 --- // --- BINDING ADAPTERS ---
/** /** Bind the album art for a [song]. */
* Bind the album art for a [song].
*/
@BindingAdapter("albumArt") @BindingAdapter("albumArt")
fun ImageView.bindAlbumArt(song: Song?) = load(song, R.drawable.ic_album) 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") @BindingAdapter("albumArt")
fun ImageView.bindAlbumArt(album: Album?) = load(album, R.drawable.ic_album) 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") @BindingAdapter("artistImage")
fun ImageView.bindArtistImage(artist: Artist?) = load(artist, R.drawable.ic_artist) 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") @BindingAdapter("genreImage")
fun ImageView.bindGenreImage(genre: Genre?) = load(genre, R.drawable.ic_genre) 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 * Get a bitmap for a [song]. [onDone] will be called with the loaded bitmap, or null if loading
* failed/shouldn't occur. * failed/shouldn't occur. **This not meant for UIs, instead use the Binding Adapters.**
* **This not meant for UIs, instead use the Binding Adapters.**
*/ */
fun loadBitmap( fun loadBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
context: Context,
song: Song,
onDone: (Bitmap?) -> Unit
) {
context.imageLoader.enqueue( context.imageLoader.enqueue(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(song.album) .data(song.album)
.size(Size.ORIGINAL) .size(Size.ORIGINAL)
.transformations(SquareFrameTransform()) .transformations(SquareFrameTransform())
.target( .target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
onError = { onDone(null) }, .build())
onSuccess = { onDone(it.toBitmap()) }
)
.build()
)
} }

View file

@ -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 package org.oxycblt.auxio.coil
import coil.decode.DataSource import coil.decode.DataSource
@ -9,8 +26,8 @@ import coil.transition.Transition
import coil.transition.TransitionTarget import coil.transition.TransitionTarget
/** /**
* A copy of [CrossfadeTransition.Factory] that applies a transition to error results. * A copy of [CrossfadeTransition.Factory] that applies a transition to error results. You know.
* You know. Like they used to. * Like they used to.
* @author Coil Team * @author Coil Team
*/ */
class CrossfadeFactory : Transition.Factory { class CrossfadeFactory : Transition.Factory {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Fetchers.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.fetch.SourceResult
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import kotlin.math.min
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.music.Album 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.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.Sort 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. * Fetcher that returns the album art for a given [Album] or [Song], depending on the factory used.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class AlbumArtFetcher private constructor( class AlbumArtFetcher private constructor(private val context: Context, private val album: Album) :
private val context: Context, BaseFetcher() {
private val album: Album
) : BaseFetcher() {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
return fetchArt(context, album)?.let { stream -> return fetchArt(context, album)?.let { stream ->
SourceResult( SourceResult(
source = ImageSource(stream.source().buffer(), context), source = ImageSource(stream.source().buffer(), context),
mimeType = null, mimeType = null,
dataSource = DataSource.DISK dataSource = DataSource.DISK)
)
} }
} }
@ -71,17 +67,15 @@ class AlbumArtFetcher private constructor(
* Fetcher that fetches the image for an [Artist] * Fetcher that fetches the image for an [Artist]
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ArtistImageFetcher private constructor( class ArtistImageFetcher
private constructor(
private val context: Context, private val context: Context,
private val size: Size, private val size: Size,
private val artist: Artist, private val artist: Artist,
) : BaseFetcher() { ) : BaseFetcher() {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
val albums = Sort.ByName(true) val albums = Sort.ByName(true).sortAlbums(artist.albums)
.sortAlbums(artist.albums) val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
val results = albums.mapAtMost(4) { album ->
fetchArt(context, album)
}
return createMosaic(context, results, size) return createMosaic(context, results, size)
} }
@ -97,7 +91,8 @@ class ArtistImageFetcher private constructor(
* Fetcher that fetches the image for a [Genre] * Fetcher that fetches the image for a [Genre]
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class GenreImageFetcher private constructor( class GenreImageFetcher
private constructor(
private val context: Context, private val context: Context,
private val size: Size, private val size: Size,
private val genre: Genre, private val genre: Genre,
@ -105,9 +100,7 @@ class GenreImageFetcher private constructor(
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
// We don't need to sort here, as the way we // We don't need to sort here, as the way we
val albums = genre.songs.groupBy { it.album }.keys val albums = genre.songs.groupBy { it.album }.keys
val results = albums.mapAtMost(4) { album -> val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
fetchArt(context, album)
}
return createMosaic(context, results, size) 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. * Map at most [n] items from a collection. [transform] is called for each item that is eligible. If
* If null is returned, then that item will be skipped. * 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 until = min(size, n)
val out = mutableListOf<R>() val out = mutableListOf<R>()
@ -132,9 +128,7 @@ private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(n: Int, transform:
break break
} }
transform(item)?.let { transform(item)?.let { out.add(it) }
out.add(it)
}
} }
return out return out

View file

@ -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 package org.oxycblt.auxio.coil
import coil.key.Keyer import coil.key.Keyer
@ -5,9 +22,7 @@ import coil.request.Options
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
/** /** A basic keyer for music data. */
* A basic keyer for music data.
*/
class MusicKeyer : Keyer<Music> { class MusicKeyer : Keyer<Music> {
override fun key(data: Music, options: Options): String { override fun key(data: Music, options: Options): String {
return if (data is Song) { return if (data is Song) {

View file

@ -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 package org.oxycblt.auxio.coil
import android.content.Context import android.content.Context
@ -11,21 +28,21 @@ import org.oxycblt.auxio.util.getColorSafe
import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.stateList
/** /**
* An [AppCompatImageView] that applies the specified cornerRadius attribute if the user * An [AppCompatImageView] that applies the specified cornerRadius attribute if the user has enabled
* has enabled the "Round album covers" option. We don't round album covers by default as * the "Round album covers" option. We don't round album covers by default as it desecrates album
* it desecrates album artwork, but if the user desires it we do have an option to enable it. * artwork, but if the user desires it we do have an option to enable it.
*/ */
class RoundableImageView @JvmOverloads constructor( class RoundableImageView
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@AttrRes defStyleAttr: Int = 0 AppCompatImageView(context, attrs, defStyleAttr) {
) : AppCompatImageView(context, attrs, defStyleAttr) {
init { init {
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.RoundableImageView) val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.RoundableImageView)
val cornerRadius = styledAttrs.getDimension(R.styleable.RoundableImageView_cornerRadius, 0f) val cornerRadius = styledAttrs.getDimension(R.styleable.RoundableImageView_cornerRadius, 0f)
styledAttrs.recycle() styledAttrs.recycle()
background = MaterialShapeDrawable().apply { background =
MaterialShapeDrawable().apply {
setCornerSize(cornerRadius) setCornerSize(cornerRadius)
fillColor = context.getColorSafe(android.R.color.transparent).stateList fillColor = context.getColorSafe(android.R.color.transparent).stateList
} }

View file

@ -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 package org.oxycblt.auxio.coil
import android.graphics.Bitmap import android.graphics.Bitmap
@ -7,8 +24,8 @@ import coil.transform.Transformation
import kotlin.math.min import kotlin.math.min
/** /**
* A transformation that performs a center crop-style transformation on an image, however unlike * A transformation that performs a center crop-style transformation on an image, however unlike the
* the actual ScaleType, this isn't affected by any hacks we do with ImageView itself. * actual ScaleType, this isn't affected by any hacks we do with ImageView itself.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SquareFrameTransform : Transformation { class SquareFrameTransform : Transformation {
@ -29,12 +46,7 @@ class SquareFrameTransform : Transformation {
if (dstSize != wantedWidth || dstSize != wantedHeight) { if (dstSize != wantedWidth || dstSize != wantedHeight) {
// Desired size differs from the cropped size, resize the bitmap. // Desired size differs from the cropped size, resize the bitmap.
return Bitmap.createScaledBitmap( return Bitmap.createScaledBitmap(dst, wantedWidth, wantedHeight, true)
dst,
wantedWidth,
wantedHeight,
true
)
} }
return dst return dst

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumDetailFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -58,11 +57,12 @@ class AlbumDetailFragment : DetailFragment() {
detailModel.setAlbum(args.albumId) detailModel.setAlbum(args.albumId)
val binding = FragmentDetailBinding.inflate(layoutInflater) val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = AlbumDetailAdapter( val detailAdapter =
playbackModel, detailModel, AlbumDetailAdapter(
playbackModel,
detailModel,
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) }, 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 --- // --- UI SETUP ---
@ -75,13 +75,11 @@ class AlbumDetailFragment : DetailFragment() {
requireContext().showToast(R.string.lbl_queue_added) requireContext().showToast(R.string.lbl_queue_added)
true true
} }
R.id.action_queue_add -> { R.id.action_queue_add -> {
playbackModel.addToQueue(detailModel.curAlbum.value!!) playbackModel.addToQueue(detailModel.curAlbum.value!!)
requireContext().showToast(R.string.lbl_queue_added) requireContext().showToast(R.string.lbl_queue_added)
true true
} }
else -> false else -> false
} }
} }
@ -95,15 +93,11 @@ class AlbumDetailFragment : DetailFragment() {
// -- DETAILVIEWMODEL SETUP --- // -- DETAILVIEWMODEL SETUP ---
detailModel.albumData.observe(viewLifecycleOwner) { data -> detailModel.albumData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) }
detailAdapter.submitList(data)
}
detailModel.showMenu.observe(viewLifecycleOwner) { config -> detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) { if (config != null) {
showMenu(config) { id -> showMenu(config) { id -> id == R.id.option_sort_asc }
id == R.id.option_sort_asc
}
} }
} }
@ -118,9 +112,8 @@ class AlbumDetailFragment : DetailFragment() {
detailModel.finishNavToItem() detailModel.finishNavToItem()
} else { } else {
logD("Navigating to another album") logD("Navigating to another album")
findNavController().navigate( findNavController()
AlbumDetailFragmentDirections.actionShowAlbum(item.album.id) .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.id))
)
} }
} }
@ -133,20 +126,17 @@ class AlbumDetailFragment : DetailFragment() {
detailModel.finishNavToItem() detailModel.finishNavToItem()
} else { } else {
logD("Navigating to another album") logD("Navigating to another album")
findNavController().navigate( findNavController()
AlbumDetailFragmentDirections.actionShowAlbum(item.id) .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id))
)
} }
} }
// Always launch a new ArtistDetailFragment. // Always launch a new ArtistDetailFragment.
is Artist -> { is Artist -> {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController().navigate( findNavController()
AlbumDetailFragmentDirections.actionShowArtist(item.id) .navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id))
)
} }
null -> {} null -> {}
else -> logW("Unsupported navigation item ${item::class.java}") else -> logW("Unsupported navigation item ${item::class.java}")
} }
@ -158,8 +148,7 @@ class AlbumDetailFragment : DetailFragment() {
updateQueueActions(song, binding) updateQueueActions(song, binding)
if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM && 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) detailAdapter.highlightSong(song, binding.detailRecycler)
} else { } else {
// Clear the ViewHolders if the mode isn't ALL_SONGS // Clear the ViewHolders if the mode isn't ALL_SONGS
@ -172,9 +161,7 @@ class AlbumDetailFragment : DetailFragment() {
return binding.root return binding.root
} }
/** /** Updates the queue actions when */
* Updates the queue actions when
*/
private fun updateQueueActions(song: Song?, binding: FragmentDetailBinding) { private fun updateQueueActions(song: Song?, binding: FragmentDetailBinding) {
for (item in binding.detailToolbar.menu.children) { for (item in binding.detailToolbar.menu.children) {
if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) { 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( private fun scrollToItem(
id: Long, id: Long,
binding: FragmentDetailBinding, binding: FragmentDetailBinding,
@ -198,8 +183,7 @@ class AlbumDetailFragment : DetailFragment() {
binding.detailRecycler.post { binding.detailRecycler.post {
// Make sure to increment the position to make up for the detail header // Make sure to increment the position to make up for the detail header
binding.detailRecycler.layoutManager?.startSmoothScroll( binding.detailRecycler.layoutManager?.startSmoothScroll(
CenterSmoothScroller(requireContext(), pos) CenterSmoothScroller(requireContext(), pos))
)
// If the recyclerview can scroll, its certain that it will have to scroll to // 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 // 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 * [LinearSmoothScroller] subclass that centers the item on the screen instead of snapping to
* snapping to the top or bottom. * the top or bottom.
*/ */
private class CenterSmoothScroller( private class CenterSmoothScroller(context: Context, target: Int) :
context: Context, LinearSmoothScroller(context) {
target: Int
) : LinearSmoothScroller(context) {
init { init {
targetPosition = target targetPosition = target
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ArtistDetailFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -53,24 +52,19 @@ class ArtistDetailFragment : DetailFragment() {
detailModel.setArtist(args.artistId) detailModel.setArtist(args.artistId)
val binding = FragmentDetailBinding.inflate(layoutInflater) val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = ArtistDetailAdapter( val detailAdapter =
ArtistDetailAdapter(
playbackModel, playbackModel,
doOnClick = { data -> doOnClick = { data ->
if (!detailModel.isNavigating) { if (!detailModel.isNavigating) {
detailModel.setNavigating(true) detailModel.setNavigating(true)
findNavController().navigate( findNavController()
ArtistDetailFragmentDirections.actionShowAlbum(data.id) .navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id))
)
} }
}, },
doOnSongClick = { data -> doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) },
playbackModel.playSong(data, PlaybackMode.IN_ARTIST) doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) })
},
doOnLongClick = { view, data ->
newMenu(view, data, ActionMenu.FLAG_IN_ARTIST)
}
)
// --- UI SETUP --- // --- UI SETUP ---
@ -91,9 +85,7 @@ class ArtistDetailFragment : DetailFragment() {
detailModel.showMenu.observe(viewLifecycleOwner) { config -> detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) { if (config != null) {
showMenu(config) { id -> showMenu(config) { id -> id != R.id.option_sort_artist }
id != R.id.option_sort_artist
}
} }
} }
@ -106,26 +98,20 @@ class ArtistDetailFragment : DetailFragment() {
detailModel.finishNavToItem() detailModel.finishNavToItem()
} else { } else {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController().navigate( findNavController()
ArtistDetailFragmentDirections.actionShowArtist(item.id) .navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id))
)
} }
} }
is Album -> { is Album -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController().navigate( findNavController()
ArtistDetailFragmentDirections.actionShowAlbum(item.id) .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
)
} }
is Song -> { is Song -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController().navigate( findNavController()
ArtistDetailFragmentDirections.actionShowAlbum(item.album.id) .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.id))
)
} }
null -> {} null -> {}
else -> logW("Unsupported navigation item ${item::class.java}") else -> logW("Unsupported navigation item ${item::class.java}")
} }
@ -143,8 +129,7 @@ class ArtistDetailFragment : DetailFragment() {
// Highlight songs if they are being played // Highlight songs if they are being played
playbackModel.song.observe(viewLifecycleOwner) { song -> playbackModel.song.observe(viewLifecycleOwner) { song ->
if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST && 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) detailAdapter.highlightSong(song, binding.detailRecycler)
} else { } else {
// Clear the ViewHolders if the mode isn't ALL_SONGS // Clear the ViewHolders if the mode isn't ALL_SONGS

View file

@ -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 package org.oxycblt.auxio.detail
import android.animation.ValueAnimator import android.animation.ValueAnimator
@ -12,24 +29,23 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import java.lang.Exception
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.EdgeAppBarLayout import org.oxycblt.auxio.ui.EdgeAppBarLayout
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow import org.oxycblt.auxio.util.logTraceOrThrow
import java.lang.Exception
/** /**
* An [EdgeAppBarLayout] variant that also shows the name of the toolbar whenever the detail * 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 * 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. * CollapsingToolbarLayout since that thing is a mess with crippling bugs and state issues. This
* This just works. * just works.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class DetailAppBarLayout @JvmOverloads constructor( class DetailAppBarLayout
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@AttrRes defStyleAttr: Int = 0 EdgeAppBarLayout(context, attrs, defStyleAttr) {
) : EdgeAppBarLayout(context, attrs, defStyleAttr) {
private var mTitleView: AppCompatTextView? = null private var mTitleView: AppCompatTextView? = null
private var mRecycler: RecyclerView? = null private var mRecycler: RecyclerView? = null
@ -50,7 +66,8 @@ class DetailAppBarLayout @JvmOverloads constructor(
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar) val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
// Reflect to get the actual title view to do transformations on // Reflect to get the actual title view to do transformations on
val newTitleView = try { val newTitleView =
try {
Toolbar::class.java.getDeclaredField("mTitleTextView").run { Toolbar::class.java.getDeclaredField("mTitleTextView").run {
isAccessible = true isAccessible = true
get(toolbar) as AppCompatTextView get(toolbar) as AppCompatTextView
@ -103,21 +120,21 @@ class DetailAppBarLayout @JvmOverloads constructor(
if (titleView?.alpha == to) return if (titleView?.alpha == to) return
mTitleAnimator = ValueAnimator.ofFloat(from, to).apply { mTitleAnimator =
addUpdateListener { ValueAnimator.ofFloat(from, to).apply {
titleView?.alpha = it.animatedValue as Float 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() start()
} }
} }
class Behavior @JvmOverloads constructor( class Behavior
context: Context? = null, @JvmOverloads
attrs: AttributeSet? = null constructor(context: Context? = null, attrs: AttributeSet? = null) :
) : AppBarLayout.Behavior(context, attrs) { AppBarLayout.Behavior(context, attrs) {
override fun onNestedPreScroll( override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout, coordinatorLayout: CoordinatorLayout,
child: AppBarLayout, child: AppBarLayout,
@ -132,8 +149,8 @@ class DetailAppBarLayout @JvmOverloads constructor(
val appBar = child as DetailAppBarLayout val appBar = child as DetailAppBarLayout
val recycler = appBar.findRecyclerView() val recycler = appBar.findRecyclerView()
val showTitle = (recycler.layoutManager as LinearLayoutManager) val showTitle =
.findFirstVisibleItemPosition() > 0 (recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0
appBar.setTitleVisibility(showTitle) appBar.setTitleVisibility(showTitle)
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* DetailFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -70,21 +69,15 @@ abstract class DetailFragment : Fragment() {
inflateMenu(menuId) inflateMenu(menuId)
} }
setNavigationOnClickListener { setNavigationOnClickListener { findNavController().navigateUp() }
findNavController().navigateUp()
}
onMenuClick?.let { onClick -> onMenuClick?.let { onClick ->
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item -> onClick(item.itemId) }
onClick(item.itemId)
}
} }
} }
} }
/** /** Shortcut method for recyclerview setup */
* Shortcut method for recyclerview setup
*/
protected fun setupRecycler( protected fun setupRecycler(
binding: FragmentDetailBinding, binding: FragmentDetailBinding,
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>, detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
@ -99,10 +92,14 @@ abstract class DetailFragment : Fragment() {
/** /**
* Shortcut method for spinning up the sorting [PopupMenu] * 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 * @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]") logD("Launching menu [$config]")
PopupMenu(config.anchor.context, config.anchor).apply { PopupMenu(config.anchor.context, config.anchor).apply {
@ -120,9 +117,7 @@ abstract class DetailFragment : Fragment() {
true true
} }
setOnDismissListener { setOnDismissListener { detailModel.finishShowMenu(null) }
detailModel.finishShowMenu(null)
}
if (showItem != null) { if (showItem != null) {
for (item in menu.children) { for (item in menu.children) {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* DetailViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -47,22 +46,26 @@ class DetailViewModel : ViewModel() {
// --- CURRENT VALUES --- // --- CURRENT VALUES ---
private val mCurGenre = MutableLiveData<Genre?>() private val mCurGenre = MutableLiveData<Genre?>()
val curGenre: LiveData<Genre?> get() = mCurGenre val curGenre: LiveData<Genre?>
get() = mCurGenre
private val mGenreData = MutableLiveData(listOf<Item>()) private val mGenreData = MutableLiveData(listOf<Item>())
val genreData: LiveData<List<Item>> = mGenreData val genreData: LiveData<List<Item>> = mGenreData
private val mCurArtist = MutableLiveData<Artist?>() private val mCurArtist = MutableLiveData<Artist?>()
val curArtist: LiveData<Artist?> get() = mCurArtist val curArtist: LiveData<Artist?>
get() = mCurArtist
private val mArtistData = MutableLiveData(listOf<Item>()) private val mArtistData = MutableLiveData(listOf<Item>())
val artistData: LiveData<List<Item>> = mArtistData val artistData: LiveData<List<Item>> = mArtistData
private val mCurAlbum = MutableLiveData<Album?>() private val mCurAlbum = MutableLiveData<Album?>()
val curAlbum: LiveData<Album?> get() = mCurAlbum val curAlbum: LiveData<Album?>
get() = mCurAlbum
private val mAlbumData = MutableLiveData(listOf<Item>()) 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) data class MenuConfig(val anchor: View, val sortMode: Sort)
@ -72,7 +75,8 @@ class DetailViewModel : ViewModel() {
private val mNavToItem = MutableLiveData<Item?>() private val mNavToItem = MutableLiveData<Item?>()
/** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */ /** 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 var isNavigating = false
private set private set
@ -101,10 +105,7 @@ class DetailViewModel : ViewModel() {
refreshAlbumData() 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?) { fun finishShowMenu(newMode: Sort?) {
mShowMenu.value = null mShowMenu.value = null
@ -130,23 +131,17 @@ class DetailViewModel : ViewModel() {
currentMenuContext = null currentMenuContext = null
} }
/** /** Navigate to an item, whether a song/album/artist */
* Navigate to an item, whether a song/album/artist
*/
fun navToItem(item: Item) { fun navToItem(item: Item) {
mNavToItem.value = item mNavToItem.value = item
} }
/** /** Mark that the navigation process is done. */
* Mark that the navigation process is done.
*/
fun finishNavToItem() { fun finishNavToItem() {
mNavToItem.value = null mNavToItem.value = null
} }
/** /** Update the current navigation status to [isNavigating] */
* Update the current navigation status to [isNavigating]
*/
fun setNavigating(navigating: Boolean) { fun setNavigating(navigating: Boolean) {
isNavigating = navigating isNavigating = navigating
} }
@ -165,9 +160,7 @@ class DetailViewModel : ViewModel() {
onClick = { view -> onClick = { view ->
currentMenuContext = DisplayMode.SHOW_GENRES currentMenuContext = DisplayMode.SHOW_GENRES
mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort) mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort)
} }))
)
)
data.addAll(settingsManager.detailGenreSort.sortGenre(curGenre.value!!)) data.addAll(settingsManager.detailGenreSort.sortGenre(curGenre.value!!))
@ -179,12 +172,7 @@ class DetailViewModel : ViewModel() {
val artist = requireNotNull(curArtist.value) val artist = requireNotNull(curArtist.value)
val data = mutableListOf<Item>(artist) val data = mutableListOf<Item>(artist)
data.add( data.add(Header(id = -2, string = R.string.lbl_albums))
Header(
id = -2,
string = R.string.lbl_albums
)
)
data.addAll(Sort.ByYear(false).sortAlbums(artist.albums)) data.addAll(Sort.ByYear(false).sortAlbums(artist.albums))
@ -197,9 +185,7 @@ class DetailViewModel : ViewModel() {
onClick = { view -> onClick = { view ->
currentMenuContext = DisplayMode.SHOW_ARTISTS currentMenuContext = DisplayMode.SHOW_ARTISTS
mShowMenu.value = MenuConfig(view, settingsManager.detailArtistSort) mShowMenu.value = MenuConfig(view, settingsManager.detailArtistSort)
} }))
)
)
data.addAll(settingsManager.detailArtistSort.sortArtist(artist)) data.addAll(settingsManager.detailArtistSort.sortArtist(artist))
@ -220,9 +206,7 @@ class DetailViewModel : ViewModel() {
onClick = { view -> onClick = { view ->
currentMenuContext = DisplayMode.SHOW_ALBUMS currentMenuContext = DisplayMode.SHOW_ALBUMS
mShowMenu.value = MenuConfig(view, settingsManager.detailAlbumSort) mShowMenu.value = MenuConfig(view, settingsManager.detailAlbumSort)
} }))
)
)
data.addAll(settingsManager.detailAlbumSort.sortAlbum(curAlbum.value!!)) data.addAll(settingsManager.detailAlbumSort.sortAlbum(curAlbum.value!!))

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* GenreDetailFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -53,15 +52,11 @@ class GenreDetailFragment : DetailFragment() {
detailModel.setGenre(args.genreId) detailModel.setGenre(args.genreId)
val binding = FragmentDetailBinding.inflate(inflater) val binding = FragmentDetailBinding.inflate(inflater)
val detailAdapter = GenreDetailAdapter( val detailAdapter =
GenreDetailAdapter(
playbackModel, playbackModel,
doOnClick = { song -> doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) },
playbackModel.playSong(song, PlaybackMode.IN_GENRE) doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) })
},
doOnLongClick = { view, data ->
newMenu(view, data, ActionMenu.FLAG_IN_GENRE)
}
)
// --- UI SETUP --- // --- UI SETUP ---
@ -75,34 +70,26 @@ class GenreDetailFragment : DetailFragment() {
// --- DETAILVIEWMODEL SETUP --- // --- DETAILVIEWMODEL SETUP ---
detailModel.genreData.observe(viewLifecycleOwner) { data -> detailModel.genreData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) }
detailAdapter.submitList(data)
}
detailModel.navToItem.observe(viewLifecycleOwner) { item -> detailModel.navToItem.observe(viewLifecycleOwner) { item ->
when (item) { when (item) {
// All items will launch new detail fragments. // All items will launch new detail fragments.
is Artist -> { is Artist -> {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController().navigate( findNavController()
GenreDetailFragmentDirections.actionShowArtist(item.id) .navigate(GenreDetailFragmentDirections.actionShowArtist(item.id))
)
} }
is Album -> { is Album -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController().navigate( findNavController()
GenreDetailFragmentDirections.actionShowAlbum(item.id) .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id))
)
} }
is Song -> { is Song -> {
logD("Navigating to another song") logD("Navigating to another song")
findNavController().navigate( findNavController()
GenreDetailFragmentDirections.actionShowAlbum(item.album.id) .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.id))
)
} }
null -> {} null -> {}
else -> logW("Unsupported navigation command ${item::class.java}") else -> logW("Unsupported navigation command ${item::class.java}")
} }
@ -112,8 +99,7 @@ class GenreDetailFragment : DetailFragment() {
playbackModel.song.observe(viewLifecycleOwner) { song -> playbackModel.song.observe(viewLifecycleOwner) { song ->
if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE && 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) detailAdapter.highlightSong(song, binding.detailRecycler)
} else { } else {
// Clear the ViewHolders if the mode isn't ALL_SONGS // Clear the ViewHolders if the mode isn't ALL_SONGS

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumDetailAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) { return when (viewType) {
ALBUM_DETAIL_ITEM_TYPE -> AlbumDetailViewHolder( ALBUM_DETAIL_ITEM_TYPE ->
ItemDetailBinding.inflate(parent.context.inflater) AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
) ALBUM_SONG_ITEM_TYPE ->
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
ALBUM_SONG_ITEM_TYPE -> AlbumSongViewHolder(
ItemAlbumSongBinding.inflate(parent.context.inflater)
)
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context) ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type $viewType") else -> error("Invalid ViewHolder item type $viewType")
} }
} }
@ -84,8 +78,7 @@ class AlbumDetailAdapter(
is Album -> (holder as AlbumDetailViewHolder).bind(item) is Album -> (holder as AlbumDetailViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item) is Song -> (holder as AlbumSongViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
else -> { else -> {}
}
} }
if (holder is Highlightable) { if (holder is Highlightable) {
@ -114,9 +107,7 @@ class AlbumDetailAdapter(
if (song != null) { if (song != null) {
// Use existing data instead of having to re-sort it. // Use existing data instead of having to re-sort it.
val pos = currentList.indexOfFirst { item -> val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
item.id == song.id && item is Song
}
// Check if the ViewHolder for this song is visible, if it is then highlight it. // 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 // If the ViewHolder is not visible, then the adapter should take care of it if
@ -130,9 +121,8 @@ class AlbumDetailAdapter(
} }
} }
inner class AlbumDetailViewHolder( inner class AlbumDetailViewHolder(private val binding: ItemDetailBinding) :
private val binding: ItemDetailBinding BaseViewHolder<Album>(binding) {
) : BaseViewHolder<Album>(binding) {
override fun onBind(data: Album) { override fun onBind(data: Album) {
binding.detailCover.apply { binding.detailCover.apply {
@ -144,27 +134,21 @@ class AlbumDetailAdapter(
binding.detailSubhead.apply { binding.detailSubhead.apply {
text = data.artist.resolvedName text = data.artist.resolvedName
setOnClickListener { setOnClickListener { detailModel.navToItem(data.artist) }
detailModel.navToItem(data.artist)
}
} }
binding.detailInfo.apply { binding.detailInfo.apply {
text = context.getString( text =
context.getString(
R.string.fmt_three, R.string.fmt_three,
data.year?.toString() ?: context.getString(R.string.def_date), data.year?.toString() ?: context.getString(R.string.def_date),
context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size), context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size),
data.totalDuration data.totalDuration)
)
} }
binding.detailPlayButton.setOnClickListener { binding.detailPlayButton.setOnClickListener { playbackModel.playAlbum(data, false) }
playbackModel.playAlbum(data, false)
}
binding.detailShuffleButton.setOnClickListener { binding.detailShuffleButton.setOnClickListener { playbackModel.playAlbum(data, true) }
playbackModel.playAlbum(data, true)
}
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ArtistDetailAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) { return when (viewType) {
ARTIST_DETAIL_ITEM_TYPE -> ArtistDetailViewHolder( ARTIST_DETAIL_ITEM_TYPE ->
ItemDetailBinding.inflate(parent.context.inflater) ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
) ARTIST_ALBUM_ITEM_TYPE ->
ArtistAlbumViewHolder(ItemArtistAlbumBinding.inflate(parent.context.inflater))
ARTIST_ALBUM_ITEM_TYPE -> ArtistAlbumViewHolder( ARTIST_SONG_ITEM_TYPE ->
ItemArtistAlbumBinding.inflate(parent.context.inflater) ArtistSongViewHolder(ItemArtistSongBinding.inflate(parent.context.inflater))
)
ARTIST_SONG_ITEM_TYPE -> ArtistSongViewHolder(
ItemArtistSongBinding.inflate(parent.context.inflater)
)
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context) HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context) ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type $viewType") else -> error("Invalid ViewHolder item type $viewType")
} }
} }
@ -99,8 +90,7 @@ class ArtistDetailAdapter(
is Song -> (holder as ArtistSongViewHolder).bind(item) is Song -> (holder as ArtistSongViewHolder).bind(item)
is Header -> (holder as HeaderViewHolder).bind(item) is Header -> (holder as HeaderViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
else -> { else -> {}
}
} }
if (holder is Highlightable) { if (holder is Highlightable) {
@ -133,9 +123,7 @@ class ArtistDetailAdapter(
if (album != null) { if (album != null) {
// Use existing data instead of having to re-sort it. // Use existing data instead of having to re-sort it.
val pos = currentList.indexOfFirst { item -> val pos = currentList.indexOfFirst { item -> item.id == album.id && item is Album }
item.id == album.id && item is Album
}
// Check if the ViewHolder if this album is visible, and highlight it if so. // Check if the ViewHolder if this album is visible, and highlight it if so.
recycler.layoutManager?.findViewByPosition(pos)?.let { child -> recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
@ -163,9 +151,7 @@ class ArtistDetailAdapter(
if (song != null) { if (song != null) {
// Use existing data instead of having to re-sort it. // Use existing data instead of having to re-sort it.
// We also have to account for the album count when searching for the ViewHolder. // We also have to account for the album count when searching for the ViewHolder.
val pos = currentList.indexOfFirst { item -> val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
item.id == song.id && item is Song
}
// Check if the ViewHolder for this song is visible, if it is then highlight it. // 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 // If the ViewHolder is not visible, then the adapter should take care of it if
@ -179,39 +165,35 @@ class ArtistDetailAdapter(
} }
} }
inner class ArtistDetailViewHolder( inner class ArtistDetailViewHolder(private val binding: ItemDetailBinding) :
private val binding: ItemDetailBinding BaseViewHolder<Artist>(binding) {
) : BaseViewHolder<Artist>(binding) {
override fun onBind(data: Artist) { override fun onBind(data: Artist) {
val context = binding.root.context val context = binding.root.context
binding.detailCover.apply { binding.detailCover.apply {
bindArtistImage(data) bindArtistImage(data)
contentDescription = context.getString( contentDescription =
R.string.desc_artist_image, context.getString(R.string.desc_artist_image, data.resolvedName)
data.resolvedName
)
} }
binding.detailName.text = data.resolvedName binding.detailName.text = data.resolvedName
// Get the genre that corresponds to the most songs in this artist, which would be // Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre. // the most "Prominent" genre.
binding.detailSubhead.text = data.songs binding.detailSubhead.text =
data.songs
.groupBy { it.genre.resolvedName } .groupBy { it.genre.resolvedName }
.entries.maxByOrNull { it.value.size } .entries
?.key ?: context.getString(R.string.def_genre) .maxByOrNull { it.value.size }
?.key
?: context.getString(R.string.def_genre)
binding.detailInfo.bindArtistInfo(data) binding.detailInfo.bindArtistInfo(data)
binding.detailPlayButton.setOnClickListener { binding.detailPlayButton.setOnClickListener { playbackModel.playArtist(data, false) }
playbackModel.playArtist(data, false)
}
binding.detailShuffleButton.setOnClickListener { binding.detailShuffleButton.setOnClickListener { playbackModel.playArtist(data, true) }
playbackModel.playArtist(data, true)
}
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* GenreDetailAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) { return when (viewType) {
GENRE_DETAIL_ITEM_TYPE -> GenreDetailViewHolder( GENRE_DETAIL_ITEM_TYPE ->
ItemDetailBinding.inflate(parent.context.inflater) GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
) GENRE_SONG_ITEM_TYPE ->
GenreSongViewHolder(
GENRE_SONG_ITEM_TYPE -> GenreSongViewHolder(
ItemGenreSongBinding.inflate(parent.context.inflater), ItemGenreSongBinding.inflate(parent.context.inflater),
) )
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context) ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
else -> error("Bad ViewHolder item type $viewType") else -> error("Bad ViewHolder item type $viewType")
} }
} }
@ -110,9 +106,7 @@ class GenreDetailAdapter(
if (song != null) { if (song != null) {
// Use existing data instead of having to re-sort it. // Use existing data instead of having to re-sort it.
val pos = currentList.indexOfFirst { item -> val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
item.id == song.id && item is Song
}
// Check if the ViewHolder for this song is visible, if it is then highlight it. // 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 // If the ViewHolder is not visible, then the adapter should take care of it if
@ -126,31 +120,23 @@ class GenreDetailAdapter(
} }
} }
inner class GenreDetailViewHolder( inner class GenreDetailViewHolder(private val binding: ItemDetailBinding) :
private val binding: ItemDetailBinding BaseViewHolder<Genre>(binding) {
) : BaseViewHolder<Genre>(binding) {
override fun onBind(data: Genre) { override fun onBind(data: Genre) {
val context = binding.root.context val context = binding.root.context
binding.detailCover.apply { binding.detailCover.apply {
bindGenreImage(data) bindGenreImage(data)
contentDescription = context.getString( contentDescription = context.getString(R.string.desc_genre_image, data.resolvedName)
R.string.desc_genre_image,
data.resolvedName
)
} }
binding.detailName.text = data.resolvedName binding.detailName.text = data.resolvedName
binding.detailSubhead.bindGenreInfo(data) binding.detailSubhead.bindGenreInfo(data)
binding.detailInfo.text = data.totalDuration binding.detailInfo.text = data.totalDuration
binding.detailPlayButton.setOnClickListener { binding.detailPlayButton.setOnClickListener { playbackModel.playGenre(data, false) }
playbackModel.playGenre(data, false)
}
binding.detailShuffleButton.setOnClickListener { binding.detailShuffleButton.setOnClickListener { playbackModel.playGenre(data, true) }
playbackModel.playGenre(data, true)
}
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Highlightable.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -18,9 +17,7 @@
package org.oxycblt.auxio.detail.recycler package org.oxycblt.auxio.detail.recycler
/** /** Interface that allows the highlighting of certain ViewHolders */
* Interface that allows the highlighting of certain ViewHolders
*/
interface Highlightable { interface Highlightable {
fun setHighlighted(isHighlighted: Boolean) fun setHighlighted(isHighlighted: Boolean)
} }

View file

@ -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 package org.oxycblt.auxio.home
import android.content.Context import android.content.Context
@ -11,10 +28,8 @@ import org.oxycblt.auxio.util.logD
* - On medium screens, use only text * - On medium screens, use only text
* - On large screens, use text and an icon * - On large screens, use text and an icon
*/ */
class AdaptiveTabStrategy( class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel) :
context: Context, TabLayoutMediator.TabConfigurationStrategy {
private val homeModel: HomeViewModel
) : TabLayoutMediator.TabConfigurationStrategy {
private val width = context.resources.configuration.smallestScreenWidthDp private val width = context.resources.configuration.smallestScreenWidthDp
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
@ -23,19 +38,15 @@ class AdaptiveTabStrategy(
when { when {
width < 370 -> { width < 370 -> {
logD("Using icon-only configuration") logD("Using icon-only configuration")
tab.setIcon(tabMode.icon) tab.setIcon(tabMode.icon).setContentDescription(tabMode.string)
.setContentDescription(tabMode.string)
} }
width < 640 -> { width < 640 -> {
logD("Using text-only configuration") logD("Using text-only configuration")
tab.setText(tabMode.string) tab.setText(tabMode.string)
} }
else -> { else -> {
logD("Using icon-and-text configuration") logD("Using icon-and-text configuration")
tab.setIcon(tabMode.icon) tab.setIcon(tabMode.icon).setText(tabMode.string)
.setText(tabMode.string)
} }
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* EdgeFloatingActionButton.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. * A container for a FloatingActionButton that enables edge-to-edge support.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class EdgeFabContainer @JvmOverloads constructor( class EdgeFabContainer
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@AttrRes defStyleAttr: Int = 0 FrameLayout(context, attrs, defStyleAttr) {
) : FrameLayout(context, attrs, defStyleAttr) {
init { init {
clipToPadding = false clipToPadding = false
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MainFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.logTraceOrThrow
/** /**
* The main "Launching Point" fragment of Auxio, allowing navigation to the detail * The main "Launching Point" fragment of Auxio, allowing navigation to the detail views for each
* views for each respective item. * respective item.
* @author OxygenCobalt * @author OxygenCobalt TODO: Make tabs invisible when there is only one TODO: Add duration and song
* TODO: Make tabs invisible when there is only one * count sorts
* TODO: Add duration and song count sorts
*/ */
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
@ -83,26 +81,26 @@ class HomeFragment : Fragment() {
logD("Navigating to search") logD("Navigating to search")
findNavController().navigate(HomeFragmentDirections.actionShowSearch()) findNavController().navigate(HomeFragmentDirections.actionShowSearch())
} }
R.id.action_settings -> { R.id.action_settings -> {
logD("Navigating to settings") logD("Navigating to settings")
parentFragment?.parentFragment?.findNavController()?.navigate( parentFragment
MainFragmentDirections.actionShowSettings() ?.parentFragment
) ?.findNavController()
?.navigate(MainFragmentDirections.actionShowSettings())
} }
R.id.action_about -> { R.id.action_about -> {
logD("Navigating to about") logD("Navigating to about")
parentFragment?.parentFragment?.findNavController()?.navigate( parentFragment
MainFragmentDirections.actionShowAbout() ?.parentFragment
) ?.findNavController()
?.navigate(MainFragmentDirections.actionShowAbout())
} }
R.id.submenu_sorting -> {}
R.id.submenu_sorting -> { }
R.id.option_sort_asc -> { R.id.option_sort_asc -> {
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!) val new =
homeModel
.getSortForDisplay(homeModel.curTab.value!!)
.ascending(item.isChecked) .ascending(item.isChecked)
homeModel.updateCurrentSort(new) homeModel.updateCurrentSort(new)
} }
@ -110,7 +108,9 @@ class HomeFragment : Fragment() {
// Sorting option was selected, mark it as selected and update the mode // Sorting option was selected, mark it as selected and update the mode
else -> { else -> {
item.isChecked = true item.isChecked = true
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!) val new =
homeModel
.getSortForDisplay(homeModel.curTab.value!!)
.assignId(item.itemId) .assignId(item.itemId)
homeModel.updateCurrentSort(requireNotNull(new)) homeModel.updateCurrentSort(requireNotNull(new))
} }
@ -129,9 +129,11 @@ class HomeFragment : Fragment() {
// scroll events being registered as horizontal scroll events. Reflect into the // scroll events being registered as horizontal scroll events. Reflect into the
// internal recyclerview and change the touch slope so that touch actions will // internal recyclerview and change the touch slope so that touch actions will
// act more as a scroll than as a swipe. // 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 { try {
val recycler = ViewPager2::class.java.getDeclaredField("mRecyclerView").run { val recycler =
ViewPager2::class.java.getDeclaredField("mRecyclerView").run {
isAccessible = true isAccessible = true
get(binding.homePager) get(binding.homePager)
} }
@ -152,18 +154,17 @@ class HomeFragment : Fragment() {
// page transitions. // page transitions.
offscreenPageLimit = homeModel.tabs.size offscreenPageLimit = homeModel.tabs.size
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { registerOnPageChangeCallback(
override fun onPageSelected(position: Int) = homeModel.updateCurrentTab(position) object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) =
homeModel.updateCurrentTab(position)
}) })
TabLayoutMediator( TabLayoutMediator(binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel))
binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel) .attach()
).attach()
} }
binding.homeFab.setOnClickListener { binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
playbackModel.shuffleAll()
}
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
@ -213,18 +214,12 @@ class HomeFragment : Fragment() {
// the tab changes. // the tab changes.
when (tab) { when (tab) {
DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab) DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab)
DisplayMode.SHOW_ALBUMS ->
DisplayMode.SHOW_ALBUMS -> updateSortMenu(sortItem, tab) { id -> updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_album }
id != R.id.option_sort_album DisplayMode.SHOW_ARTISTS ->
} updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc }
DisplayMode.SHOW_GENRES ->
DisplayMode.SHOW_ARTISTS -> updateSortMenu(sortItem, tab) { id -> updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc }
id == R.id.option_sort_asc
}
DisplayMode.SHOW_GENRES -> updateSortMenu(sortItem, tab) { id ->
id == R.id.option_sort_asc
}
} }
binding.homeAppbar.liftOnScrollTargetViewId = tab.viewId 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. // This is only here just in case a collapsing toolbar is re-added.
binding.homeAppbar.post { binding.homeAppbar.post {
when (item) { when (item) {
is Song -> findNavController().navigate( is Song ->
HomeFragmentDirections.actionShowAlbum(item.album.id) findNavController()
) .navigate(HomeFragmentDirections.actionShowAlbum(item.album.id))
is Album ->
is Album -> findNavController().navigate( findNavController()
HomeFragmentDirections.actionShowAlbum(item.id) .navigate(HomeFragmentDirections.actionShowAlbum(item.id))
) is Artist ->
findNavController()
is Artist -> findNavController().navigate( .navigate(HomeFragmentDirections.actionShowArtist(item.id))
HomeFragmentDirections.actionShowArtist(item.id) is Genre ->
) findNavController()
.navigate(HomeFragmentDirections.actionShowGenre(item.id))
is Genre -> findNavController().navigate( else -> {}
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_SONGS -> R.id.home_song_list
DisplayMode.SHOW_ALBUMS -> R.id.home_album_list DisplayMode.SHOW_ALBUMS -> R.id.home_album_list
DisplayMode.SHOW_ARTISTS -> R.id.home_artist_list DisplayMode.SHOW_ARTISTS -> R.id.home_artist_list

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* HomeViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 settingsManager = SettingsManager.getInstance()
private val mSongs = MutableLiveData(listOf<Song>()) 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>()) 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>()) 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>()) 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 var tabs: List<DisplayMode> = visibleTabs
private set private set
/** Internal getter for getting the visible library tabs */ /** Internal getter for getting the visible library tabs */
private val visibleTabs: List<DisplayMode> get() = settingsManager.libTabs private val visibleTabs: List<DisplayMode>
.filterIsInstance<Tab.Visible>().map { it.mode } get() = settingsManager.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
private val mCurTab = MutableLiveData(tabs[0]) private val mCurTab = MutableLiveData(tabs[0])
val curTab: LiveData<DisplayMode> = mCurTab val curTab: LiveData<DisplayMode> = mCurTab
/** /**
* Marker to recreate all library tabs, usually initiated by a settings change. * Marker to recreate all library tabs, usually initiated by a settings change. When this flag
* When this flag is set, all tabs (and their respective viewpager fragments) will be * is set, all tabs (and their respective viewpager fragments) will be recreated from scratch.
* recreated from scratch.
*/ */
private val mRecreateTabs = MutableLiveData(false) private val mRecreateTabs = MutableLiveData(false)
val recreateTabs: LiveData<Boolean> = mRecreateTabs 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) { fun updateCurrentTab(pos: Int) {
logD("Updating current tab to ${tabs[pos]}") logD("Updating current tab to ${tabs[pos]}")
mCurTab.value = 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) { fun updateCurrentSort(sort: Sort) {
logD("Updating ${mCurTab.value} sort to $sort") logD("Updating ${mCurTab.value} sort to $sort")
when (mCurTab.value) { when (mCurTab.value) {
@ -117,29 +115,25 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
settingsManager.libSongSort = sort settingsManager.libSongSort = sort
mSongs.value = sort.sortSongs(mSongs.value!!) mSongs.value = sort.sortSongs(mSongs.value!!)
} }
DisplayMode.SHOW_ALBUMS -> { DisplayMode.SHOW_ALBUMS -> {
settingsManager.libAlbumSort = sort settingsManager.libAlbumSort = sort
mAlbums.value = sort.sortAlbums(mAlbums.value!!) mAlbums.value = sort.sortAlbums(mAlbums.value!!)
} }
DisplayMode.SHOW_ARTISTS -> { DisplayMode.SHOW_ARTISTS -> {
settingsManager.libArtistSort = sort settingsManager.libArtistSort = sort
mArtists.value = sort.sortParents(mArtists.value!!) mArtists.value = sort.sortParents(mArtists.value!!)
} }
DisplayMode.SHOW_GENRES -> { DisplayMode.SHOW_GENRES -> {
settingsManager.libGenreSort = sort settingsManager.libGenreSort = sort
mGenres.value = sort.sortParents(mGenres.value!!) mGenres.value = sort.sortParents(mGenres.value!!)
} }
else -> {} else -> {}
} }
} }
/** /**
* Update the fast scroll state. This is used to control the FAB visibility whenever * Update the fast scroll state. This is used to control the FAB visibility whenever the user
* the user begins to fast scroll. * begins to fast scroll.
*/ */
fun updateFastScrolling(scrolling: Boolean) { fun updateFastScrolling(scrolling: Boolean) {
mFastScrolling.value = scrolling mFastScrolling.value = scrolling

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Md2PopupBackground.java is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.home.fastscroll package org.oxycblt.auxio.home.fastscroll
import android.content.Context import android.content.Context
@ -30,19 +30,18 @@ import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import android.view.View import android.view.View
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import kotlin.math.sqrt
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenOffsetSafe import org.oxycblt.auxio.util.getDimenOffsetSafe
import kotlin.math.sqrt
/** /**
* The custom drawable used as FastScrollRecyclerView's popup background. * The custom drawable used as FastScrollRecyclerView's popup background. This is an adaptation from
* This is an adaptation from AndroidFastScroll's MD2 theme. * AndroidFastScroll's MD2 theme.
* *
* Attributions as per the Apache 2.0 license: * Attributions as per the Apache 2.0 license: ORIGINAL AUTHOR: Hai Zhang
* ORIGINAL AUTHOR: Hai Zhang [https://github.com/zhanghai] * [https://github.com/zhanghai] PROJECT: Android Fast Scroll
* PROJECT: Android Fast Scroll [https://github.com/zhanghai/AndroidFastScroll] * [https://github.com/zhanghai/AndroidFastScroll] MODIFIER: OxygenCobalt [https://github.com/]
* MODIFIER: OxygenCobalt [https://github.com/]
* *
* !!! MODIFICATIONS !!!: * !!! MODIFICATIONS !!!:
* - Use modified Auxio resources instead of AFS resources * - Use modified Auxio resources instead of AFS resources
@ -53,7 +52,8 @@ import kotlin.math.sqrt
* @author Hai Zhang, OxygenCobalt * @author Hai Zhang, OxygenCobalt
*/ */
class FastScrollPopupDrawable(context: Context) : Drawable() { class FastScrollPopupDrawable(context: Context) : Drawable() {
private val paint: Paint = Paint().apply { private val paint: Paint =
Paint().apply {
isAntiAlias = true isAntiAlias = true
color = context.getAttrColorSafe(R.attr.colorSecondary) color = context.getAttrColorSafe(R.attr.colorSecondary)
style = Paint.Style.FILL 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 // Paths don't need to be convex on android Q, but the API was mislabeled and so
// we still have to use this method. // we still have to use this method.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path) Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
else ->
else -> if (!path.isConvex) { if (!path.isConvex) {
// The outline path must be convex before Q, but we may run into floating point // 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. // so in this case we just omit the shadow instead of crashing.
super.getOutline(outline) super.getOutline(outline)
} }
@ -153,11 +154,15 @@ class FastScrollPopupDrawable(context: Context) : Drawable() {
sweepAngle: Float sweepAngle: Float
) { ) {
path.arcTo( path.arcTo(
centerX - radius, centerY - radius, centerX + radius, centerY + radius, centerX - radius,
startAngle, sweepAngle, false centerY - radius,
) centerX + radius,
centerY + radius,
startAngle,
sweepAngle,
false)
} }
private val isRtl: Boolean get() = private val isRtl: Boolean
DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL get() = DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* FastScrollRecyclerView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.getAttrColorSafe 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.getDimenSizeSafe
import org.oxycblt.auxio.util.getDrawableSafe import org.oxycblt.auxio.util.getDrawableSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
import kotlin.math.abs
/** /**
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of * 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. * Hai Zhang's AndroidFastScroll but slimmed down for Auxio and with a couple of enhancements.
* *
* Attributions as per the Apache 2.0 license: * Attributions as per the Apache 2.0 license: ORIGINAL AUTHOR: Hai Zhang
* ORIGINAL AUTHOR: Hai Zhang [https://github.com/zhanghai] * [https://github.com/zhanghai] PROJECT: Android Fast Scroll
* PROJECT: Android Fast Scroll [https://github.com/zhanghai/AndroidFastScroll] * [https://github.com/zhanghai/AndroidFastScroll] MODIFIER: OxygenCobalt [https://github.com/]
* MODIFIER: OxygenCobalt [https://github.com/]
* *
* !!! MODIFICATIONS !!!: * !!! MODIFICATIONS !!!:
* - Scroller will no longer show itself on startup or relayouts, which looked unpleasant * - Scroller will no longer show itself on startup or relayouts, which looked unpleasant with
* with multiple views * multiple views
* - DefaultAnimationHelper and RecyclerViewHelper were merged into the class * - DefaultAnimationHelper and RecyclerViewHelper were merged into the class
* - FastScroller overlay was merged into RecyclerView instance * - FastScroller overlay was merged into RecyclerView instance
* - Removed FastScrollerBuilder * - Removed FastScrollerBuilder
@ -75,17 +73,16 @@ import kotlin.math.abs
* *
* @author Hai Zhang, OxygenCobalt * @author Hai Zhang, OxygenCobalt
*/ */
class FastScrollRecyclerView @JvmOverloads constructor( class FastScrollRecyclerView
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@AttrRes defStyleAttr: Int = 0 RecyclerView(context, attrs, defStyleAttr) {
) : RecyclerView(context, attrs, defStyleAttr) {
/** Callback to provide a string to be shown on the popup when an item is passed */ /** Callback to provide a string to be shown on the popup when an item is passed */
var popupProvider: ((Int) -> String)? = null var popupProvider: ((Int) -> String)? = null
/** /**
* A listener for when a drag event occurs. The value will be true if a drag has begun, * A listener for when a drag event occurs. The value will be true if a drag has begun, and
* and false if a drag ended. * false if a drag ended.
*/ */
var onDragListener: ((Boolean) -> Unit)? = null var onDragListener: ((Boolean) -> Unit)? = null
@ -128,16 +125,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val thumbDrawable = context.getDrawableSafe(R.drawable.ui_scroll_thumb) val thumbDrawable = context.getDrawableSafe(R.drawable.ui_scroll_thumb)
trackView = View(context) trackView = View(context)
thumbView = View(context).apply { thumbView =
View(context).apply {
alpha = 0f alpha = 0f
background = thumbDrawable background = thumbDrawable
} }
popupView = AppCompatTextView(context).apply { popupView =
AppCompatTextView(context).apply {
alpha = 0f alpha = 0f
layoutParams = FrameLayout.LayoutParams( layoutParams =
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT FrameLayout.LayoutParams(
) ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
minimumWidth = context.getDimenSizeSafe(R.dimen.popup_min_width) minimumWidth = context.getDimenSizeSafe(R.dimen.popup_min_width)
minimumHeight = context.getDimenSizeSafe(R.dimen.size_btn_large) minimumHeight = context.getDimenSizeSafe(R.dimen.size_btn_large)
@ -168,23 +167,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
overlay.add(thumbView) overlay.add(thumbView)
overlay.add(popupView) overlay.add(popupView)
addItemDecoration(object : ItemDecoration() { addItemDecoration(
override fun onDraw( object : ItemDecoration() {
canvas: Canvas, override fun onDraw(canvas: Canvas, parent: RecyclerView, state: State) {
parent: RecyclerView,
state: State
) {
onPreDraw() onPreDraw()
} }
}) })
// We use a listener instead of overriding onTouchEvent so that we don't conflict with // We use a listener instead of overriding onTouchEvent so that we don't conflict with
// RecyclerView touch events. // RecyclerView touch events.
addOnItemTouchListener(object : SimpleOnItemTouchListener() { addOnItemTouchListener(
override fun onTouchEvent( object : SimpleOnItemTouchListener() {
recyclerView: RecyclerView, override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) {
event: MotionEvent
) {
onItemTouch(event) onItemTouch(event)
} }
@ -206,18 +200,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
thumbView.layoutDirection = layoutDirection thumbView.layoutDirection = layoutDirection
popupView.layoutDirection = layoutDirection popupView.layoutDirection = layoutDirection
val trackLeft = if (isRtl) { val trackLeft =
if (isRtl) {
scrollerPadding.left scrollerPadding.left
} else { } else {
width - scrollerPadding.right - thumbWidth width - scrollerPadding.right - thumbWidth
} }
trackView.layout( trackView.layout(
trackLeft, scrollerPadding.top, trackLeft + thumbWidth, trackLeft, scrollerPadding.top, trackLeft + thumbWidth, height - scrollerPadding.bottom)
height - scrollerPadding.bottom
)
val thumbLeft = if (isRtl) { val thumbLeft =
if (isRtl) {
scrollerPadding.left scrollerPadding.left
} else { } else {
width - scrollerPadding.right - thumbWidth width - scrollerPadding.right - thumbWidth
@ -228,7 +222,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight) thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
val firstPos = firstAdapterPos val firstPos = firstAdapterPos
val popupText = if (firstPos != NO_POSITION) { val popupText =
if (firstPos != NO_POSITION) {
popupProvider?.invoke(firstPos) ?: "" popupProvider?.invoke(firstPos) ?: ""
} else { } else {
"" ""
@ -242,58 +237,67 @@ class FastScrollRecyclerView @JvmOverloads constructor(
if (popupView.text != popupText) { if (popupView.text != popupText) {
popupView.text = popupText popupView.text = popupText
val widthMeasureSpec = ViewGroup.getChildMeasureSpec( val widthMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
scrollerPadding.left + scrollerPadding.right + thumbWidth + scrollerPadding.left +
popupLayoutParams.leftMargin + popupLayoutParams.rightMargin, scrollerPadding.right +
popupLayoutParams.width thumbWidth +
) popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin,
popupLayoutParams.width)
val heightMeasureSpec = ViewGroup.getChildMeasureSpec( val heightMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
scrollerPadding.top + scrollerPadding.bottom + popupLayoutParams.topMargin + scrollerPadding.top +
scrollerPadding.bottom +
popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin, popupLayoutParams.bottomMargin,
popupLayoutParams.height popupLayoutParams.height)
)
popupView.measure(widthMeasureSpec, heightMeasureSpec) popupView.measure(widthMeasureSpec, heightMeasureSpec)
} }
val popupWidth = popupView.measuredWidth val popupWidth = popupView.measuredWidth
val popupHeight = popupView.measuredHeight 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 scrollerPadding.left + thumbWidth + popupLayoutParams.leftMargin
} else { } 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 // We handle RTL separately, so it's okay if Gravity.RIGHT is used here
@SuppressLint("RtlHardcoded") @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.CENTER_HORIZONTAL -> popupHeight / 2
Gravity.RIGHT -> popupHeight Gravity.RIGHT -> popupHeight
else -> 0 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 -> { Gravity.CENTER_VERTICAL -> {
thumbView.paddingTop + ( thumbView.paddingTop +
thumbHeight - thumbView.paddingTop - thumbView.paddingBottom (thumbHeight - thumbView.paddingTop - thumbView.paddingBottom) / 2
) / 2
} }
Gravity.BOTTOM -> thumbHeight - thumbView.paddingBottom Gravity.BOTTOM -> thumbHeight - thumbView.paddingBottom
else -> thumbView.paddingTop else -> thumbView.paddingTop
} }
val popupTop = MathUtils.clamp( val popupTop =
MathUtils.clamp(
thumbTop + thumbAnchorY - popupAnchorY, thumbTop + thumbAnchorY - popupAnchorY,
scrollerPadding.top + popupLayoutParams.topMargin, scrollerPadding.top + popupLayoutParams.topMargin,
height - scrollerPadding.bottom - popupLayoutParams.bottomMargin - popupHeight height - scrollerPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
)
popupView.layout( popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight
)
} }
} }
@ -315,9 +319,10 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
updatePadding( updatePadding(
initialPadding.left, initialPadding.top, initialPadding.right, initialPadding.left,
initialPadding.bottom + bars.bottom initialPadding.top,
) initialPadding.right,
initialPadding.bottom + bars.bottom)
scrollerPadding.bottom = bars.bottom scrollerPadding.bottom = bars.bottom
@ -358,24 +363,25 @@ class FastScrollRecyclerView @JvmOverloads constructor(
if (isInViewTouchTarget(thumbView, eventX, eventY)) { if (isInViewTouchTarget(thumbView, eventX, eventY)) {
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else {
dragStartThumbOffset = (eventY - scrollerPadding.top - thumbHeight / 2f).toInt() dragStartThumbOffset =
(eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} }
setDragging(true) setDragging(true)
} }
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
if (!dragging && isInViewTouchTarget(trackView, downX, downY) && if (!dragging &&
abs(eventY - downY) > touchSlop isInViewTouchTarget(trackView, downX, downY) &&
) { abs(eventY - downY) > touchSlop) {
if (isInViewTouchTarget(thumbView, downX, downY)) { if (isInViewTouchTarget(thumbView, downX, downY)) {
dragStartY = lastY dragStartY = lastY
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else {
dragStartY = eventY dragStartY = eventY
dragStartThumbOffset = (eventY - scrollerPadding.top - thumbHeight / 2f).toInt() dragStartThumbOffset =
(eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} }
setDragging(true) setDragging(true)
@ -386,7 +392,6 @@ class FastScrollRecyclerView @JvmOverloads constructor(
scrollToThumbOffset(thumbOffset) scrollToThumbOffset(thumbOffset)
} }
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> setDragging(false) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> setDragging(false)
} }
@ -433,9 +438,9 @@ class FastScrollRecyclerView @JvmOverloads constructor(
private fun scrollToThumbOffset(thumbOffset: Int) { private fun scrollToThumbOffset(thumbOffset: Int) {
val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange) val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange)
val scrollOffset = ( val scrollOffset =
scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange (scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
).toInt() - paddingTop paddingTop
scrollTo(scrollOffset) scrollTo(scrollOffset)
} }
@ -461,7 +466,6 @@ class FastScrollRecyclerView @JvmOverloads constructor(
targetPosition *= mgr.spanCount targetPosition *= mgr.spanCount
mgr.scrollToPositionWithOffset(targetPosition, trueOffset) mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
} }
is LinearLayoutManager -> { is LinearLayoutManager -> {
mgr.scrollToPositionWithOffset(targetPosition, trueOffset) mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
} }
@ -538,10 +542,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
} }
private fun animateView(view: View, alpha: Float) { private fun animateView(view: View, alpha: Float) {
view.animate() view.animate().alpha(alpha).setDuration(ANIM_MILLIS).start()
.alpha(alpha)
.setDuration(ANIM_MILLIS)
.start()
} }
// --- LAYOUT STATE --- // --- LAYOUT STATE ---
@ -601,7 +602,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
} }
private val itemCount: Int private val itemCount: Int
get() = when (val mgr = layoutManager) { get() =
when (val mgr = layoutManager) {
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1 is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
is LinearLayoutManager -> mgr.itemCount is LinearLayoutManager -> mgr.itemCount
else -> 0 else -> 0

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -49,14 +48,12 @@ class AlbumListFragment : HomeListFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = AlbumAdapter( val adapter =
AlbumAdapter(
doOnClick = { album -> doOnClick = { album ->
findNavController().navigate( findNavController().navigate(HomeFragmentDirections.actionShowAlbum(album.id))
HomeFragmentDirections.actionShowAlbum(album.id)
)
}, },
::newMenu ::newMenu)
)
setupRecycler(R.id.home_album_list, binding, adapter, homeModel.albums) 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. // Change how we display the popup depending on the mode.
when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) { when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) {
// By Name -> Use Name // By Name -> Use Name
is Sort.ByName -> album.name.sliceArticle() is Sort.ByName -> album.name.sliceArticle().first().uppercase()
.first().uppercase()
// By Artist -> Use Artist Name // By Artist -> Use Artist Name
is Sort.ByArtist -> album.artist.resolvedName.sliceArticle() is Sort.ByArtist -> album.artist.resolvedName.sliceArticle().first().uppercase()
.first().uppercase()
// Year -> Use Full Year // Year -> Use Full Year
is Sort.ByYear -> album.year?.toString() is Sort.ByYear -> album.year?.toString() ?: getString(R.string.def_date)
?: getString(R.string.def_date)
// Unsupported sort, error gracefully // Unsupported sort, error gracefully
else -> "" else -> ""

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -47,14 +46,12 @@ class ArtistListFragment : HomeListFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = ArtistAdapter( val adapter =
ArtistAdapter(
doOnClick = { artist -> doOnClick = { artist ->
findNavController().navigate( findNavController().navigate(HomeFragmentDirections.actionShowArtist(artist.id))
HomeFragmentDirections.actionShowArtist(artist.id)
)
}, },
::newMenu ::newMenu)
)
setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists) setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists)
@ -63,8 +60,7 @@ class ArtistListFragment : HomeListFragment() {
override val listPopupProvider: (Int) -> String override val listPopupProvider: (Int) -> String
get() = { idx -> get() = { idx ->
homeModel.artists.value!![idx].resolvedName homeModel.artists.value!![idx].resolvedName.sliceArticle().first().uppercase()
.sliceArticle().first().uppercase()
} }
class ArtistAdapter( class ArtistAdapter(

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -47,14 +46,12 @@ class GenreListFragment : HomeListFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = GenreAdapter( val adapter =
GenreAdapter(
doOnClick = { Genre -> doOnClick = { Genre ->
findNavController().navigate( findNavController().navigate(HomeFragmentDirections.actionShowGenre(Genre.id))
HomeFragmentDirections.actionShowGenre(Genre.id)
)
}, },
::newMenu ::newMenu)
)
setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres) setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres)
@ -63,8 +60,7 @@ class GenreListFragment : HomeListFragment() {
override val listPopupProvider: (Int) -> String override val listPopupProvider: (Int) -> String
get() = { idx -> get() = { idx ->
homeModel.genres.value!![idx].resolvedName homeModel.genres.value!![idx].resolvedName.sliceArticle().first().uppercase()
.sliceArticle().first().uppercase()
} }
class GenreAdapter( class GenreAdapter(

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* HomeListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel 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 abstract val listPopupProvider: (Int) -> String
protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler( protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler(
@ -56,18 +53,15 @@ abstract class HomeListFragment : Fragment() {
applySpans() applySpans()
popupProvider = listPopupProvider popupProvider = listPopupProvider
onDragListener = { dragging -> onDragListener = { dragging -> homeModel.updateFastScrolling(dragging) }
homeModel.updateFastScrolling(dragging)
}
} }
// Make sure that this RecyclerView has data before startup // Make sure that this RecyclerView has data before startup
homeData.observe(viewLifecycleOwner) { data -> homeData.observe(viewLifecycleOwner) { data -> homeAdapter.updateData(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>() protected var data = listOf<T>()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SongListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -47,12 +46,7 @@ class SongListFragment : HomeListFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = SongsAdapter( val adapter = SongsAdapter(doOnClick = { song -> playbackModel.playSong(song) }, ::newMenu)
doOnClick = { song ->
playbackModel.playSong(song)
},
::newMenu
)
setupRecycler(R.id.home_song_list, binding, adapter, homeModel.songs) 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. // based off the names of the parent objects and not the child objects.
when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) { when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) {
// Name -> Use name // Name -> Use name
is Sort.ByName -> song.name.sliceArticle() is Sort.ByName -> song.name.sliceArticle().first().uppercase()
.first().uppercase()
// Artist -> Use Artist Name // Artist -> Use Artist Name
is Sort.ByArtist -> is Sort.ByArtist ->
song.album.artist.resolvedName song.album.artist.resolvedName.sliceArticle().first().uppercase()
.sliceArticle().first().uppercase()
// Album -> Use Album Name // Album -> Use Album Name
is Sort.ByAlbum -> song.album.name.sliceArticle() is Sort.ByAlbum -> song.album.name.sliceArticle().first().uppercase()
.first().uppercase()
// Year -> Use Full Year // Year -> Use Full Year
is Sort.ByYear -> song.album.year?.toString() is Sort.ByYear -> song.album.year?.toString() ?: getString(R.string.def_date)
?: getString(R.string.def_date)
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Tab.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.logE
/** /**
* A data representation of a library tab. * A data representation of a library tab. A tab can come in two moves, [Visible] or [Invisible].
* A tab can come in two moves, [Visible] or [Invisible]. Invisibility means that the tab * Invisibility means that the tab will still be present in the customization menu, but will not be
* will still be present in the customization menu, but will not be shown on the home UI. * shown on the home UI.
* *
* Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs cannot * 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: * be serialized on their own. Instead, they are saved as a sequence of tabs as shown below:
* *
* 0bTAB1_TAB2_TAB3_TAB4_TAB5 * 0bTAB1_TAB2_TAB3_TAB4_TAB5
* *
* Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists. * Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists. Each
* Each chunk in a sequence is represented as: * chunk in a sequence is represented as:
* *
* VTTT * VTTT
* *
@ -49,14 +48,12 @@ sealed class Tab(open val mode: DisplayMode) {
data class Invisible(override val mode: DisplayMode) : Tab(mode) data class Invisible(override val mode: DisplayMode) : Tab(mode)
companion object { 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 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 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 { fun toSequence(tabs: Array<Tab>): Int {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason. // Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.mode } val distinct = tabs.distinctBy { it.mode }
@ -65,7 +62,8 @@ sealed class Tab(open val mode: DisplayMode) {
var shift = SEQUENCE_LEN * 4 var shift = SEQUENCE_LEN * 4
for (tab in distinct) { for (tab in distinct) {
val bin = when (tab) { val bin =
when (tab) {
is Visible -> 1.shl(3) or tab.mode.ordinal is Visible -> 1.shl(3) or tab.mode.ordinal
is Invisible -> tab.mode.ordinal is Invisible -> tab.mode.ordinal
} }
@ -77,9 +75,7 @@ sealed class Tab(open val mode: DisplayMode) {
return sequence return sequence
} }
/** /** Convert a [sequence] into an array of tabs. */
* Convert a [sequence] into an array of tabs.
*/
fun fromSequence(sequence: Int): Array<Tab>? { fun fromSequence(sequence: Int): Array<Tab>? {
val tabs = mutableListOf<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) { for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
val chunk = sequence.shr(shift) and 0b1111 val chunk = sequence.shr(shift) and 0b1111
val mode = when (chunk and 7) { val mode =
when (chunk and 7) {
0 -> DisplayMode.SHOW_SONGS 0 -> DisplayMode.SHOW_SONGS
1 -> DisplayMode.SHOW_ALBUMS 1 -> DisplayMode.SHOW_ALBUMS
2 -> DisplayMode.SHOW_ARTISTS 2 -> DisplayMode.SHOW_ARTISTS
@ -97,7 +94,8 @@ sealed class Tab(open val mode: DisplayMode) {
} }
// Figure out the visibility // Figure out the visibility
tabs += if (chunk and 1.shl(3) != 0) { tabs +=
if (chunk and 1.shl(3) != 0) {
Visible(mode) Visible(mode)
} else { } else {
Invisible(mode) Invisible(mode)

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* TabAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 getTabs: () -> Array<Tab>,
private val onTabSwitch: (Tab) -> Unit, private val onTabSwitch: (Tab) -> Unit,
) : RecyclerView.Adapter<TabAdapter.TabViewHolder>() { ) : 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 override fun getItemCount(): Int = Tab.SEQUENCE_LEN
@ -43,13 +43,12 @@ class TabAdapter(
holder.bind(tabs[position]) holder.bind(tabs[position])
} }
inner class TabViewHolder( inner class TabViewHolder(private val binding: ItemTabBinding) :
private val binding: ItemTabBinding RecyclerView.ViewHolder(binding.root) {
) : RecyclerView.ViewHolder(binding.root) {
init { init {
binding.root.layoutParams = RecyclerView.LayoutParams( binding.root.layoutParams =
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT RecyclerView.LayoutParams(
) RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* CustomizeListDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.logD
/** /**
* The dialog for customizing library tabs. This dialog does not rely on any specific ViewModel * The dialog for customizing library tabs. This dialog does not rely on any specific ViewModel and
* and serializes it's state instead of * serializes it's state instead of
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class TabCustomizeDialog : LifecycleDialog() { class TabCustomizeDialog : LifecycleDialog() {
@ -58,7 +57,8 @@ class TabCustomizeDialog : LifecycleDialog() {
// Set up adapter & drag callback // Set up adapter & drag callback
val callback = TabDragCallback { pendingTabs } val callback = TabDragCallback { pendingTabs }
val helper = ItemTouchHelper(callback) val helper = ItemTouchHelper(callback)
val tabAdapter = TabAdapter( val tabAdapter =
TabAdapter(
helper, helper,
getTabs = { pendingTabs }, getTabs = { pendingTabs },
onTabSwitch = { tab -> onTabSwitch = { tab ->
@ -69,16 +69,16 @@ class TabCustomizeDialog : LifecycleDialog() {
if (index != -1) { if (index != -1) {
val curTab = pendingTabs[index] val curTab = pendingTabs[index]
logD("Updating tab $curTab to $tab") logD("Updating tab $curTab to $tab")
pendingTabs[index] = when (curTab) { pendingTabs[index] =
when (curTab) {
is Tab.Visible -> Tab.Invisible(curTab.mode) is Tab.Visible -> Tab.Invisible(curTab.mode)
is Tab.Invisible -> Tab.Visible(curTab.mode) is Tab.Invisible -> Tab.Visible(curTab.mode)
} }
} }
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)
pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty() .isEnabled = pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty()
} })
)
callback.addTabAdapter(tabAdapter) callback.addTabAdapter(tabAdapter)

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* QueueDragCallback.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. * A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu.
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. * Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. TODO: Consider unifying the
* TODO: Consider unifying the shared behavior between this and QueueDragCallback into a single * shared behavior between this and QueueDragCallback into a single class.
* class.
*/ */
class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.Callback() { 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 private lateinit var tabAdapter: TabAdapter
override fun getMovementFlags( override fun getMovementFlags(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder viewHolder: RecyclerView.ViewHolder
): Int = makeFlag( ): Int = makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN)
ItemTouchHelper.ACTION_STATE_DRAG,
ItemTouchHelper.UP or ItemTouchHelper.DOWN
)
override fun onChildDraw( override fun onChildDraw(
c: Canvas, c: Canvas,
@ -76,8 +72,8 @@ class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.C
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled(): Boolean = false
/** /**
* Add the tab adapter to this callback. * Add the tab adapter to this callback. Done because there's a circular dependency between the
* Done because there's a circular dependency between the two objects * two objects
*/ */
fun addTabAdapter(adapter: TabAdapter) { fun addTabAdapter(adapter: TabAdapter) {
tabAdapter = adapter tabAdapter = adapter

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Models.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,18 +26,15 @@ import androidx.annotation.StringRes
// --- MUSIC MODELS --- // --- MUSIC MODELS ---
/** /** The base for all items in Auxio. */
* The base for all items in Auxio.
*/
sealed class Item { sealed class Item {
/** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */ /** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
abstract val id: Long abstract val id: Long
} }
/** /**
* [Item] variant that represents a music item. * [Item] variant that represents a music item. TODO: Make name the actual display name and move raw
* TODO: Make name the actual display name and move raw names (including file names) to a new * names (including file names) to a new field called rawName.
* field called rawName.
*/ */
sealed class Music : Item() { sealed class Music : Item() {
/** The raw name of this 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 * [Music] variant that denotes that this object is a parent of other data objects, such as an
* as an [Album] or [Artist] * [Album] or [Artist]
* @property resolvedName * @property resolvedName
*/ */
sealed class MusicParent : Music() { sealed class MusicParent : Music() {
/** /**
* A name resolved from it's raw form to a form suitable to be shown in a ui. * A name resolved from it's raw form to a form suitable to be shown in a ui. Ex. "unknown"
* Ex. "unknown" would become Unknown Artist, (124) would become its proper genre name, etc. * would become Unknown Artist, (124) would become its proper genre name, etc.
*/ */
abstract val resolvedName: String abstract val resolvedName: String
} }
/** /** The data object for a song. */
* The data object for a song.
*/
data class Song( data class Song(
override val name: String, override val name: String,
/** The file name of this song, excluding the full path. */ /** The file name of this song, excluding the full path. */
@ -82,7 +76,8 @@ data class Song(
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalMediaStoreAlbumArtistName: String?, val internalMediaStoreAlbumArtistName: String?,
) : Music() { ) : Music() {
override val id: Long get() { override val id: Long
get() {
var result = name.hashCode().toLong() var result = name.hashCode().toLong()
result = 31 * result + album.name.hashCode() result = 31 * result + album.name.hashCode()
result = 31 * result + album.artist.name.hashCode() result = 31 * result + album.artist.name.hashCode()
@ -92,47 +87,58 @@ data class Song(
} }
/** The URI for this song. */ /** The URI for this song. */
val uri: Uri get() = ContentUris.withAppendedId( val uri: Uri
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, internalMediaStoreId get() =
) ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, internalMediaStoreId)
/** The duration of this song, in seconds (rounded down) */ /** 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. */ /** 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 private var mAlbum: Album? = null
/** The album of this song. */ /** The album of this song. */
val album: Album get() = requireNotNull(mAlbum) val album: Album
get() = requireNotNull(mAlbum)
private var mGenre: Genre? = null private var mGenre: Genre? = null
/** The genre of this song. Will be an "unknown genre" if the song does not have any. */ /** 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. */ /** An album name resolved to this song in particular. */
val resolvedAlbumName: String get() = val resolvedAlbumName: String
album.resolvedName get() = album.resolvedName
/** An artist name resolved to this song in particular. */ /** An artist name resolved to this song in particular. */
val resolvedArtistName: String get() = val resolvedArtistName: String
internalMediaStoreArtistName ?: album.artist.resolvedName get() = internalMediaStoreArtistName ?: album.artist.resolvedName
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalAlbumGroupingId: Long get() { val internalAlbumGroupingId: Long
get() {
var result = internalGroupingArtistName.lowercase().hashCode().toLong() var result = internalGroupingArtistName.lowercase().hashCode().toLong()
result = 31 * result + internalMediaStoreAlbumName.lowercase().hashCode() result = 31 * result + internalMediaStoreAlbumName.lowercase().hashCode()
return result return result
} }
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalGroupingArtistName: String get() = internalMediaStoreAlbumArtistName val internalGroupingArtistName: String
get() =
internalMediaStoreAlbumArtistName
?: internalMediaStoreArtistName ?: MediaStore.UNKNOWN_STRING ?: internalMediaStoreArtistName ?: MediaStore.UNKNOWN_STRING
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalIsMissingAlbum: Boolean get() = mAlbum == null val internalIsMissingAlbum: Boolean
get() = mAlbum == null
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalIsMissingArtist: Boolean get() = mAlbum?.internalIsMissingArtist ?: true val internalIsMissingArtist: Boolean
/** Internal field. Do not use. **/ get() = mAlbum?.internalIsMissingArtist ?: true
val internalIsMissingGenre: Boolean get() = mGenre == null /** Internal field. Do not use. */
val internalIsMissingGenre: Boolean
get() = mGenre == null
/** Internal method. Do not use. */ /** Internal method. Do not use. */
fun internalLinkAlbum(album: Album) { 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( data class Album(
override val name: String, override val name: String,
/** The latest year of the songs in this album. Null if none of the songs had metadata. */ /** 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() var result = name.hashCode().toLong()
result = 31 * result + artist.name.hashCode() result = 31 * result + artist.name.hashCode()
result = 31 * result + (year ?: 0) result = 31 * result + (year ?: 0)
@ -176,23 +181,25 @@ data class Album(
get() = name get() = name
/** The formatted total duration of this album */ /** The formatted total duration of this album */
val totalDuration: String get() = val totalDuration: String
songs.sumOf { it.seconds }.toDuration(false) get() = songs.sumOf { it.seconds }.toDuration(false)
private var mArtist: Artist? = null private var mArtist: Artist? = null
/** The parent artist of this album. */ /** 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. */ /** The artist name, resolved to this album in particular. */
val resolvedArtistName: String get() = val resolvedArtistName: String
artist.resolvedName get() = artist.resolvedName
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalArtistGroupingId: Long get() = val internalArtistGroupingId: Long
internalGroupingArtistName.lowercase().hashCode().toLong() get() = internalGroupingArtistName.lowercase().hashCode().toLong()
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalIsMissingArtist: Boolean get() = mArtist == null val internalIsMissingArtist: Boolean
get() = mArtist == null
/** Internal method. Do not use. */ /** Internal method. Do not use. */
fun internalLinkArtist(artist: Artist) { 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) * The [MusicParent] for an *album* artist. This reflects a group of songs with the same(ish) album
* album artist or artist field, not the individual performers of an artist. * artist or artist field, not the individual performers of an artist.
*/ */
data class Artist( data class Artist(
override val name: String, override val name: String,
@ -222,9 +229,7 @@ data class Artist(
val songs = albums.flatMap { it.songs } val songs = albums.flatMap { it.songs }
} }
/** /** The data object for a genre. */
* The data object for a genre.
*/
data class Genre( data class Genre(
override val name: String, override val name: String,
override val resolvedName: String, override val resolvedName: String,
@ -239,13 +244,11 @@ data class Genre(
override val id = name.hashCode().toLong() override val id = name.hashCode().toLong()
/** The formatted total duration of this genre */ /** The formatted total duration of this genre */
val totalDuration: String get() = val totalDuration: String
songs.sumOf { it.seconds }.toDuration(false) 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( data class Header(
override val id: Long, override val id: Long,
/** The string resource used for the header. */ /** The string resource used for the header. */

View file

@ -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 package org.oxycblt.auxio.music
import android.content.ContentResolver 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 * 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 * 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 * before you lose your sanity trying to understand the hoops I had to jump through for this system,
* system, but if you really want to stay, here's a debrief on why this code is so awful. * 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 * 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 * other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime
* crime against humanity and probably a way to summon Zalgo if you look at it the wrong way. * 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 * You think that if you wanted to query a song's genre from a media database, you could just put
* put "genre" in the query and it would return it, right? But not with MediaStore! No, that's * "genre" in the query and it would return it, right? But not with MediaStore! No, that's too
* too straightforward for this contract that was dropped on it's head as a baby. So instead, you * straightforward for this contract that was dropped on it's head as a baby. So instead, you have
* have to query for each genre, query all the songs in each genre, and then iterate through those * to query for each genre, query all the songs in each genre, and then iterate through those songs
* songs to link every song with their genre. This is not documented anywhere, and the * to link every song with their genre. This is not documented anywhere, and the O(mom im scared)
* O(mom im scared) algorithm you have to run to get it working single-handedly DOUBLES Auxio's * algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no
* loading times. At no point have the devs considered that this system is absolutely insane, and * point have the devs considered that this system is absolutely insane, and instead focused on
* instead focused on adding infuriat- I mean nice proprietary extensions to MediaStore for their * adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play
* own Google Play Music, and of course every Google Play Music user knew how great that turned * Music, and of course every Google Play Music user knew how great that turned out!
* out!
* *
* It's not even ergonomics that makes this API bad. It's base implementation is completely borked * 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? * as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? I
* I sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see * sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see that
* that the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or * the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or DATE tag.
* DATE tag. Once again, this is because internally android uses an ancient in-house metadata * Once again, this is because internally android uses an ancient in-house metadata parser to get
* parser to get everything indexed, and so far they have not bothered to modernize this parser * everything indexed, and so far they have not bothered to modernize this parser or even switch it
* or even switch it to something more powerful like Taglib, not even in Android 12. ID3v2.4 has * to something more powerful like Taglib, not even in Android 12. ID3v2.4 has been around for *21
* been around for *21 years.* *It can drink now.* All of my what. * 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 * 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 * table, so we have to go for the less efficient "make a big query on all the songs lol" method so
* so that songs don't end up fragmented across artists. Pretty much every OEM has added some * that songs don't end up fragmented across artists. Pretty much every OEM has added some extension
* extension or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) * or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the
* crippling the normal tables so that you're railroaded into their music app. The way I do * normal tables so that you're railroaded into their music app. The way I do blacklisting relies on
* blacklisting relies on a semi-deprecated method, and the supposedly "modern" method is SLOWER and * a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more
* causes even more problems since I have to manage databases across version boundaries. Sometimes * problems since I have to manage databases across version boundaries. Sometimes music will have a
* music will have a deformed clone that I can't filter out, sometimes Genres will just break for * deformed clone that I can't filter out, sometimes Genres will just break for no reason, and
* no reason, and sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to * sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift
* Latin-1 to *Shift JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY * 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 * Is there anything we can do about it? No. Google has routinely shut down issues that begged
* to fix glaring issues with MediaStore or to just take the API behind the woodshed and shoot it. * google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
* Largely because they have zero incentive to improve it given how "obscure" local music listening * shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
* is. As a result, some players like Vanilla and VLC just hack their own pseudo-MediaStore * listening is. As a result, some players like Vanilla and VLC just hack their own
* implementation from their own (better) parsers, but this is both infeasible for Auxio due to how * pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for
* incredibly slow it is to get a file handle from the android sandbox AND how much harder it is to * Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much
* manage a database of your own media that mirrors the filesystem perfectly. And even if I set * harder it is to manage a database of your own media that mirrors the filesystem perfectly. And
* aside those crippling issues and changed my indexer to that, it would face the even larger * even if I set aside those crippling issues and changed my indexer to that, it would face the even
* problem of how google keeps trying to kill the filesystem and force you into their * 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 * 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. * day that greenland melts and birthdays stop happening forever.
* *
@ -94,38 +110,30 @@ class MusicLoader {
for (song in songs) { for (song in songs) {
if (song.internalIsMissingAlbum || if (song.internalIsMissingAlbum ||
song.internalIsMissingArtist || song.internalIsMissingArtist ||
song.internalIsMissingGenre song.internalIsMissingGenre) {
) {
throw IllegalStateException( throw IllegalStateException(
"Found malformed song: ${song.name} [" + "Found malformed song: ${song.name} [" +
"album: ${!song.internalIsMissingAlbum} " + "album: ${!song.internalIsMissingAlbum} " +
"artist: ${!song.internalIsMissingArtist} " + "artist: ${!song.internalIsMissingArtist} " +
"genre: ${!song.internalIsMissingGenre}]" "genre: ${!song.internalIsMissingGenre}]")
)
} }
} }
return Library( return Library(genres, artists, albums, songs)
genres,
artists,
albums,
songs
)
} }
/** /**
* Gets a content resolver in a way that does not mangle metadata on * Gets a content resolver in a way that does not mangle metadata on certain OEM skins. See
* certain OEM skins. See https://github.com/OxygenCobalt/Auxio/issues/50 * https://github.com/OxygenCobalt/Auxio/issues/50 for more info.
* for more info.
*/ */
private val Context.contentResolverSafe: ContentResolver get() = private val Context.contentResolverSafe: ContentResolver
applicationContext.contentResolver get() = applicationContext.contentResolver
/** /**
* Does the initial query over the song database, including excluded directory * Does the initial query over the song database, including excluded directory checks. The songs
* checks. The songs returned by this function are **not** well-formed. The * returned by this function are **not** well-formed. The companion [buildAlbums],
* companion [buildAlbums], [buildArtists], and [readGenres] functions must be * [buildArtists], and [readGenres] functions must be called with the returned list so that all
* called with the returned list so that all songs are properly linked up. * songs are properly linked up.
*/ */
private fun loadSongs(context: Context): List<Song> { private fun loadSongs(context: Context): List<Song> {
val blacklistDatabase = ExcludedDatabase.getInstance(context) val blacklistDatabase = ExcludedDatabase.getInstance(context)
@ -157,18 +165,22 @@ class MusicLoader {
MediaStore.Audio.AudioColumns.ALBUM, MediaStore.Audio.AudioColumns.ALBUM,
MediaStore.Audio.AudioColumns.ALBUM_ID, MediaStore.Audio.AudioColumns.ALBUM_ID,
MediaStore.Audio.AudioColumns.ARTIST, MediaStore.Audio.AudioColumns.ARTIST,
AUDIO_COLUMN_ALBUM_ARTIST AUDIO_COLUMN_ALBUM_ARTIST),
), selector,
selector, args.toTypedArray(), null args.toTypedArray(),
)?.use { cursor -> null)
?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) 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 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 yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) 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 artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_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 // If the artist field is <unknown>, make it null. This makes handling the
// insanity of the artist field easier later on. // 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) { if (this == MediaStore.UNKNOWN_STRING) {
null null
} else { } else {
@ -219,16 +232,22 @@ class MusicLoader {
albumId, albumId,
artist, artist,
albumArtist, albumArtist,
) ))
)
} }
} }
// Deduplicate songs to prevent (most) deformed music clones // Deduplicate songs to prevent (most) deformed music clones
songs = songs.distinctBy { songs =
it.name to it.internalMediaStoreAlbumName to it.internalMediaStoreArtistName to songs
it.internalMediaStoreAlbumArtistName to it.track to it.duration .distinctBy {
}.toMutableList() it.name to
it.internalMediaStoreAlbumName to
it.internalMediaStoreArtistName to
it.internalMediaStoreAlbumArtistName to
it.track to
it.duration
}
.toMutableList()
logD("Successfully loaded ${songs.size} songs") 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 * Group songs up into their respective albums. Instead of using the unreliable album or artist
* artist databases, we instead group up songs by their *lowercase* artist and album name * databases, we instead group up songs by their *lowercase* artist and album name to create
* to create albums. This serves two purposes: * albums. This serves two purposes:
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". * 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
* This makes sure both of those are resolved into a single artist called "Rammstein" * 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 * 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
* ensures that all songs are unified under a single album. * 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 * This does come with some costs, it's far slower than using the album ID itself, and it may
* it may result in an unrelated album art being selected depending on the song chosen * result in an unrelated album art being selected depending on the song chosen as the template,
* as the template, but it seems to work pretty well. * but it seems to work pretty well.
*/ */
private fun buildAlbums(songs: List<Song>): List<Album> { private fun buildAlbums(songs: List<Song>): List<Album> {
val albums = mutableListOf<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 // 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. // weird years like "0" wont show up if there are alternatives.
// TODO: Weigh songs with null years lower than songs with zero years // TODO: Weigh songs with null years lower than songs with zero years
val templateSong = requireNotNull( val templateSong =
albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 } requireNotNull(albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 })
)
val albumName = templateSong.internalMediaStoreAlbumName val albumName = templateSong.internalMediaStoreAlbumName
val albumYear = templateSong.internalMediaStoreYear val albumYear = templateSong.internalMediaStoreYear
val albumCoverUri = ContentUris.withAppendedId( val albumCoverUri =
ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"), Uri.parse("content://media/external/audio/albumart"),
templateSong.internalMediaStoreAlbumId templateSong.internalMediaStoreAlbumId)
)
val artistName = templateSong.internalGroupingArtistName val artistName = templateSong.internalGroupingArtistName
albums.add( albums.add(
@ -277,8 +295,7 @@ class MusicLoader {
albumCoverUri, albumCoverUri,
albumSongs, albumSongs,
artistName, artistName,
) ))
)
} }
logD("Successfully built ${albums.size} albums") 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 * Group up albums into artists. This also requires a de-duplication step due to some edge cases
* edge cases where [buildAlbums] could not detect duplicates. * where [buildAlbums] could not detect duplicates.
*/ */
private fun buildArtists(context: Context, albums: List<Album>): List<Artist> { private fun buildArtists(context: Context, albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>() val artists = mutableListOf<Artist>()
@ -297,19 +314,14 @@ class MusicLoader {
for (entry in albumsByArtist) { for (entry in albumsByArtist) {
val templateAlbum = entry.value[0] val templateAlbum = entry.value[0]
val artistName = templateAlbum.internalGroupingArtistName val artistName = templateAlbum.internalGroupingArtistName
val resolvedName = when (templateAlbum.internalGroupingArtistName) { val resolvedName =
when (templateAlbum.internalGroupingArtistName) {
MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist) MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist)
else -> artistName else -> artistName
} }
val artistAlbums = entry.value val artistAlbums = entry.value
artists.add( artists.add(Artist(artistName, resolvedName, artistAlbums))
Artist(
artistName,
resolvedName,
artistAlbums
)
)
} }
logD("Successfully built ${artists.size} artists") 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 * Read all genres and link them up to the given songs. This is the code that requires me to
* requires me to make dozens of useless queries just to link genres up. * make dozens of useless queries just to link genres up.
*/ */
private fun readGenres(context: Context, songs: List<Song>): List<Genre> { private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>() val genres = mutableListOf<Genre>()
context.contentResolverSafe.query( context.contentResolverSafe.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf( arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME),
MediaStore.Audio.Genres._ID, null,
MediaStore.Audio.Genres.NAME null,
), null)
null, null, null ?.use { cursor ->
)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID) val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME) val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
// Genre names can be a normal name, an ID3v2 constant, or null. Normal names are // Genre names can be a normal name, an ID3v2 constant, or null. Normal names
// resolved as usual, but null values don't make sense and are often junk anyway, // are
// resolved as usual, but null values don't make sense and are often junk
// anyway,
// so we skip genres that have them. // so we skip genres that have them.
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue val name = cursor.getStringOrNull(nameIndex) ?: continue
val resolvedName = name.genreNameCompat ?: name val resolvedName = name.genreNameCompat ?: name
val genreSongs = queryGenreSongs(context, id, songs) ?: continue val genreSongs = queryGenreSongs(context, id, songs) ?: continue
genres.add( genres.add(Genre(name, resolvedName, genreSongs))
Genre(
name,
resolvedName,
genreSongs
)
)
} }
} }
val songsWithoutGenres = songs.filter { it.internalIsMissingGenre } val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
if (songsWithoutGenres.isNotEmpty()) { if (songsWithoutGenres.isNotEmpty()) {
// Songs that don't have a genre will be thrown into an unknown genre. // Songs that don't have a genre will be thrown into an unknown genre.
val unknownGenre = Genre( val unknownGenre =
Genre(
name = MediaStore.UNKNOWN_STRING, name = MediaStore.UNKNOWN_STRING,
resolvedName = context.getString(R.string.def_genre), resolvedName = context.getString(R.string.def_genre),
songsWithoutGenres songsWithoutGenres)
)
genres.add(unknownGenre) genres.add(unknownGenre)
} }
@ -372,10 +379,11 @@ class MusicLoader {
} }
/** /**
* Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the * Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the genre
* genre constant map that Auxio uses. * constant map that Auxio uses.
*/ */
private val String.genreNameCompat: String? get() { private val String.genreNameCompat: String?
get() {
if (isDigitsOnly()) { if (isDigitsOnly()) {
// ID3v1, just parse as an integer // ID3v1, just parse as an integer
return genreConstantTable.getOrNull(toInt()) 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 * Queries the genre songs for [genreId]. Some genres are insane and don't contain songs for
* for some reason, so if that's the case then this function will return null. * 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>? { private fun queryGenreSongs(context: Context, genreId: Long, songs: List<Song>): List<Song>? {
val genreSongs = mutableListOf<Song>() val genreSongs = mutableListOf<Song>()
@ -405,8 +413,10 @@ class MusicLoader {
context.contentResolverSafe.query( context.contentResolverSafe.query(
MediaStore.Audio.Genres.Members.getContentUri("external", genreId), MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID), arrayOf(MediaStore.Audio.Genres.Members._ID),
null, null, null null,
)?.use { cursor -> null,
null)
?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID) val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
@ -422,10 +432,10 @@ class MusicLoader {
companion object { companion object {
/** /**
* The album_artist MediaStore field has existed since at least API 21, but until API * The album_artist MediaStore field has existed since at least API 21, but until API 30 it
* 30 it was a proprietary extension for Google Play Music and was not documented. * was a proprietary extension for Google Play Music and was not documented. Since this
* Since this field probably works on all versions Auxio supports, we suppress the * field probably works on all versions Auxio supports, we suppress the warning about using
* warning about using a possibly-unsupported constant. * a possibly-unsupported constant.
*/ */
@Suppress("InlinedApi") @Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST 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 * A complete table of all the constant genre values for ID3(v2), including non-standard
* extensions. * extensions.
*/ */
private val genreConstantTable = arrayOf( private val genreConstantTable =
arrayOf(
// ID3 Standard // ID3 Standard
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Blues",
"Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Classic Rock",
"Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Country",
"Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Dance",
"Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "Disco",
"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop", "Funk",
"Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic", "Grunge",
"Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", "Hip-Hop",
"Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret", "Jazz",
"New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal", "Metal",
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock", "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 // Winamp extensions, more or less a de-facto standard
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Folk",
"Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Folk-Rock",
"Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "National Folk",
"Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", "Opera", "Chamber Music", "Swing",
"Sonata", "Symphony", "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam", "Fast Fusion",
"Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Bebob",
"Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall", "Latin",
"Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", "Britpop", "Revival",
"Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta", "Heavy Metal", "Black Metal", "Celtic",
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Bluegrass",
"Thrash Metal", "Anime", "JPop", "Synthpop", "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. // Winamp 5.6+ extensions, also used by EasyTAG.
// I only include this because post-rock is a based genre and deserves a slot. // I only include this because post-rock is a based genre and deserves a slot.
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout", "Abstract",
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental", "Art Rock",
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock", "Baroque",
"Leftfield", "Lounge", "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk", "Bhangra",
"Post-Rock", "Psytrance", "Shoegaze", "Space Rock", "Trop Rock", "World Music", "Big Beat",
"Neoclassical", "Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast", "Breakbeat",
"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient" "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")
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MusicStore.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.lang.Exception
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import java.lang.Exception
/** /**
* The main storage for music items. * The main storage for music items. Getting an instance of this object is more complicated as it
* Getting an instance of this object is more complicated as it loads asynchronously. * loads asynchronously. See the companion object for more. TODO: Add automatic rescanning [major
* See the companion object for more. * change]
* TODO: Add automatic rescanning [major change]
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MusicStore private constructor() { class MusicStore private constructor() {
private var mGenres = listOf<Genre>() private var mGenres = listOf<Genre>()
val genres: List<Genre> get() = mGenres val genres: List<Genre>
get() = mGenres
private var mArtists = listOf<Artist>() private var mArtists = listOf<Artist>()
val artists: List<Artist> get() = mArtists val artists: List<Artist>
get() = mArtists
private var mAlbums = listOf<Album>() private var mAlbums = listOf<Album>()
val albums: List<Album> get() = mAlbums val albums: List<Album>
get() = mAlbums
private var mSongs = listOf<Song>() 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 { private fun load(context: Context): Response {
logD("Starting initial music load") logD("Starting initial music load")
val notGranted = ContextCompat.checkSelfPermission( val notGranted =
context, Manifest.permission.READ_EXTERNAL_STORAGE ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
) == PackageManager.PERMISSION_DENIED PackageManager.PERMISSION_DENIED
if (notGranted) { if (notGranted) {
return Response.Err(ErrorKind.NO_PERMS) return Response.Err(ErrorKind.NO_PERMS)
@ -69,8 +69,7 @@ class MusicStore private constructor() {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val loader = MusicLoader() val loader = MusicLoader()
val library = loader.load(context) val library = loader.load(context) ?: return Response.Err(ErrorKind.NO_MUSIC)
?: return Response.Err(ErrorKind.NO_MUSIC)
mSongs = library.songs mSongs = library.songs
mAlbums = library.albums mAlbums = library.albums
@ -87,27 +86,22 @@ class MusicStore private constructor() {
return Response.Ok(this) 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? { fun findSongFast(songId: Long, albumId: Long): Song? {
return albums.find { it.id == albumId }?.songs?.find { it.id == songId } 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. * @return The corresponding [Song] for this [uri], null if there isn't one.
*/ */
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? { fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
resolver.query( resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor
uri, ->
arrayOf(OpenableColumns.DISPLAY_NAME),
null, null, null
)?.use { cursor ->
cursor.moveToFirst() cursor.moveToFirst()
val fileName = cursor.getString( val fileName =
cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
)
return songs.find { it.fileName == fileName } return songs.find { it.fileName == fileName }
} }
@ -116,9 +110,8 @@ class MusicStore private constructor() {
} }
/** /**
* A response that [MusicStore] returns when loading music. * A response that [MusicStore] returns when loading music. And before you ask, yes, I do like
* And before you ask, yes, I do like rust. * rust. TODO: Add the exception to the "FAILED" ErrorKind
* TODO: Add the exception to the "FAILED" ErrorKind
*/ */
sealed class Response { sealed class Response {
class Ok(val musicStore: MusicStore) : Response() class Ok(val musicStore: MusicStore) : Response()
@ -126,12 +119,13 @@ class MusicStore private constructor() {
} }
enum class ErrorKind { enum class ErrorKind {
NO_PERMS, NO_MUSIC, FAILED NO_PERMS,
NO_MUSIC,
FAILED
} }
companion object { companion object {
@Volatile @Volatile private var RESPONSE: Response? = null
private var RESPONSE: Response? = null
/** /**
* Initialize the loading process for this instance. This must be ran on a background * Initialize the loading process for this instance. This must be ran on a background
@ -145,11 +139,10 @@ class MusicStore private constructor() {
return currentInstance return currentInstance
} }
val response = withContext(Dispatchers.IO) { val response =
withContext(Dispatchers.IO) {
val response = MusicStore().load(context) val response = MusicStore().load(context)
synchronized(this) { synchronized(this) { RESPONSE = response }
RESPONSE = response
}
response response
} }
@ -157,11 +150,12 @@ class MusicStore private constructor() {
} }
/** /**
* Await the successful creation of a [MusicStore] instance. The co-routine calling * Await the successful creation of a [MusicStore] instance. The co-routine calling this
* this will block until the successful creation of a [MusicStore], in which it will * will block until the successful creation of a [MusicStore], in which it will then be
* then be returned. * 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 // We have to do a withContext call so we don't block the JVM thread
val musicStore: MusicStore 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 * Maybe get a MusicStore instance. This is useful if you are running code while the loading
* loading process may still be going on. * process may still be going on.
* *
* @return null if the music store instance is still loading or if the loading process has * @return null if the music store instance is still loading or if the loading process has
* encountered an error. An instance is returned otherwise. * 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 * Require a MusicStore instance. This function is dangerous and should only be used if it's
* it's guaranteed that the caller's code will only be called after the initial loading * guaranteed that the caller's code will only be called after the initial loading process.
* process.
*/ */
fun requireInstance(): MusicStore { fun requireInstance(): MusicStore {
return requireNotNull(maybeGetInstance()) { 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 { fun loaded(): Boolean {
return maybeGetInstance() != null return maybeGetInstance() != null
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MusicUtils.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. * Convert a [Long] of seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then * @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* --:-- will be returned if the second value is 0. * will be returned if the second value is 0.
*/ */
fun Long.toDuration(isElapsed: Boolean): String { fun Long.toDuration(isElapsed: Boolean): String {
if (!isElapsed && this == 0L) { if (!isElapsed && this == 0L) {
@ -58,11 +57,7 @@ fun TextView.bindSongInfo(song: Song?) {
return return
} }
text = context.getString( text = context.getString(R.string.fmt_two, song.resolvedArtistName, song.resolvedAlbumName)
R.string.fmt_two,
song.resolvedArtistName,
song.resolvedAlbumName
)
} }
@BindingAdapter("albumInfo") @BindingAdapter("albumInfo")
@ -72,11 +67,11 @@ fun TextView.bindAlbumInfo(album: Album?) {
return return
} }
text = context.getString( text =
context.getString(
R.string.fmt_two, R.string.fmt_two,
album.resolvedArtistName, album.resolvedArtistName,
context.getPluralSafe(R.plurals.fmt_song_count, album.songs.size) context.getPluralSafe(R.plurals.fmt_song_count, album.songs.size))
)
} }
@BindingAdapter("artistInfo") @BindingAdapter("artistInfo")
@ -86,11 +81,11 @@ fun TextView.bindArtistInfo(artist: Artist?) {
return return
} }
text = context.getString( text =
context.getString(
R.string.fmt_two, R.string.fmt_two,
context.getPluralSafe(R.plurals.fmt_album_count, artist.albums.size), 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") @BindingAdapter("genreInfo")

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MusicViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -33,8 +32,8 @@ class MusicViewModel : ViewModel() {
private var isBusy = false private var isBusy = false
/** /**
* Initiate the loading process. This is done here since HomeFragment will be the first * Initiate the loading process. This is done here since HomeFragment will be the first fragment
* fragment navigated to and because SnackBars will have the best UX here. * navigated to and because SnackBars will have the best UX here.
*/ */
fun loadMusic(context: Context) { fun loadMusic(context: Context) {
if (mLoaderResponse.value != null || isBusy) { if (mLoaderResponse.value != null || isBusy) {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* BlacklistDatabase.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.queryAll
/** /**
* Database for storing excluded directories. * Database for storing excluded directories. Note that the paths stored here will not work with
* Note that the paths stored here will not work with MediaStore unless you append a "%" at the end. * MediaStore unless you append a "%" at the end. Yes. I know Room exists. But that would needlessly
* Yes. I know Room exists. But that would needlessly bloat my app and has crippling bugs. * bloat my app and has crippling bugs.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { 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) 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>) { fun writePaths(paths: List<String>) {
assertBackgroundThread() assertBackgroundThread()
@ -58,21 +55,14 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
logD("Deleted paths db") logD("Deleted paths db")
for (path in paths) { for (path in paths) {
insert( insert(TABLE_NAME, null, ContentValues(1).apply { put(COLUMN_PATH, path) })
TABLE_NAME, null,
ContentValues(1).apply {
put(COLUMN_PATH, path)
}
)
} }
logD("Successfully wrote ${paths.size} paths to db") 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> { fun readPaths(): List<String> {
assertBackgroundThread() assertBackgroundThread()
@ -97,12 +87,9 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
const val TABLE_NAME = "blacklist_dirs_table" const val TABLE_NAME = "blacklist_dirs_table"
const val COLUMN_PATH = "COLUMN_PATH" const val COLUMN_PATH = "COLUMN_PATH"
@Volatile @Volatile private var INSTANCE: ExcludedDatabase? = null
private var INSTANCE: ExcludedDatabase? = null
/** /** Get/Instantiate the single instance of [ExcludedDatabase]. */
* Get/Instantiate the single instance of [ExcludedDatabase].
*/
fun getInstance(context: Context): ExcludedDatabase { fun getInstance(context: Context): ExcludedDatabase {
val currentInstance = INSTANCE val currentInstance = INSTANCE

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* BlacklistDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -57,13 +56,10 @@ class ExcludedDialog : LifecycleDialog() {
): View { ): View {
val binding = DialogExcludedBinding.inflate(inflater) val binding = DialogExcludedBinding.inflate(inflater)
val adapter = ExcludedEntryAdapter { path -> val adapter = ExcludedEntryAdapter { path -> excludedModel.removePath(path) }
excludedModel.removePath(path)
}
val launcher = registerForActivityResult( val launcher =
ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
)
// --- UI SETUP --- // --- UI SETUP ---
@ -131,9 +127,9 @@ class ExcludedDialog : LifecycleDialog() {
private fun parseDocTreePath(uri: Uri): String? { private fun parseDocTreePath(uri: Uri): String? {
// Turn the raw URI into a document tree URI // Turn the raw URI into a document tree URI
val docUri = DocumentsContract.buildDocumentUriUsingTree( val docUri =
uri, DocumentsContract.getTreeDocumentId(uri) DocumentsContract.buildDocumentUriUsingTree(
) uri, DocumentsContract.getTreeDocumentId(uri))
// Turn it into a semi-usable path // Turn it into a semi-usable path
val typeAndPath = DocumentsContract.getTreeDocumentId(docUri).split(":") val typeAndPath = DocumentsContract.getTreeDocumentId(docUri).split(":")
@ -153,15 +149,11 @@ class ExcludedDialog : LifecycleDialog() {
private fun saveAndRestart() { private fun saveAndRestart() {
excludedModel.save { excludedModel.save {
playbackModel.savePlaybackState(requireContext()) { playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() }
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 { private fun getRootPath(): String {
return Environment.getExternalStorageDirectory().absolutePath return Environment.getExternalStorageDirectory().absolutePath
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* BlacklistEntryAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. * Adapter that shows the excluded directories and their "Clear" button.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExcludedEntryAdapter( class ExcludedEntryAdapter(private val onClear: (String) -> Unit) :
private val onClear: (String) -> Unit RecyclerView.Adapter<ExcludedEntryAdapter.ViewHolder>() {
) : RecyclerView.Adapter<ExcludedEntryAdapter.ViewHolder>() {
private var paths = mutableListOf<String>() private var paths = mutableListOf<String>()
override fun getItemCount() = paths.size override fun getItemCount() = paths.size
@ -49,21 +47,18 @@ class ExcludedEntryAdapter(
notifyDataSetChanged() notifyDataSetChanged()
} }
inner class ViewHolder( inner class ViewHolder(private val binding: ItemExcludedDirBinding) :
private val binding: ItemExcludedDirBinding RecyclerView.ViewHolder(binding.root) {
) : RecyclerView.ViewHolder(binding.root) {
init { init {
binding.root.layoutParams = RecyclerView.LayoutParams( binding.root.layoutParams =
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT RecyclerView.LayoutParams(
) RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
} }
fun bind(path: String) { fun bind(path: String) {
binding.excludedPath.text = path binding.excludedPath.text = path
binding.excludedPath.requestLayout() binding.excludedPath.requestLayout()
binding.excludedClear.setOnClickListener { binding.excludedClear.setOnClickListener { onClear(path) }
onClear(path)
}
} }
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* BlacklistViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.logD
/** /**
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal * ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal of
* of paths. Use [Factory] to instantiate this. * paths. Use [Factory] to instantiate this. TODO: Unify with MusicViewModel
* TODO: Unify with MusicViewModel
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() { class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
private val mPaths = MutableLiveData(mutableListOf<String>()) 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>() private var dbPaths = listOf<String>()
/** /** Check if changes have been made to the ViewModel's paths. */
* Check if changes have been made to the ViewModel's paths. val isModified: Boolean
*/ get() = dbPaths != paths.value
val isModified: Boolean get() = dbPaths != paths.value
init { init {
loadDatabasePaths() loadDatabasePaths()
} }
/** /**
* Add a path to this ViewModel. It will not write the path to the database unless * Add a path to this ViewModel. It will not write the path to the database unless [save] is
* [save] is called. * called.
*/ */
fun addPath(path: String) { fun addPath(path: String) {
if (!mPaths.value!!.contains(path)) { if (!mPaths.value!!.contains(path)) {
@ -70,9 +68,7 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
mPaths.value = mPaths.value 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) { fun save(onDone: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
@ -80,24 +76,18 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
dbPaths = mPaths.value!! dbPaths = mPaths.value!!
onDone() onDone()
this@ExcludedViewModel.logD( 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() { private fun loadDatabasePaths() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
dbPaths = excludedDatabase.readPaths() dbPaths = excludedDatabase.readPaths()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { mPaths.value = dbPaths.toMutableList() }
mPaths.value = dbPaths.toMutableList()
}
this@ExcludedViewModel.logD( this@ExcludedViewModel.logD(
"Path load completed successfully in ${System.currentTimeMillis() - start}ms" "Path load completed successfully in ${System.currentTimeMillis() - start}ms")
)
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* CompactPlaybackView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A view displaying the playback state in a compact manner. This is only meant to be used * A view displaying the playback state in a compact manner. This is only meant to be used by
* by [PlaybackLayout]. * [PlaybackLayout].
*/ */
class PlaybackBarView @JvmOverloads constructor( class PlaybackBarView
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
defStyleAttr: Int = 0 ConstraintLayout(context, attrs, defStyleAttr) {
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewPlaybackBarBinding.inflate(context.inflater, this, true) private val binding = ViewPlaybackBarBinding.inflate(context.inflater, this, true)
init { init {
@ -52,35 +50,29 @@ class PlaybackBarView @JvmOverloads constructor(
// we use colorSecondary instead of colorSurfaceVariant. This is because // we use colorSecondary instead of colorSurfaceVariant. This is because
// colorSurfaceVariant is used with the assumption that the view that is using it is // 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. // not elevated and is therefore not colored. This view is elevated.
binding.playbackProgressBar.trackColor = MaterialColors.compositeARGBWithAlpha( binding.playbackProgressBar.trackColor =
context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt() MaterialColors.compositeARGBWithAlpha(
) context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt())
} }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Since we swipe up this view, we need to make sure it does not collide with // 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 // 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. // 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 -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
insets.getInsets(WindowInsets.Type.systemGestures()).bottom insets.getInsets(WindowInsets.Type.systemGestures()).bottom
} }
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") insets.systemGestureInsets.bottom
insets.systemGestureInsets.bottom
} }
else -> 0 else -> 0
} }
updatePadding( updatePadding(
bottom = bottom =
if (gesturePadding != 0) if (gesturePadding != 0) gesturePadding else insets.systemBarInsetsCompat.bottom)
gesturePadding
else
insets.systemBarInsetsCompat.bottom
)
return insets return insets
} }
@ -91,23 +83,15 @@ class PlaybackBarView @JvmOverloads constructor(
viewLifecycleOwner: LifecycleOwner viewLifecycleOwner: LifecycleOwner
) { ) {
setOnLongClickListener { setOnLongClickListener {
playbackModel.song.value?.let { song -> playbackModel.song.value?.let { song -> detailModel.navToItem(song) }
detailModel.navToItem(song)
}
true true
} }
binding.playbackSkipPrev?.setOnClickListener { binding.playbackSkipPrev?.setOnClickListener { playbackModel.skipPrev() }
playbackModel.skipPrev()
}
binding.playbackPlayPause.setOnClickListener { binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlayingStatus() }
playbackModel.invertPlayingStatus()
}
binding.playbackSkipNext?.setOnClickListener { binding.playbackSkipNext?.setOnClickListener { playbackModel.skipNext() }
playbackModel.skipNext()
}
binding.playbackPlayPause.isActivated = playbackModel.isPlaying.value!! binding.playbackPlayPause.isActivated = playbackModel.isPlaying.value!!

View file

@ -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 package org.oxycblt.auxio.playback
import android.content.Context 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. * 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 * Auxio's playback buttons have never followed the typical 24dp icon size that all other UI
* other UI elements do, mostly because those icons just look bad at that size with * elements do, mostly because those icons just look bad at that size with all the gobs of
* all the gobs of whitespace surrounding them. So, this view resizes the icons to a * whitespace surrounding them. So, this view resizes the icons to a fixed 32dp in a way that
* fixed 32dp in a way that doesn't require a whole new icon set. * 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 * This view also enables use of an "indicator", which is a dot that can denote when a button is
* button is active. This is useful for the shuffle/loop buttons, as at times highlighting * active. This is useful for the shuffle/loop buttons, as at times highlighting them is not enough
* them is not enough to differentiate them. * to differentiate them.
*/ */
class PlaybackButton @JvmOverloads constructor( class PlaybackButton
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@AttrRes defStyleAttr: Int = 0 AppCompatImageButton(context, attrs, defStyleAttr) {
) : AppCompatImageButton(context, attrs, defStyleAttr) {
private val iconSize = context.getDimenSizeSafe(R.dimen.size_playback_icon) private val iconSize = context.getDimenSizeSafe(R.dimen.size_playback_icon)
private val centerMatrix = Matrix() private val centerMatrix = Matrix()
private val matrixSrc = RectF() private val matrixSrc = RectF()
@ -55,21 +71,26 @@ class PlaybackButton @JvmOverloads constructor(
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) super.onMeasure(widthMeasureSpec, heightMeasureSpec)
imageMatrix = centerMatrix.apply { imageMatrix =
centerMatrix.apply {
reset() reset()
drawable?.let { drawable -> drawable?.let { drawable ->
// Android is too good to allow us to set a fixed image size, so we instead need // 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. // to define a matrix to scale an image directly.
// First scale the icon up to the desired size. // 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()) matrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat())
centerMatrix.setRectToRect(matrixSrc, matrixDst, Matrix.ScaleToFit.CENTER) 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( 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 val y = ((measuredHeight - iconSize) / 2) + iconSize
indicatorDrawable.bounds.set( indicatorDrawable.bounds.set(
x, y, x + indicatorDrawable.intrinsicWidth, y + indicatorDrawable.intrinsicHeight x, y, x + indicatorDrawable.intrinsicWidth, y + indicatorDrawable.intrinsicHeight)
)
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. * 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.** * Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
* @author OxygenCobalt * @author OxygenCobalt TODO: Handle RTL correctly in the playback buttons
* TODO: Handle RTL correctly in the playback buttons
*/ */
class PlaybackFragment : Fragment() { class PlaybackFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
@ -66,18 +64,13 @@ class PlaybackFragment : Fragment() {
binding.root.setOnApplyWindowInsetsListener { _, insets -> binding.root.setOnApplyWindowInsetsListener { _, insets ->
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
binding.root.updatePadding( binding.root.updatePadding(top = bars.top, bottom = bars.bottom)
top = bars.top,
bottom = bars.bottom
)
insets insets
} }
binding.playbackToolbar.apply { binding.playbackToolbar.apply {
setNavigationOnClickListener { setNavigationOnClickListener { navigateUp() }
navigateUp()
}
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
if (item.itemId == R.id.action_queue) { if (item.itemId == R.id.action_queue) {
@ -96,9 +89,7 @@ class PlaybackFragment : Fragment() {
binding.playbackSeekBar.onConfirmListener = playbackModel::setPosition binding.playbackSeekBar.onConfirmListener = playbackModel::setPosition
// Abuse the play/pause FAB (see style definition for more info) // Abuse the play/pause FAB (see style definition for more info)
binding.playbackPlayPause.post { binding.playbackPlayPause.post { binding.playbackPlayPause.stateListAnimator = null }
binding.playbackPlayPause.stateListAnimator = null
}
// --- VIEWMODEL SETUP -- // --- VIEWMODEL SETUP --
@ -114,8 +105,8 @@ class PlaybackFragment : Fragment() {
} }
playbackModel.parent.observe(viewLifecycleOwner) { parent -> playbackModel.parent.observe(viewLifecycleOwner) { parent ->
binding.playbackToolbar.subtitle = parent?.resolvedName binding.playbackToolbar.subtitle =
?: getString(R.string.lbl_all_songs) parent?.resolvedName ?: getString(R.string.lbl_all_songs)
} }
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling -> playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
@ -123,7 +114,8 @@ class PlaybackFragment : Fragment() {
} }
playbackModel.loopMode.observe(viewLifecycleOwner) { loopMode -> playbackModel.loopMode.observe(viewLifecycleOwner) { loopMode ->
val resId = when (loopMode) { val resId =
when (loopMode) {
LoopMode.NONE, null -> R.drawable.ic_loop LoopMode.NONE, null -> R.drawable.ic_loop
LoopMode.ALL -> R.drawable.ic_loop_on LoopMode.ALL -> R.drawable.ic_loop_on
LoopMode.TRACK -> R.drawable.ic_loop_one LoopMode.TRACK -> R.drawable.ic_loop_one

View file

@ -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 package org.oxycblt.auxio.playback
import android.content.Context import android.content.Context
@ -19,6 +36,9 @@ import androidx.core.view.isInvisible
import androidx.customview.widget.ViewDragHelper import androidx.customview.widget.ViewDragHelper
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.google.android.material.shape.MaterialShapeDrawable 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.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailViewModel 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.replaceSystemBarInsetsCompat
import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.stateList
import org.oxycblt.auxio.util.systemBarInsetsCompat 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 * This layout handles pretty much every aspect of the playback UI flow, notably the playback bar
* bar and it's ability to slide up into the playback view. It's a blend of Hai Zhang's * 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 * PersistentBarLayout and Umano's SlidingUpPanelLayout, albeit heavily minified to remove
* extraneous use cases and updated to support the latest SDK level and androidx tools. * 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 * **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 * reduced to Auxio's use case in particular and is really hard to understand since it has a ton of
* of state and view magic. I tried my best to document it, but it's probably not the most friendly * state and view magic. I tried my best to document it, but it's probably not the most friendly or
* or extendable. You have been warned. * extendable. You have been warned.
* *
* @author OxygenCobalt (With help from Umano and Hai Zhang) * @author OxygenCobalt (With help from Umano and Hai Zhang) TODO: Find a better way to handle
* TODO: Find a better way to handle PlaybackFragment in general (navigation, creation) * PlaybackFragment in general (navigation, creation)
*/ */
class PlaybackLayout @JvmOverloads constructor( class PlaybackLayout
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
defStyle: Int = 0 ViewGroup(context, attrs, defStyle) {
) : ViewGroup(context, attrs, defStyle) {
private enum class PanelState { private enum class PanelState {
EXPANDED, COLLAPSED, HIDDEN, DRAGGING EXPANDED,
COLLAPSED,
HIDDEN,
DRAGGING
} }
private lateinit var contentView: View private lateinit var contentView: View
@ -67,20 +86,19 @@ class PlaybackLayout @JvmOverloads constructor(
private val playbackContainerBg: MaterialShapeDrawable private val playbackContainerBg: MaterialShapeDrawable
private val playbackFragment = PlaybackFragment() private val playbackFragment = PlaybackFragment()
/** /** The drag helper that animates and dispatches drag events to the panels. */
* The drag helper that animates and dispatches drag events to the panels. private val dragHelper =
*/ ViewDragHelper.create(this, DragHelperCallback()).apply {
private val dragHelper = ViewDragHelper.create(this, DragHelperCallback()).apply {
minVelocity = MIN_FLING_VEL * resources.displayMetrics.density minVelocity = MIN_FLING_VEL * resources.displayMetrics.density
} }
/** /**
* The current window insets. * The current window insets. Important since this layout must play a long with Auxio's
* Important since this layout must play a long with Auxio's edge-to-edge functionality. * edge-to-edge functionality.
*/ */
private var lastInsets: WindowInsets? = null 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 private var panelState = INIT_PANEL_STATE
/** The last panel state before a drag event began. */ /** The last panel state before a drag event began. */
@ -90,10 +108,8 @@ class PlaybackLayout @JvmOverloads constructor(
private var panelRange = 0 private var panelRange = 0
/** /**
* The relative offset of this panel as a percentage of [panelRange]. * The relative offset of this panel as a percentage of [panelRange]. A value of 1 means a fully
* A value of 1 means a fully expanded panel. * expanded panel. A value of 0 means a collapsed panel. A value below 0 means a hidden panel.
* A value of 0 means a collapsed panel.
* A value below 0 means a hidden panel.
*/ */
private var panelOffset = 0f private var panelOffset = 0f
@ -105,39 +121,44 @@ class PlaybackLayout @JvmOverloads constructor(
private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal) private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
/** See [isDragging] */ /** See [isDragging] */
private val dragStateField = ViewDragHelper::class.java.getDeclaredField("mDragState").apply { private val dragStateField =
isAccessible = true ViewDragHelper::class.java.getDeclaredField("mDragState").apply { isAccessible = true }
}
init { init {
setWillNotDraw(false) setWillNotDraw(false)
// Set up our playback views. Doing this allows us to abstract away the implementation // Set up our playback views. Doing this allows us to abstract away the implementation
// of these views from the user of this layout [MainFragment]. // of these views from the user of this layout [MainFragment].
playbackContainerView = FrameLayout(context).apply { playbackContainerView =
FrameLayout(context).apply {
id = R.id.playback_container id = R.id.playback_container
isClickable = true isClickable = true
isFocusable = false isFocusable = false
isFocusableInTouchMode = false isFocusableInTouchMode = false
playbackContainerBg = MaterialShapeDrawable.createWithElevationOverlay(context).apply { playbackContainerBg =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
elevation = context.pxOfDp(elevationNormal).toFloat() 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 // 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. // 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) setDrawableByLayerId(R.id.panel_overlay, playbackContainerBg)
} }
disableDropShadowCompat() disableDropShadowCompat()
} }
playbackBarView = PlaybackBarView(context).apply { playbackBarView =
PlaybackBarView(context).apply {
id = R.id.playback_bar id = R.id.playback_bar
playbackContainerView.addView(this) playbackContainerView.addView(this)
@ -156,7 +177,8 @@ class PlaybackLayout @JvmOverloads constructor(
} }
} }
playbackPanelView = FrameLayout(context).apply { playbackPanelView =
FrameLayout(context).apply {
playbackContainerView.addView(this) playbackContainerView.addView(this)
(layoutParams as FrameLayout.LayoutParams).apply { (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 // since we don't want to stack fragments but we can't ensure that this view doesn't
// already have a fragment attached. // already have a fragment attached.
try { try {
(context as AppCompatActivity).supportFragmentManager.beginTransaction() (context as AppCompatActivity)
.supportFragmentManager
.beginTransaction()
.replace(R.id.playback_panel, playbackFragment) .replace(R.id.playback_panel, playbackFragment)
.commit() .commit()
} catch (e: Exception) { } catch (e: Exception) {
@ -185,8 +209,8 @@ class PlaybackLayout @JvmOverloads constructor(
// / --- CONTROL METHODS --- // / --- CONTROL METHODS ---
/** /**
* Update the song that this layout is showing. This will be reflected in the compact view * Update the song that this layout is showing. This will be reflected in the compact view at
* at the bottom of the screen. * the bottom of the screen.
*/ */
fun setup( fun setup(
playbackModel: PlaybackViewModel, playbackModel: PlaybackViewModel,
@ -195,9 +219,7 @@ class PlaybackLayout @JvmOverloads constructor(
) { ) {
setSong(playbackModel.song.value) setSong(playbackModel.song.value)
playbackModel.song.observe(viewLifecycleOwner) { song -> playbackModel.song.observe(viewLifecycleOwner) { song -> setSong(song) }
setSong(song)
}
playbackBarView.setup(playbackModel, detailModel, viewLifecycleOwner) playbackBarView.setup(playbackModel, detailModel, viewLifecycleOwner)
} }
@ -243,7 +265,8 @@ class PlaybackLayout @JvmOverloads constructor(
} }
if (!isLaidOut) { 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) setPanelStateInternal(state)
} else { } else {
// We are laid out. In this case we actually animate to our desired target. // We are laid out. In this case we actually animate to our desired target.
@ -293,7 +316,8 @@ class PlaybackLayout @JvmOverloads constructor(
if (!isLaidOut) { if (!isLaidOut) {
// This is our first layout, so make sure we know what offset we should work with // This is our first layout, so make sure we know what offset we should work with
// before we measure our content // before we measure our content
panelOffset = when (panelState) { panelOffset =
when (panelState) {
PanelState.EXPANDED -> 1.0f PanelState.EXPANDED -> 1.0f
PanelState.HIDDEN -> computePanelOffset(measuredHeight) PanelState.HIDDEN -> computePanelOffset(measuredHeight)
else -> 0f else -> 0f
@ -315,9 +339,8 @@ class PlaybackLayout @JvmOverloads constructor(
// Note that these views will always be a fixed MATCH_PARENT. This is intentional, // 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. // as it reduces the logic we have to deal with regarding WRAP_CONTENT views.
val contentWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY) val contentWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
val contentHeightSpec = MeasureSpec.makeMeasureSpec( val contentHeightSpec =
measuredHeight - barHeightAdjusted, MeasureSpec.EXACTLY MeasureSpec.makeMeasureSpec(measuredHeight - barHeightAdjusted, MeasureSpec.EXACTLY)
)
contentView.measure(contentWidthSpec, contentHeightSpec) contentView.measure(contentWidthSpec, contentHeightSpec)
} }
@ -330,8 +353,7 @@ class PlaybackLayout @JvmOverloads constructor(
0, 0,
panelTop, panelTop,
playbackContainerView.measuredWidth, playbackContainerView.measuredWidth,
playbackContainerView.measuredHeight + panelTop playbackContainerView.measuredHeight + panelTop)
)
layoutContent() layoutContent()
} }
@ -352,9 +374,7 @@ class PlaybackLayout @JvmOverloads constructor(
canvas.clipRect(tRect) canvas.clipRect(tRect)
} }
return super.drawChild(canvas, child, drawingTime).also { return super.drawChild(canvas, child, drawingTime).also { canvas.restoreToCount(save) }
canvas.restoreToCount(save)
}
} }
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { 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 * Apply window insets to the content views in this layouts. This is done separately as at times
* times we want to re-inset the content views but not re-inset the bar view. * we want to re-inset the content views but not re-inset the bar view.
*/ */
private fun applyContentWindowInsets() { private fun applyContentWindowInsets() {
val insets = lastInsets 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 { private fun adjustInsets(insets: WindowInsets): WindowInsets {
// We kind to do a reverse-measure to figure out how we should inset this view. // 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 // 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 consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0) val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)
return insets.replaceSystemBarInsetsCompat( 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()) putParcelable("superState", super.onSaveInstanceState())
putSerializable( putSerializable(
KEY_PANEL_STATE, KEY_PANEL_STATE,
@ -402,8 +420,7 @@ class PlaybackLayout @JvmOverloads constructor(
panelState panelState
} else { } else {
lastIdlePanelState lastIdlePanelState
} })
)
} }
override fun onRestoreInstanceState(state: Parcelable) { override fun onRestoreInstanceState(state: Parcelable) {
@ -425,7 +442,8 @@ class PlaybackLayout @JvmOverloads constructor(
return if (!canSlide) { return if (!canSlide) {
super.onTouchEvent(ev) super.onTouchEvent(ev)
} else try { } else
try {
dragHelper.processTouchEvent(ev) dragHelper.processTouchEvent(ev)
true true
} catch (ex: Exception) { } catch (ex: Exception) {
@ -454,10 +472,10 @@ class PlaybackLayout @JvmOverloads constructor(
return false return false
} }
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
val pointerUnder = playbackContainerView.isUnder(ev.x.toInt(), ev.y.toInt()) 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) { if (!(pointerUnder || motionUnder) || ady > dragSlop && adx > ady) {
// Pointer has moved beyond our control, do not intercept this event // Pointer has moved beyond our control, do not intercept this event
@ -465,7 +483,6 @@ class PlaybackLayout @JvmOverloads constructor(
return false return false
} }
} }
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP ->
if (dragHelper.isDragging) { if (dragHelper.isDragging) {
// Stopped pressing while we were dragging, let the drag helper handle it // Stopped pressing while we were dragging, let the drag helper handle it
@ -504,7 +521,8 @@ class PlaybackLayout @JvmOverloads constructor(
get() { get() {
// We can't grab the drag state outside of a callback, but that's stupid and I don't // 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. // want to vendor ViewDragHelper so I just do reflection instead.
val state = try { val state =
try {
dragStateField.get(this) dragStateField.get(this)
} catch (e: Exception) { } catch (e: Exception) {
ViewDragHelper.STATE_IDLE 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. * Do the nice view animations that occur whenever we slide up the playback panel. The way I
* The way I transition is largely inspired by Android 12's notification panel, with the * transition is largely inspired by Android 12's notification panel, with the compact view
* compact view fading out completely before the panel view fades in. * fading out completely before the panel view fades in.
*/ */
private fun updatePanelTransition() { private fun updatePanelTransition() {
val ratio = max(panelOffset, 0f) val ratio = max(panelOffset, 0f)
@ -566,8 +584,7 @@ class PlaybackLayout @JvmOverloads constructor(
params.leftMargin, params.leftMargin,
(bars.top * halfOutRatio).toInt(), (bars.top * halfOutRatio).toInt(),
params.rightMargin, params.rightMargin,
params.bottomMargin params.bottomMargin)
)
// Poke the layout only when we changed something // Poke the layout only when we changed something
if (params.topMargin != oldTopMargin) { if (params.topMargin != oldTopMargin) {
@ -592,9 +609,9 @@ class PlaybackLayout @JvmOverloads constructor(
private fun smoothSlideTo(offset: Float) { private fun smoothSlideTo(offset: Float) {
logD("Smooth sliding to $offset") logD("Smooth sliding to $offset")
val okay = dragHelper.smoothSlideViewTo( val okay =
playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset) dragHelper.smoothSlideViewTo(
) playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset))
if (okay) { if (okay) {
postInvalidateOnAnimation() postInvalidateOnAnimation()
@ -621,7 +638,6 @@ class PlaybackLayout @JvmOverloads constructor(
setPanelStateInternal(PanelState.HIDDEN) setPanelStateInternal(PanelState.HIDDEN)
playbackContainerView.visibility = INVISIBLE playbackContainerView.visibility = INVISIBLE
} }
else -> setPanelStateInternal(PanelState.EXPANDED) else -> setPanelStateInternal(PanelState.EXPANDED)
} }
} }
@ -658,7 +674,8 @@ class PlaybackLayout @JvmOverloads constructor(
} }
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
val newOffset = when { val newOffset =
when {
// Swipe Up -> Expand to top // Swipe Up -> Expand to top
yvel < 0 -> 1f yvel < 0 -> 1f
// Swipe down -> Collapse to bottom // Swipe down -> Collapse to bottom

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackSeeker.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.stateList
/** /**
* A custom view that bundles together a seekbar with a current duration and a total duration. * A custom view that bundles together a seekbar with a current duration and a total duration. The
* The sub-views are specifically laid out so that the seekbar has an adequate touch height while * sub-views are specifically laid out so that the seekbar has an adequate touch height while still
* still not having gobs of whitespace everywhere. * not having gobs of whitespace everywhere. TODO: Add smooth seeking [i.e seeking in sub-second
* TODO: Add smooth seeking [i.e seeking in sub-second values] * values]
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class PlaybackSeekBar @JvmOverloads constructor( class PlaybackSeekBar
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
defStyleRes: Int = 0 ConstraintLayout(context, attrs, defStyleRes),
) : ConstraintLayout(context, attrs, defStyleRes), Slider.OnChangeListener, Slider.OnSliderTouchListener { Slider.OnChangeListener,
Slider.OnSliderTouchListener {
private val binding = ViewSeekBarBinding.inflate(context.inflater, this, true) 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 var onConfirmListener: ((Long) -> Unit)? = null
@ -55,9 +56,10 @@ class PlaybackSeekBar @JvmOverloads constructor(
binding.seekBar.addOnSliderTouchListener(this) binding.seekBar.addOnSliderTouchListener(this)
// Override the inactive color so that it lines up with the playback progress bar. // Override the inactive color so that it lines up with the playback progress bar.
binding.seekBar.trackInactiveTintList = MaterialColors.compositeARGBWithAlpha( binding.seekBar.trackInactiveTintList =
context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt() MaterialColors.compositeARGBWithAlpha(
).stateList context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt())
.stateList
} }
fun setProgress(seconds: Long) { fun setProgress(seconds: Long) {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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]. * The ViewModel that provides a UI frontend for [PlaybackStateManager].
* *
* **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this provides * **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this
* an interface that properly sanitizes input and abstracts functions unlike the master class.** * provides an interface that properly sanitizes input and abstracts functions unlike the master
* class.**
* @author OxygenCobalt * @author OxygenCobalt
* *
* TODO: Completely rework this module to support the new music rescan system, * TODO: Completely rework this module to support the new music rescan system, proper android auto
* proper android auto and external exposing, and so on. * and external exposing, and so on.
* - DO NOT REWRITE IT! THAT'S BAD AND WILL PROBABLY RE-INTRODUCE A TON OF BUGS. * - DO NOT REWRITE IT! THAT'S BAD AND WILL PROBABLY RE-INTRODUCE A TON OF BUGS.
*/ */
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
@ -68,21 +68,29 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
private var mIntentUri: Uri? = null private var mIntentUri: Uri? = null
/** The current song. */ /** 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] */ /** 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 isPlaying: LiveData<Boolean>
val isShuffling: LiveData<Boolean> get() = mIsShuffling get() = mIsPlaying
val isShuffling: LiveData<Boolean>
get() = mIsShuffling
/** The current repeat mode, see [LoopMode] for more information */ /** 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 */ /** The current playback position, in seconds */
val position: LiveData<Long> get() = mPosition val position: LiveData<Long>
get() = mPosition
/** The queue, without the previous items. */ /** 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 */ /** 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 playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
@ -102,8 +110,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// --- PLAYING FUNCTIONS --- // --- PLAYING FUNCTIONS ---
/** /**
* Play a [song] with the [mode] specified. [mode] will default to the preferred song * Play a [song] with the [mode] specified. [mode] will default to the preferred song playback
* playback mode of the user if not specified. * mode of the user if not specified.
*/ */
fun playSong(song: Song, mode: PlaybackMode = settingsManager.songPlaybackMode) { fun playSong(song: Song, mode: PlaybackMode = settingsManager.songPlaybackMode) {
playbackManager.playSong(song, mode) playbackManager.playSong(song, mode)
@ -152,8 +160,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
} }
/** /**
* Play using a file [uri]. * Play using a file [uri]. This will not play instantly during the initial startup sequence.
* This will not play instantly during the initial startup sequence.
*/ */
fun playWithUri(uri: Uri, context: Context) { fun playWithUri(uri: Uri, context: Context) {
// Check if everything is already running to run the URI play // 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) { private fun playWithUriInternal(uri: Uri, context: Context) {
logD("Playing with uri $uri") logD("Playing with uri $uri")
val musicStore = MusicStore.maybeGetInstance() ?: return val musicStore = MusicStore.maybeGetInstance() ?: return
musicStore.findSongForUri(uri, context.contentResolver)?.let { song -> musicStore.findSongForUri(uri, context.contentResolver)?.let { song -> playSong(song) }
playSong(song)
}
} }
/** /** Shuffle all songs */
* Shuffle all songs
*/
fun shuffleAll() { fun shuffleAll() {
playbackManager.shuffleAll() playbackManager.shuffleAll()
} }
// --- POSITION FUNCTIONS --- // --- POSITION FUNCTIONS ---
/** /** Update the position and push it to [PlaybackStateManager] */
* Update the position and push it to [PlaybackStateManager]
*/
fun setPosition(progress: Long) { fun setPosition(progress: Long) {
playbackManager.seekTo((progress * 1000)) playbackManager.seekTo((progress * 1000))
} }
// --- QUEUE FUNCTIONS --- // --- QUEUE FUNCTIONS ---
/** /** Skip to the next song. */
* Skip to the next song.
*/
fun skipNext() { fun skipNext() {
playbackManager.next() playbackManager.next()
} }
/** /** Skip to the previous song. */
* Skip to the previous song.
*/
fun skipPrev() { fun skipPrev() {
playbackManager.prev() playbackManager.prev()
} }
/** /**
* Remove a queue item using it's recyclerview adapter index. If the indices are valid, * Remove a queue item using it's recyclerview adapter index. If the indices are valid, [apply]
* [apply] is called just before the change is committed so that the adapter can be updated. * is called just before the change is committed so that the adapter can be updated.
*/ */
fun removeQueueDataItem(adapterIndex: Int, apply: () -> Unit) { fun removeQueueDataItem(adapterIndex: Int, apply: () -> Unit) {
val index = adapterIndex + (playbackManager.queue.size - mNextUp.value!!.size) 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, * Move queue items using their recyclerview adapter indices. If the indices are valid, [apply]
* [apply] is called just before the change is committed so that the adapter can be updated. * is called just before the change is committed so that the adapter can be updated.
*/ */
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int, apply: () -> Unit): Boolean { fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int, apply: () -> Unit): Boolean {
val delta = (playbackManager.queue.size - mNextUp.value!!.size) val delta = (playbackManager.queue.size - mNextUp.value!!.size)
@ -239,53 +233,39 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
return false return false
} }
/** /** Add a [Song] to the top of the queue. */
* Add a [Song] to the top of the queue.
*/
fun playNext(song: Song) { fun playNext(song: Song) {
playbackManager.playNext(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) { fun playNext(album: Album) {
playbackManager.playNext(settingsManager.detailAlbumSort.sortAlbum(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) { fun addToQueue(song: Song) {
playbackManager.addToQueue(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) { fun addToQueue(album: Album) {
playbackManager.addToQueue(settingsManager.detailAlbumSort.sortAlbum(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() { fun invertPlayingStatus() {
playbackManager.setPlaying(!playbackManager.isPlaying) 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() { fun invertShuffleStatus() {
playbackManager.setShuffling(!playbackManager.isShuffling, true) 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() { fun incrementLoopStatus() {
playbackManager.setLoopMode(playbackManager.loopMode.increment()) playbackManager.setLoopMode(playbackManager.loopMode.increment())
} }
@ -293,8 +273,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// --- SAVE/RESTORE FUNCTIONS --- // --- SAVE/RESTORE FUNCTIONS ---
/** /**
* Force save the current [PlaybackStateManager] state to the database. * Force save the current [PlaybackStateManager] state to the database. Called by
* Called by SettingsListFragment. * SettingsListFragment.
*/ */
fun savePlaybackState(context: Context, onDone: () -> Unit) { fun savePlaybackState(context: Context, onDone: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
@ -320,15 +300,13 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.markRestored() playbackManager.markRestored()
} else if (!playbackManager.isRestored) { } else if (!playbackManager.isRestored) {
// Otherwise just restore // Otherwise just restore
viewModelScope.launch { viewModelScope.launch { playbackManager.restoreFromDatabase(context) }
playbackManager.restoreFromDatabase(context)
}
} }
} }
/** /**
* Attempt to restore the current playback state from an existing * Attempt to restore the current playback state from an existing [PlaybackStateManager]
* [PlaybackStateManager] instance. * instance.
*/ */
private fun restorePlaybackState() { private fun restorePlaybackState() {
logD("Attempting to restore playback state") logD("Attempting to restore playback state")

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* QueueAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * @param touchHelper The [ItemTouchHelper] ***containing*** [QueueDragCallback] to be used
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class QueueAdapter( class QueueAdapter(private val touchHelper: ItemTouchHelper) :
private val touchHelper: ItemTouchHelper RecyclerView.Adapter<RecyclerView.ViewHolder>() {
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var data = mutableListOf<Item>() private var data = mutableListOf<Item>()
private var listDiffer = AsyncListDiffer(this, DiffCallback()) private var listDiffer = AsyncListDiffer(this, DiffCallback())
@ -60,16 +58,14 @@ class QueueAdapter(
is Song -> QUEUE_SONG_ITEM_TYPE is Song -> QUEUE_SONG_ITEM_TYPE
is Header -> HeaderViewHolder.ITEM_TYPE is Header -> HeaderViewHolder.ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
else -> -1 else -> -1
} }
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) { return when (viewType) {
QUEUE_SONG_ITEM_TYPE -> QueueSongViewHolder( QUEUE_SONG_ITEM_TYPE ->
ItemQueueSongBinding.inflate(parent.context.inflater) QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
)
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context) HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context) ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type $viewType") else -> error("Invalid ViewHolder item type $viewType")
@ -86,8 +82,8 @@ class QueueAdapter(
} }
/** /**
* Submit data using [AsyncListDiffer]. * Submit data using [AsyncListDiffer]. **Only use this if you have no idea what changes
* **Only use this if you have no idea what changes occurred to the data** * occurred to the data**
*/ */
fun submitList(newData: MutableList<Item>) { fun submitList(newData: MutableList<Item>) {
if (data != newData) { 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) { fun moveItems(adapterFrom: Int, adapterTo: Int) {
data.add(adapterTo, data.removeAt(adapterFrom)) data.add(adapterTo, data.removeAt(adapterFrom))
notifyItemMoved(adapterFrom, adapterTo) 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) { fun removeItem(adapterIndex: Int) {
data.removeAt(adapterIndex) data.removeAt(adapterIndex)
notifyItemRemoved(adapterIndex) notifyItemRemoved(adapterIndex)
} }
/** /** Generic ViewHolder for a queue song */
* Generic ViewHolder for a queue song
*/
inner class QueueSongViewHolder( inner class QueueSongViewHolder(
private val binding: ItemQueueSongBinding, private val binding: ItemQueueSongBinding,
) : BaseViewHolder<Song>(binding) { ) : BaseViewHolder<Song>(binding) {
val bodyView: View get() = binding.body val bodyView: View
val backgroundView: View get() = binding.background get() = binding.body
val backgroundView: View
get() = binding.background
init { init {
binding.body.background = MaterialShapeDrawable.createWithElevationOverlay( binding.body.background =
binding.root.context MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
).apply {
fillColor = (binding.body.background as ColorDrawable).color.stateList fillColor = (binding.body.background as ColorDrawable).color.stateList
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* QueueDragCallback.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable 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.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.sign 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 * 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 * rebuilding most the "Material-y" aspects of an editable list because Google's implementations are
* are hot garbage. This shouldn't have *too many* UI bugs. I hope. * hot garbage. This shouldn't have *too many* UI bugs. I hope.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() { class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() {
@ -59,17 +58,14 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
): Int { ): Int {
// Fix to make QueueFragment scroll slower when an item is scrolled out of bounds. // Fix to make QueueFragment scroll slower when an item is scrolled out of bounds.
// Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe // Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe
val standardSpeed = super.interpolateOutOfBoundsScroll( val standardSpeed =
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll super.interpolateOutOfBoundsScroll(
) recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll)
val clampedAbsVelocity = max( val clampedAbsVelocity =
max(
MINIMUM_INITIAL_DRAG_VELOCITY, MINIMUM_INITIAL_DRAG_VELOCITY,
min( min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY))
abs(standardSpeed),
MAXIMUM_INITIAL_DRAG_VELOCITY
)
)
return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() 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 bg = holder.bodyView.background as MaterialShapeDrawable
val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_small) val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_small)
holder.itemView.animate() holder
.itemView
.animate()
.translationZ(elevation) .translationZ(elevation)
.setDuration(100) .setDuration(100)
.setUpdateListener { .setUpdateListener { bg.elevation = holder.itemView.translationZ }
bg.elevation = holder.itemView.translationZ
}
.setInterpolator(AccelerateDecelerateInterpolator()) .setInterpolator(AccelerateDecelerateInterpolator())
.start() .start()
@ -132,7 +128,9 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
logD("Dropping queue item") logD("Dropping queue item")
val bg = holder.bodyView.background as MaterialShapeDrawable val bg = holder.bodyView.background as MaterialShapeDrawable
holder.itemView.animate() holder
.itemView
.animate()
.translationZ(0.0f) .translationZ(0.0f)
.setDuration(100) .setDuration(100)
.setUpdateListener { bg.elevation = holder.itemView.translationZ } .setUpdateListener { bg.elevation = holder.itemView.translationZ }
@ -154,9 +152,7 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
val from = viewHolder.bindingAdapterPosition val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition val to = target.bindingAdapterPosition
return playbackModel.moveQueueDataItems(from, to) { return playbackModel.moveQueueDataItems(from, to) { queueAdapter.moveItems(from, to) }
queueAdapter.moveItems(from, to)
}
} }
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
@ -168,8 +164,8 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled(): Boolean = false
/** /**
* Add the queue adapter to this callback. * Add the queue adapter to this callback. Done because there's a circular dependency between
* Done because there's a circular dependency between the two objects * the two objects
*/ */
fun addQueueAdapter(adapter: QueueAdapter) { fun addQueueAdapter(adapter: QueueAdapter) {
queueAdapter = adapter queueAdapter = adapter

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* QueueFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -54,9 +53,7 @@ class QueueFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.queueToolbar.setNavigationOnClickListener { binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
findNavController().navigateUp()
}
binding.queueRecycler.apply { binding.queueRecycler.apply {
setHasFixedSize(true) setHasFixedSize(true)

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* LoopMode.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * @author OxygenCobalt
*/ */
enum class LoopMode { 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 { fun increment(): LoopMode {
return when (this) { return when (this) {
NONE -> ALL NONE -> ALL
@ -53,15 +52,12 @@ enum class LoopMode {
private const val INT_ALL = 0xA101 private const val INT_ALL = 0xA101
private const val INT_TRACK = 0xA102 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? { fun fromInt(constant: Int): LoopMode? {
return when (constant) { return when (constant) {
INT_NONE -> NONE INT_NONE -> NONE
INT_ALL -> ALL INT_ALL -> ALL
INT_TRACK -> TRACK INT_TRACK -> TRACK
else -> null else -> null
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackMode.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackStateDatabase.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.queryAll
/** /**
* A SQLite database for managing the persistent playback state and queue. * A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists.
* Yes. I know Room exists. But that would needlessly bloat my app and has crippling bugs. * But that would needlessly bloat my app and has crippling bugs.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class PlaybackStateDatabase(context: Context) : class PlaybackStateDatabase(context: Context) :
@ -58,9 +57,7 @@ class PlaybackStateDatabase(context: Context) :
// --- DATABASE CONSTRUCTION FUNCTIONS --- // --- DATABASE CONSTRUCTION FUNCTIONS ---
/** /** Create a table for this database. */
* Create a table for this database.
*/
private fun createTable(database: SQLiteDatabase, tableName: String) { private fun createTable(database: SQLiteDatabase, tableName: String) {
val command = StringBuilder() val command = StringBuilder()
command.append("CREATE TABLE IF NOT EXISTS $tableName(") command.append("CREATE TABLE IF NOT EXISTS $tableName(")
@ -74,11 +71,10 @@ class PlaybackStateDatabase(context: Context) :
database.execSQL(command.toString()) database.execSQL(command.toString())
} }
/** /** Construct a [StateColumns] table */
* Construct a [StateColumns] table
*/
private fun constructStateTable(command: StringBuilder): StringBuilder { 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_SONG_HASH} LONG,")
.append("${StateColumns.COLUMN_POSITION} LONG NOT NULL,") .append("${StateColumns.COLUMN_POSITION} LONG NOT NULL,")
.append("${StateColumns.COLUMN_PARENT_HASH} LONG,") .append("${StateColumns.COLUMN_PARENT_HASH} LONG,")
@ -90,11 +86,10 @@ class PlaybackStateDatabase(context: Context) :
return command return command
} }
/** /** Construct a [QueueColumns] table */
* Construct a [QueueColumns] table
*/
private fun constructQueueTable(command: StringBuilder): StringBuilder { 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.SONG_HASH} INTEGER NOT NULL,")
.append("${QueueColumns.ALBUM_HASH} INTEGER NOT NULL)") .append("${QueueColumns.ALBUM_HASH} INTEGER NOT NULL)")
@ -126,13 +121,13 @@ class PlaybackStateDatabase(context: Context) :
cursor.moveToFirst() cursor.moveToFirst()
val song = cursor.getLongOrNull(songIndex)?.let { id -> val song =
musicStore.songs.find { it.id == id } cursor.getLongOrNull(songIndex)?.let { id -> musicStore.songs.find { it.id == id } }
}
val mode = PlaybackMode.fromInt(cursor.getInt(modeIndex)) ?: PlaybackMode.ALL_SONGS 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) { when (mode) {
PlaybackMode.IN_GENRE -> musicStore.genres.find { it.id == id } PlaybackMode.IN_GENRE -> musicStore.genres.find { it.id == id }
PlaybackMode.IN_ARTIST -> musicStore.artists.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, song = song,
position = cursor.getLong(posIndex), position = cursor.getLong(posIndex),
parent = parent, parent = parent,
@ -157,9 +153,7 @@ class PlaybackStateDatabase(context: Context) :
return state 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) { fun writeState(state: SavedState) {
assertBackgroundThread() assertBackgroundThread()
@ -168,7 +162,8 @@ class PlaybackStateDatabase(context: Context) :
this@PlaybackStateDatabase.logD("Wiped state db") this@PlaybackStateDatabase.logD("Wiped state db")
val stateData = ContentValues(10).apply { val stateData =
ContentValues(10).apply {
put(StateColumns.COLUMN_ID, 0) put(StateColumns.COLUMN_ID, 0)
put(StateColumns.COLUMN_SONG_HASH, state.song?.id) put(StateColumns.COLUMN_SONG_HASH, state.song?.id)
put(StateColumns.COLUMN_POSITION, state.position) put(StateColumns.COLUMN_POSITION, state.position)
@ -202,9 +197,7 @@ class PlaybackStateDatabase(context: Context) :
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
musicStore.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex)) musicStore.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))
?.let { song -> ?.let { song -> queue.add(song) }
queue.add(song)
}
} }
} }
@ -213,16 +206,12 @@ class PlaybackStateDatabase(context: Context) :
return queue return queue
} }
/** /** Write a queue to the database. */
* Write a queue to the database.
*/
fun writeQueue(queue: MutableList<Song>) { fun writeQueue(queue: MutableList<Song>) {
assertBackgroundThread() assertBackgroundThread()
val database = writableDatabase val database = writableDatabase
database.transaction { database.transaction { delete(TABLE_NAME_QUEUE, null, null) }
delete(TABLE_NAME_QUEUE, null, null)
}
logD("Wiped queue db") logD("Wiped queue db")
@ -243,7 +232,8 @@ class PlaybackStateDatabase(context: Context) :
val song = queue[i] val song = queue[i]
i++ i++
val itemData = ContentValues(4).apply { val itemData =
ContentValues(4).apply {
put(QueueColumns.ID, idStart + i) put(QueueColumns.ID, idStart + i)
put(QueueColumns.SONG_HASH, song.id) put(QueueColumns.SONG_HASH, song.id)
put(QueueColumns.ALBUM_HASH, song.album.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_STATE = "playback_state_table"
const val TABLE_NAME_QUEUE = "queue_table" const val TABLE_NAME_QUEUE = "queue_table"
@Volatile @Volatile private var INSTANCE: PlaybackStateDatabase? = null
private var INSTANCE: PlaybackStateDatabase? = null
/** /** Get/Instantiate the single instance of [PlaybackStateDatabase]. */
* Get/Instantiate the single instance of [PlaybackStateDatabase].
*/
fun getInstance(context: Context): PlaybackStateDatabase { fun getInstance(context: Context): PlaybackStateDatabase {
val currentInstance = INSTANCE val currentInstance = INSTANCE

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackStateManager.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. * Master class (and possible god object) for the playback state.
* *
* This should ***NOT*** be used outside of the playback module. * 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 in the UI, use
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use [org.oxycblt.auxio.playback.system.PlaybackService]. * [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]. * All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt * @author OxygenCobalt
@ -90,27 +91,38 @@ class PlaybackStateManager private constructor() {
private var mHasPlayed = false private var mHasPlayed = false
/** The currently playing song. Null if there isn't one */ /** 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 */ /** 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 */ /** The current playback progress */
val position: Long get() = mPosition val position: Long
get() = mPosition
/** The current queue determined by [parent] and [playbackMode] */ /** 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 */ /** The current position in the queue */
val index: Int get() = mIndex val index: Int
get() = mIndex
/** The current [PlaybackMode] */ /** The current [PlaybackMode] */
val playbackMode: PlaybackMode get() = mPlaybackMode val playbackMode: PlaybackMode
get() = mPlaybackMode
/** Whether playback is paused or not */ /** Whether playback is paused or not */
val isPlaying: Boolean get() = mIsPlaying val isPlaying: Boolean
get() = mIsPlaying
/** Whether the queue is shuffled */ /** Whether the queue is shuffled */
val isShuffling: Boolean get() = mIsShuffling val isShuffling: Boolean
get() = mIsShuffling
/** The current [LoopMode] */ /** The current [LoopMode] */
val loopMode: LoopMode get() = mLoopMode val loopMode: LoopMode
get() = mLoopMode
/** Whether this instance has already been restored */ /** 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.** */ /** 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() private val settingsManager = SettingsManager.getInstance()
@ -119,16 +131,14 @@ class PlaybackStateManager private constructor() {
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
/** /**
* Add a [PlaybackStateManager.Callback] to this instance. * Add a [PlaybackStateManager.Callback] to this instance. Make sure to remove the callback with
* Make sure to remove the callback with [removeCallback] when done. * [removeCallback] when done.
*/ */
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
callbacks.add(callback) callbacks.add(callback)
} }
/** /** Remove a [PlaybackStateManager.Callback] bound to this instance. */
* Remove a [PlaybackStateManager.Callback] bound to this instance.
*/
fun removeCallback(callback: Callback) { fun removeCallback(callback: Callback) {
callbacks.remove(callback) callbacks.remove(callback)
} }
@ -149,17 +159,14 @@ class PlaybackStateManager private constructor() {
mParent = null mParent = null
mQueue = musicStore.songs.toMutableList() mQueue = musicStore.songs.toMutableList()
} }
PlaybackMode.IN_GENRE -> { PlaybackMode.IN_GENRE -> {
mParent = song.genre mParent = song.genre
mQueue = song.genre.songs.toMutableList() mQueue = song.genre.songs.toMutableList()
} }
PlaybackMode.IN_ARTIST -> { PlaybackMode.IN_ARTIST -> {
mParent = song.album.artist mParent = song.album.artist
mQueue = song.album.artist.songs.toMutableList() mQueue = song.album.artist.songs.toMutableList()
} }
PlaybackMode.IN_ALBUM -> { PlaybackMode.IN_ALBUM -> {
mParent = song.album mParent = song.album
mQueue = song.album.songs.toMutableList() mQueue = song.album.songs.toMutableList()
@ -188,12 +195,10 @@ class PlaybackStateManager private constructor() {
mQueue = parent.songs.toMutableList() mQueue = parent.songs.toMutableList()
mPlaybackMode = PlaybackMode.IN_ALBUM mPlaybackMode = PlaybackMode.IN_ALBUM
} }
is Artist -> { is Artist -> {
mQueue = parent.songs.toMutableList() mQueue = parent.songs.toMutableList()
mPlaybackMode = PlaybackMode.IN_ARTIST mPlaybackMode = PlaybackMode.IN_ARTIST
} }
is Genre -> { is Genre -> {
mQueue = parent.songs.toMutableList() mQueue = parent.songs.toMutableList()
mPlaybackMode = PlaybackMode.IN_GENRE mPlaybackMode = PlaybackMode.IN_GENRE
@ -204,9 +209,7 @@ class PlaybackStateManager private constructor() {
updatePlayback(mQueue[0]) updatePlayback(mQueue[0])
} }
/** /** Shuffle all songs. */
* Shuffle all songs.
*/
fun shuffleAll() { fun shuffleAll() {
val musicStore = MusicStore.maybeGetInstance() ?: return val musicStore = MusicStore.maybeGetInstance() ?: return
@ -218,9 +221,7 @@ class PlaybackStateManager private constructor() {
updatePlayback(mQueue[0]) 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) { private fun updatePlayback(song: Song, shouldPlay: Boolean = true) {
mSong = song mSong = song
mPosition = 0 mPosition = 0
@ -229,9 +230,7 @@ class PlaybackStateManager private constructor() {
// --- QUEUE FUNCTIONS --- // --- 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() { fun next() {
// Increment the index, if it cannot be incremented any further, then // Increment the index, if it cannot be incremented any further, then
// loop and pause/resume playback depending on the setting // loop and pause/resume playback depending on the setting
@ -246,9 +245,7 @@ class PlaybackStateManager private constructor() {
pushQueueUpdate() 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() { fun prev() {
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
if (settingsManager.rewindWithPrev && mPosition >= REWIND_THRESHOLD) { if (settingsManager.rewindWithPrev && mPosition >= REWIND_THRESHOLD) {
@ -266,9 +263,7 @@ class PlaybackStateManager private constructor() {
// --- QUEUE EDITING FUNCTIONS --- // --- 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 { fun removeQueueItem(index: Int): Boolean {
if (index > mQueue.size || index < 0) { if (index > mQueue.size || index < 0) {
logE("Index is out of bounds, did not remove queue item") logE("Index is out of bounds, did not remove queue item")
@ -281,9 +276,7 @@ class PlaybackStateManager private constructor() {
return true 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 { fun moveQueueItems(from: Int, to: Int): Boolean {
if (from > mQueue.size || from < 0 || to > mQueue.size || to < 0) { if (from > mQueue.size || from < 0 || to > mQueue.size || to < 0) {
logE("Indices were out of bounds, did not move queue item") logE("Indices were out of bounds, did not move queue item")
@ -296,9 +289,7 @@ class PlaybackStateManager private constructor() {
return true return true
} }
/** /** Add a [song] to the top of the queue. */
* Add a [song] to the top of the queue.
*/
fun playNext(song: Song) { fun playNext(song: Song) {
if (mQueue.isEmpty()) { if (mQueue.isEmpty()) {
return return
@ -308,9 +299,7 @@ class PlaybackStateManager private constructor() {
pushQueueUpdate() 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>) { fun playNext(songs: List<Song>) {
if (mQueue.isEmpty()) { if (mQueue.isEmpty()) {
return return
@ -320,29 +309,21 @@ class PlaybackStateManager private constructor() {
pushQueueUpdate() pushQueueUpdate()
} }
/** /** Add a [song] to the end of the queue. */
* Add a [song] to the end of the queue.
*/
fun addToQueue(song: Song) { fun addToQueue(song: Song) {
mQueue.add(song) mQueue.add(song)
pushQueueUpdate() 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>) { fun addToQueue(songs: List<Song>) {
mQueue.addAll(songs) mQueue.addAll(songs)
pushQueueUpdate() pushQueueUpdate()
} }
/** /** Force any callbacks to receive a queue update. */
* Force any callbacks to receive a queue update.
*/
private fun pushQueueUpdate() { private fun pushQueueUpdate() {
callbacks.forEach { callbacks.forEach { it.onQueueUpdate(mQueue, mIndex) }
it.onQueueUpdate(mQueue, mIndex)
}
} }
// --- SHUFFLE FUNCTIONS --- // --- SHUFFLE FUNCTIONS ---
@ -392,7 +373,8 @@ class PlaybackStateManager private constructor() {
val musicStore = MusicStore.maybeGetInstance() ?: return val musicStore = MusicStore.maybeGetInstance() ?: return
val lastSong = mSong val lastSong = mSong
mQueue = when (mPlaybackMode) { mQueue =
when (mPlaybackMode) {
PlaybackMode.ALL_SONGS -> PlaybackMode.ALL_SONGS ->
settingsManager.libSongSort.sortSongs(musicStore.songs).toMutableList() settingsManager.libSongSort.sortSongs(musicStore.songs).toMutableList()
PlaybackMode.IN_ALBUM -> PlaybackMode.IN_ALBUM ->
@ -412,9 +394,7 @@ class PlaybackStateManager private constructor() {
// --- STATE FUNCTIONS --- // --- STATE FUNCTIONS ---
/** /** Set whether this instance is currently [playing]. */
* Set whether this instance is currently [playing].
*/
fun setPlaying(playing: Boolean) { fun setPlaying(playing: Boolean) {
if (mIsPlaying != playing) { if (mIsPlaying != playing) {
if (playing) { if (playing) {
@ -449,39 +429,29 @@ class PlaybackStateManager private constructor() {
callbacks.forEach { it.onSeek(position) } callbacks.forEach { it.onSeek(position) }
} }
/** /** Rewind to the beginning of a song. */
* Rewind to the beginning of a song.
*/
fun rewind() { fun rewind() {
seekTo(0) seekTo(0)
setPlaying(true) setPlaying(true)
} }
/** /** Loop playback around to the beginning. */
* Loop playback around to the beginning.
*/
fun loop() { fun loop() {
seekTo(0) seekTo(0)
setPlaying(!settingsManager.pauseOnLoop) setPlaying(!settingsManager.pauseOnLoop)
} }
/** /** Set the [LoopMode] to [mode]. */
* Set the [LoopMode] to [mode].
*/
fun setLoopMode(mode: LoopMode) { fun setLoopMode(mode: LoopMode) {
mLoopMode = mode mLoopMode = mode
} }
/** /** Mark whether this instance has played or not */
* Mark whether this instance has played or not
*/
fun setHasPlayed(hasPlayed: Boolean) { fun setHasPlayed(hasPlayed: Boolean) {
mHasPlayed = hasPlayed mHasPlayed = hasPlayed
} }
/** /** Mark this instance as restored. */
* Mark this instance as restored.
*/
fun markRestored() { fun markRestored() {
mIsRestored = true mIsRestored = true
} }
@ -503,16 +473,19 @@ class PlaybackStateManager private constructor() {
database.writeState( database.writeState(
PlaybackStateDatabase.SavedState( PlaybackStateDatabase.SavedState(
mSong, mPosition, mParent, mIndex, mSong,
mPlaybackMode, mIsShuffling, mLoopMode, mPosition,
) mParent,
) mIndex,
mPlaybackMode,
mIsShuffling,
mLoopMode,
))
database.writeQueue(mQueue) database.writeQueue(mQueue)
this@PlaybackStateManager.logD( 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() markRestored()
} }
/** /** Unpack a [playbackState] into this instance. */
* Unpack a [playbackState] into this instance.
*/
private fun unpackFromPlaybackState(playbackState: PlaybackStateDatabase.SavedState) { private fun unpackFromPlaybackState(playbackState: PlaybackStateDatabase.SavedState) {
// Turn the simplified information from PlaybackState into usable data. // Turn the simplified information from PlaybackState into usable data.
@ -573,15 +544,14 @@ class PlaybackStateManager private constructor() {
pushQueueUpdate() 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() { private fun doParentSanityCheck() {
// Check if the parent was lost while in the DB. // Check if the parent was lost while in the DB.
if (mSong != null && mParent == null && mPlaybackMode != PlaybackMode.ALL_SONGS) { if (mSong != null && mParent == null && mPlaybackMode != PlaybackMode.ALL_SONGS) {
logD("Parent lost, attempting restore") logD("Parent lost, attempting restore")
mParent = when (mPlaybackMode) { mParent =
when (mPlaybackMode) {
PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album
PlaybackMode.IN_ARTIST -> mQueue.firstOrNull()?.album?.artist PlaybackMode.IN_ARTIST -> mQueue.firstOrNull()?.album?.artist
PlaybackMode.IN_GENRE -> mQueue.firstOrNull()?.genre 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() { private fun doIndexSanityCheck() {
// Be careful with how we handle the queue since a possible index de-sync // Be careful with how we handle the queue since a possible index de-sync
// could easily result in an OOB crash. // could easily result in an OOB crash.
@ -639,9 +607,8 @@ class PlaybackStateManager private constructor() {
} }
/** /**
* The interface for receiving updates from [PlaybackStateManager]. * The interface for receiving updates from [PlaybackStateManager]. Add the callback to
* Add the callback to [PlaybackStateManager] using [addCallback], * [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
* remove them on destruction with [removeCallback].
*/ */
interface Callback { interface Callback {
fun onSongUpdate(song: Song?) {} fun onSongUpdate(song: Song?) {}
@ -658,12 +625,9 @@ class PlaybackStateManager private constructor() {
companion object { companion object {
private const val REWIND_THRESHOLD = 3000L private const val REWIND_THRESHOLD = 3000L
@Volatile @Volatile private var INSTANCE: PlaybackStateManager? = null
private var INSTANCE: PlaybackStateManager? = null
/** /** Get/Instantiate the single instance of [PlaybackStateManager]. */
* Get/Instantiate the single instance of [PlaybackStateManager].
*/
fun getInstance(): PlaybackStateManager { fun getInstance(): PlaybackStateManager {
val currentInstance = INSTANCE val currentInstance = INSTANCE

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AudioReactor.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.Metadata
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import kotlin.math.pow
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import kotlin.math.pow
/** /**
* Manages the current volume and playback state across ReplayGain and AudioFocus events. * Manages the current volume and playback state across ReplayGain and AudioFocus events.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class AudioReactor( class AudioReactor(context: Context, private val callback: (Float) -> Unit) :
context: Context, AudioManager.OnAudioFocusChangeListener, SettingsManager.Callback {
private val callback: (Float) -> Unit
) : AudioManager.OnAudioFocusChangeListener, SettingsManager.Callback {
private data class Gain(val track: Float, val album: Float) private data class Gain(val track: Float, val album: Float)
private data class GainTag(val key: String, val value: Float) private data class GainTag(val key: String, val value: Float)
@ -51,14 +48,14 @@ class AudioReactor(
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val audioManager = context.getSystemServiceSafe(AudioManager::class) 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) .setWillPauseWhenDucked(false)
.setAudioAttributes( .setAudioAttributes(
AudioAttributesCompat.Builder() AudioAttributesCompat.Builder()
.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributesCompat.USAGE_MEDIA) .setUsage(AudioAttributesCompat.USAGE_MEDIA)
.build() .build())
)
.setOnAudioFocusChangeListener(this) .setOnAudioFocusChangeListener(this)
.build() .build()
@ -82,19 +79,16 @@ class AudioReactor(
settingsManager.addCallback(this) settingsManager.addCallback(this)
} }
/** /** Request the android system for audio focus */
* Request the android system for audio focus
*/
fun requestFocus() { fun requestFocus() {
logD("Requesting audio focus") logD("Requesting audio focus")
AudioManagerCompat.requestAudioFocus(audioManager, request) AudioManagerCompat.requestAudioFocus(audioManager, request)
} }
/** /**
* Updates the rough volume adjustment for [Metadata] with ReplayGain tags. * Updates the rough volume adjustment for [Metadata] with ReplayGain tags. This is based off
* This is based off Vanilla Music's implementation. * Vanilla Music's implementation. TODO: Add ReplayGain pre-amp TODO: Add positive ReplayGain
* TODO: Add ReplayGain pre-amp * values
* TODO: Add positive ReplayGain values
*/ */
fun applyReplayGain(metadata: Metadata?) { fun applyReplayGain(metadata: Metadata?) {
if (metadata == null) { if (metadata == null) {
@ -104,7 +98,8 @@ class AudioReactor(
} }
// ReplayGain is configurable, so determine what to do based off of the mode. // 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 -> { ReplayGainMode.OFF -> {
logD("ReplayGain is off") logD("ReplayGain is off")
volume = 1f volume = 1f
@ -113,21 +108,14 @@ class AudioReactor(
// User wants track gain to be preferred. Default to album gain only if there // User wants track gain to be preferred. Default to album gain only if there
// is no track gain. // is no track gain.
ReplayGainMode.TRACK -> ReplayGainMode.TRACK -> { gain -> gain.track == 0f }
{ gain ->
gain.track == 0f
}
// User wants album gain to be preferred. Default to track gain only if there // User wants album gain to be preferred. Default to track gain only if there
// is no album gain. // is no album gain.
ReplayGainMode.ALBUM -> ReplayGainMode.ALBUM -> { gain -> gain.album != 0f }
{ gain ->
gain.album != 0f
}
// User wants album gain to be used when in an album, track gain otherwise. // User wants album gain to be used when in an album, track gain otherwise.
ReplayGainMode.DYNAMIC -> ReplayGainMode.DYNAMIC -> { _ ->
{ _ ->
playbackManager.parent is Album && playbackManager.parent is Album &&
playbackManager.song?.album == playbackManager.parent playbackManager.song?.album == playbackManager.parent
} }
@ -135,7 +123,8 @@ class AudioReactor(
val gain = parseReplayGain(metadata) val gain = parseReplayGain(metadata)
val adjust = if (gain != null) { val adjust =
if (gain != null) {
if (useAlbumGain(gain)) { if (useAlbumGain(gain)) {
logD("Using album gain") logD("Using album gain")
gain.album gain.album
@ -171,12 +160,10 @@ class AudioReactor(
key = entry.description?.uppercase() key = entry.description?.uppercase()
value = entry.value value = entry.value
} }
is VorbisComment -> { is VorbisComment -> {
key = entry.key key = entry.key
value = entry.value value = entry.value
} }
else -> continue 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() { fun release() {
AudioManagerCompat.abandonAudioFocusRequest(audioManager, request) AudioManagerCompat.abandonAudioFocusRequest(audioManager, request)
settingsManager.removeCallback(this) settingsManager.removeCallback(this)
@ -302,11 +287,6 @@ class AudioReactor(
const val R128_TRACK = "R128_TRACK_GAIN" const val R128_TRACK = "R128_TRACK_GAIN"
const val R128_ALBUM = "R128_ALBUM_GAIN" const val R128_ALBUM = "R128_ALBUM_GAIN"
val REPLAY_GAIN_TAGS = arrayOf( val REPLAY_GAIN_TAGS = arrayOf(RG_TRACK, RG_ALBUM, R128_ALBUM, R128_TRACK)
RG_TRACK,
RG_ALBUM,
R128_ALBUM,
R128_TRACK
)
} }
} }

View file

@ -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 package org.oxycblt.auxio.playback.system
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -8,14 +25,14 @@ import androidx.core.content.ContextCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* Some apps like to party like it's 2011 and just blindly query for the ACTION_MEDIA_BUTTON * Some apps like to party like it's 2011 and just blindly query for the ACTION_MEDIA_BUTTON intent
* intent to determine the media apps on a system. *Auxio does not expose this.* Auxio exposes * to determine the media apps on a system. *Auxio does not expose this.* Auxio exposes a
* a MediaSession that an app should control instead through the much better MediaController API. * MediaSession that an app should control instead through the much better MediaController API. But
* But who cares about that, we need to make sure the 3% of barely functioning TouchWiz devices * who cares about that, we need to make sure the 3% of barely functioning TouchWiz devices running
* running KitKat don't break! To prevent Auxio from not showing up at all in these apps, we * KitKat don't break! To prevent Auxio from not showing up at all in these apps, we declare a
* declare a BroadcastReceiver that deliberately handles this event. This also means that Auxio * BroadcastReceiver that deliberately handles this event. This also means that Auxio will start
* will start without warning if you use the media buttons while the app exists, because I guess * without warning if you use the media buttons while the app exists, because I guess we just have
* we just have to deal with this. * to deal with this.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MediaButtonReceiver : BroadcastReceiver() { class MediaButtonReceiver : BroadcastReceiver() {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackNotification.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.newMainIntent
/** /**
* The unified notification for [PlaybackService]. This is not self-sufficient, updates have * The unified notification for [PlaybackService]. This is not self-sufficient, updates have to be
* to be delivered manually. * delivered manually.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class PlaybackNotification private constructor( class PlaybackNotification
private val context: Context, private constructor(private val context: Context, mediaToken: MediaSessionCompat.Token) :
mediaToken: MediaSessionCompat.Token NotificationCompat.Builder(context, CHANNEL_ID) {
) : NotificationCompat.Builder(context, CHANNEL_ID) {
init { init {
setSmallIcon(R.drawable.ic_auxio) setSmallIcon(R.drawable.ic_auxio)
setCategory(NotificationCompat.CATEGORY_SERVICE) 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_SKIP_NEXT, R.drawable.ic_skip_next))
addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_exit)) addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_exit))
setStyle( setStyle(MediaStyle().setMediaSession(mediaToken).setShowActionsInCompactView(1, 2, 3))
MediaStyle()
.setMediaSession(mediaToken)
.setShowActionsInCompactView(1, 2, 3)
)
// Don't connect to PlaybackStateManager here. This is because it's possible for this // Don't connect to PlaybackStateManager here. This is because it's possible for this
// notification to not be updated by PlaybackStateManager before PlaybackService pushes // 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) { fun setPlaying(isPlaying: Boolean) {
mActions[2] = buildPlayPauseAction(context, isPlaying) 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) { fun setLoop(loopMode: LoopMode) {
mActions[0] = buildLoopAction(context, 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) { fun setShuffle(isShuffling: Boolean) {
mActions[0] = buildShuffleAction(context, isShuffling) 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?) { fun setParent(parent: MusicParent?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return 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) return buildAction(context, PlaybackService.ACTION_PLAY_PAUSE, drawableRes)
} }
private fun buildLoopAction( private fun buildLoopAction(context: Context, loopMode: LoopMode): NotificationCompat.Action {
context: Context, val drawableRes =
loopMode: LoopMode when (loopMode) {
): NotificationCompat.Action {
val drawableRes = when (loopMode) {
LoopMode.NONE -> R.drawable.ic_remote_loop_off LoopMode.NONE -> R.drawable.ic_remote_loop_off
LoopMode.ALL -> R.drawable.ic_loop LoopMode.ALL -> R.drawable.ic_loop
LoopMode.TRACK -> R.drawable.ic_loop_one LoopMode.TRACK -> R.drawable.ic_loop_one
@ -155,7 +139,8 @@ class PlaybackNotification private constructor(
context: Context, context: Context,
isShuffled: Boolean isShuffled: Boolean
): NotificationCompat.Action { ): 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) return buildAction(context, PlaybackService.ACTION_SHUFFLE, drawableRes)
} }
@ -165,10 +150,9 @@ class PlaybackNotification private constructor(
actionName: String, actionName: String,
@DrawableRes iconRes: Int @DrawableRes iconRes: Int
): NotificationCompat.Action { ): NotificationCompat.Action {
val action = NotificationCompat.Action.Builder( val action =
iconRes, actionName, NotificationCompat.Action.Builder(
context.newBroadcastIntent(actionName) iconRes, actionName, context.newBroadcastIntent(actionName))
)
return action.build() return action.build()
} }
@ -177,19 +161,18 @@ class PlaybackNotification private constructor(
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK" const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK"
const val NOTIFICATION_ID = 0xA0A0 const val NOTIFICATION_ID = 0xA0A0
/** /** Build a new instance of [PlaybackNotification]. */
* Build a new instance of [PlaybackNotification].
*/
fun from( fun from(
context: Context, context: Context,
notificationManager: NotificationManager, notificationManager: NotificationManager,
mediaSession: MediaSessionCompat mediaSession: MediaSessionCompat
): PlaybackNotification { ): PlaybackNotification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel =
CHANNEL_ID, context.getString(R.string.info_channel_name), NotificationChannel(
NotificationManager.IMPORTANCE_DEFAULT CHANNEL_ID,
) context.getString(R.string.info_channel_name),
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackService.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * - Headset management
* - Widgets * - Widgets
* *
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], * This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], so
* so therefore there's no need to bind to it to deliver commands. * therefore there's no need to bind to it to deliver commands.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback { class PlaybackService :
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
// Player components // Player components
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSession: MediaSessionCompat
@ -126,10 +126,10 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.setContentType(C.CONTENT_TYPE_MUSIC) .setContentType(C.CONTENT_TYPE_MUSIC)
.build(), .build(),
false false)
)
audioReactor = AudioReactor(this) { volume -> audioReactor =
AudioReactor(this) { volume ->
logD("Updating player volume to $volume") logD("Updating player volume to $volume")
player.volume = volume player.volume = volume
} }
@ -139,9 +139,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
widgets = WidgetController(this) widgets = WidgetController(this)
// Set up the media button callbacks // Set up the media button callbacks
mediaSession = MediaSessionCompat(this, packageName).apply { mediaSession = MediaSessionCompat(this, packageName).apply { isActive = true }
isActive = true
}
connector = PlaybackSessionConnector(this, player, mediaSession) connector = PlaybackSessionConnector(this, player, mediaSession)
@ -215,7 +213,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
when (state) { when (state) {
Player.STATE_READY -> startPolling() Player.STATE_READY -> startPolling()
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
if (playbackManager.loopMode == LoopMode.TRACK) { if (playbackManager.loopMode == LoopMode.TRACK) {
playbackManager.loop() playbackManager.loop()
@ -223,7 +220,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
playbackManager.next() playbackManager.next()
} }
} }
else -> {} else -> {}
} }
} }
@ -347,17 +343,14 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
// --- OTHER FUNCTIONS --- // --- OTHER FUNCTIONS ---
/** /** Create the [ExoPlayer] instance. */
* Create the [ExoPlayer] instance.
*/
private fun newPlayer(): ExoPlayer { private fun newPlayer(): ExoPlayer {
// Since Auxio is a music player, only specify an audio renderer to save // Since Auxio is a music player, only specify an audio renderer to save
// battery/apk size/cache size // battery/apk size/cache size
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
arrayOf( arrayOf(
MediaCodecAudioRenderer(this, MediaCodecSelector.DEFAULT, handler, audioListener), MediaCodecAudioRenderer(this, MediaCodecSelector.DEFAULT, handler, audioListener),
LibflacAudioRenderer(handler, audioListener) LibflacAudioRenderer(handler, audioListener))
)
} }
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable // Enable constant bitrate seeking so that certain MP3s/AACs are seekable
@ -369,9 +362,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
.build() .build()
} }
/** /** Fully restore the notification and playback state */
* Fully restore the notification and playback state
*/
private fun restore() { private fun restore() {
logD("Restoring the service state") logD("Restoring the service state")
@ -387,16 +378,16 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
widgets.update() widgets.update()
} }
/** /** Start polling the position on a coroutine. */
* Start polling the position on a coroutine.
*/
private fun startPolling() { private fun startPolling() {
val pollFlow = flow { val pollFlow =
flow {
while (true) { while (true) {
emit(player.currentPosition) emit(player.currentPosition)
delay(POS_POLL_INTERVAL) delay(POS_POLL_INTERVAL)
} }
}.conflate() }
.conflate()
serviceScope.launch { serviceScope.launch {
pollFlow.takeWhile { player.isPlaying }.collect { pos -> 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. // Specify that this is a media service, if supported.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground( startForeground(
PlaybackNotification.NOTIFICATION_ID, notification.build(), PlaybackNotification.NOTIFICATION_ID,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK notification.build(),
) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else { } else {
startForeground(PlaybackNotification.NOTIFICATION_ID, notification.build()) startForeground(PlaybackNotification.NOTIFICATION_ID, notification.build())
} }
@ -427,24 +418,19 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
} else { } else {
// If we are already in foreground just update the notification // If we are already in foreground just update the notification
notificationManager.notify( 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() { private fun stopForegroundAndNotification() {
stopForeground(true) stopForeground(true)
notificationManager.cancel(PlaybackNotification.NOTIFICATION_ID) notificationManager.cancel(PlaybackNotification.NOTIFICATION_ID)
isForeground = false 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 inner class PlaybackReceiver : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false private var initialHeadsetPlugEventHandled = false
@ -477,56 +463,44 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug() AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
// --- AUXIO EVENTS --- // --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> playbackManager.setPlaying( ACTION_PLAY_PAUSE -> playbackManager.setPlaying(!playbackManager.isPlaying)
!playbackManager.isPlaying ACTION_LOOP -> playbackManager.setLoopMode(playbackManager.loopMode.increment())
) ACTION_SHUFFLE ->
playbackManager.setShuffling(!playbackManager.isShuffling, keepSong = true)
ACTION_LOOP -> playbackManager.setLoopMode(
playbackManager.loopMode.increment()
)
ACTION_SHUFFLE -> playbackManager.setShuffling(
!playbackManager.isShuffling, keepSong = true
)
ACTION_SKIP_PREV -> playbackManager.prev() ACTION_SKIP_PREV -> playbackManager.prev()
ACTION_SKIP_NEXT -> playbackManager.next() ACTION_SKIP_NEXT -> playbackManager.next()
ACTION_EXIT -> { ACTION_EXIT -> {
playbackManager.setPlaying(false) playbackManager.setPlaying(false)
stopForegroundAndNotification() stopForegroundAndNotification()
} }
WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update() WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update()
} }
} }
/** /**
* Resume from a headset plug event in the case that the quirk is enabled. * Resume from a headset plug event in the case that the quirk is enabled. This
* This functionality remains a quirk for two reasons: * functionality remains a quirk for two reasons:
* 1. Automatically resuming more or less overrides all other audio streams, which * 1. Automatically resuming more or less overrides all other audio streams, which is not
* is not that friendly * that friendly
* 2. There is a bug where playback will always start when this service starts, mostly * 2. There is a bug where playback will always start when this service starts, mostly due
* due to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but * to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but I fear
* I fear that it may not work on OEM skins that for whatever reason don't make this * that it may not work on OEM skins that for whatever reason don't make this action fire.
* action fire. * TODO: Figure out how players like Retro are able to get autoplay working with bluetooth
* TODO: Figure out how players like Retro are able to get autoplay working with * headsets
* bluetooth headsets
*/ */
private fun maybeResumeFromPlug() { private fun maybeResumeFromPlug() {
if (playbackManager.song != null && if (playbackManager.song != null &&
settingsManager.headsetAutoplay && settingsManager.headsetAutoplay &&
initialHeadsetPlugEventHandled initialHeadsetPlugEventHandled) {
) {
logD("Device connected, resuming") logD("Device connected, resuming")
playbackManager.setPlaying(true) playbackManager.setPlaying(true)
} }
} }
/** /**
* Pause from a headset plug. * Pause from a headset plug. TODO: Find a way to centralize this stuff into a single
* TODO: Find a way to centralize this stuff into a single BroadcastReciever instead * BroadcastReciever instead of the weird disjointed arrangement between MediaSession and
* of the weird disjointed arrangement between MediaSession and this. * this.
*/ */
private fun pauseFromPlug() { private fun pauseFromPlug() {
if (playbackManager.song != null) { if (playbackManager.song != null) {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlaybackSessionConnector.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.logD
/** /**
* Nightmarish class that coordinates communication between [MediaSessionCompat], [Player], * Nightmarish class that coordinates communication between [MediaSessionCompat], [Player], and
* and [PlaybackStateManager]. * [PlaybackStateManager].
*/ */
class PlaybackSessionConnector( class PlaybackSessionConnector(
private val context: Context, private val context: Context,
@ -84,7 +83,8 @@ class PlaybackSessionConnector(
} }
override fun onSetRepeatMode(repeatMode: Int) { override fun onSetRepeatMode(repeatMode: Int) {
val mode = when (repeatMode) { val mode =
when (repeatMode) {
PlaybackStateCompat.REPEAT_MODE_ALL -> LoopMode.ALL PlaybackStateCompat.REPEAT_MODE_ALL -> LoopMode.ALL
PlaybackStateCompat.REPEAT_MODE_GROUP -> LoopMode.ALL PlaybackStateCompat.REPEAT_MODE_GROUP -> LoopMode.ALL
PlaybackStateCompat.REPEAT_MODE_ONE -> LoopMode.TRACK PlaybackStateCompat.REPEAT_MODE_ONE -> LoopMode.TRACK
@ -98,8 +98,7 @@ class PlaybackSessionConnector(
playbackManager.setShuffling( playbackManager.setShuffling(
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP, shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP,
true true)
)
} }
override fun onStop() { override fun onStop() {
@ -117,7 +116,8 @@ class PlaybackSessionConnector(
val artistName = song.resolvedArtistName val artistName = song.resolvedArtistName
val builder = MediaMetadataCompat.Builder() val builder =
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.name) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.name)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName)
@ -149,9 +149,7 @@ class PlaybackSessionConnector(
Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_REPEAT_MODE_CHANGED, Player.EVENT_REPEAT_MODE_CHANGED,
Player.EVENT_PLAYBACK_PARAMETERS_CHANGED Player.EVENT_PLAYBACK_PARAMETERS_CHANGED)) {
)
) {
invalidateSessionState() invalidateSessionState()
} }
} }
@ -162,24 +160,20 @@ class PlaybackSessionConnector(
logD("Updating media session state") logD("Updating media session state")
// Position updates arrive faster when you upload STATE_PAUSED for some insane reason. // Position updates arrive faster when you upload STATE_PAUSED for some insane reason.
val state = PlaybackStateCompat.Builder() val state =
PlaybackStateCompat.Builder()
.setActions(ACTIONS) .setActions(ACTIONS)
.setBufferedPosition(player.bufferedPosition) .setBufferedPosition(player.bufferedPosition)
.setState( .setState(
PlaybackStateCompat.STATE_PAUSED, PlaybackStateCompat.STATE_PAUSED,
player.currentPosition, player.currentPosition,
1.0f, 1.0f,
SystemClock.elapsedRealtime() SystemClock.elapsedRealtime())
)
mediaSession.setPlaybackState(state.build()) mediaSession.setPlaybackState(state.build())
state.setState( state.setState(
getPlayerState(), getPlayerState(), player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
player.currentPosition,
1.0f,
SystemClock.elapsedRealtime()
)
mediaSession.setPlaybackState(state.build()) mediaSession.setPlaybackState(state.build())
} }
@ -199,7 +193,8 @@ class PlaybackSessionConnector(
} }
companion object { companion object {
const val ACTIONS = PlaybackStateCompat.ACTION_PLAY or const val ACTIONS =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SET_REPEAT_MODE or PlaybackStateCompat.ACTION_SET_REPEAT_MODE or

View file

@ -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 package org.oxycblt.auxio.playback.system
/** /** Represents the current setting for ReplayGain. */
* Represents the current setting for ReplayGain.
*/
enum class ReplayGainMode { enum class ReplayGainMode {
/** Do not apply ReplayGain. */ /** Do not apply ReplayGain. */
OFF, OFF,
@ -13,9 +28,7 @@ enum class ReplayGainMode {
/** Apply the album gain only when playing from an album, defaulting to track gain otherwise. */ /** Apply the album gain only when playing from an album, defaulting to track gain otherwise. */
DYNAMIC; DYNAMIC;
/** /** Converts this type to an integer constant. */
* Converts this type to an integer constant.
*/
fun toInt(): Int { fun toInt(): Int {
return when (this) { return when (this) {
OFF -> INT_OFF OFF -> INT_OFF
@ -31,9 +44,7 @@ enum class ReplayGainMode {
private const val INT_ALBUM = 0xA112 private const val INT_ALBUM = 0xA112
private const val INT_DYNAMIC = 0xA113 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? { fun fromInt(value: Int): ReplayGainMode? {
return when (value) { return when (value) {
INT_OFF -> OFF INT_OFF -> OFF

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SearchAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) { return when (viewType) {
GenreViewHolder.ITEM_TYPE -> GenreViewHolder.from( GenreViewHolder.ITEM_TYPE ->
parent.context, doOnClick, doOnLongClick GenreViewHolder.from(parent.context, doOnClick, doOnLongClick)
) ArtistViewHolder.ITEM_TYPE ->
ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick)
ArtistViewHolder.ITEM_TYPE -> ArtistViewHolder.from( AlbumViewHolder.ITEM_TYPE ->
parent.context, doOnClick, doOnLongClick AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick)
) SongViewHolder.ITEM_TYPE ->
SongViewHolder.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) HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type") else -> error("Invalid ViewHolder item type")
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SearchFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 imm = requireContext().getSystemServiceSafe(InputMethodManager::class)
val searchAdapter = SearchAdapter( val searchAdapter =
doOnClick = { item -> SearchAdapter(doOnClick = { item -> onItemSelection(item, imm) }, ::newMenu)
onItemSelection(item, imm)
},
::newMenu
)
// --- UI SETUP -- // --- UI SETUP --
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.searchToolbar.apply { binding.searchToolbar.apply {
val itemId = when (searchModel.filterMode) { val itemId =
when (searchModel.filterMode) {
DisplayMode.SHOW_SONGS -> R.id.option_filter_songs DisplayMode.SHOW_SONGS -> R.id.option_filter_songs
DisplayMode.SHOW_ALBUMS -> R.id.option_filter_albums DisplayMode.SHOW_ALBUMS -> R.id.option_filter_albums
DisplayMode.SHOW_ARTISTS -> R.id.option_filter_artists DisplayMode.SHOW_ARTISTS -> R.id.option_filter_artists
@ -114,9 +110,7 @@ class SearchFragment : Fragment() {
if (!launchedKeyboard) { if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown // Auto-open the keyboard when this view is shown
requestFocus() requestFocus()
postDelayed(200) { postDelayed(200) { imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) }
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
launchedKeyboard = true launchedKeyboard = true
} }
@ -125,9 +119,7 @@ class SearchFragment : Fragment() {
binding.searchRecycler.apply { binding.searchRecycler.apply {
adapter = searchAdapter adapter = searchAdapter
applySpans { pos -> applySpans { pos -> searchAdapter.currentList[pos] is Header }
searchAdapter.currentList[pos] is Header
}
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
@ -148,15 +140,14 @@ class SearchFragment : Fragment() {
} }
detailModel.navToItem.observe(viewLifecycleOwner) { item -> detailModel.navToItem.observe(viewLifecycleOwner) { item ->
findNavController().navigate( findNavController()
.navigate(
when (item) { when (item) {
is Song -> SearchFragmentDirections.actionShowAlbum(item.album.id) is Song -> SearchFragmentDirections.actionShowAlbum(item.album.id)
is Album -> SearchFragmentDirections.actionShowAlbum(item.id) is Album -> SearchFragmentDirections.actionShowAlbum(item.id)
is Artist -> SearchFragmentDirections.actionShowArtist(item.id) is Artist -> SearchFragmentDirections.actionShowArtist(item.id)
else -> return@observe else -> return@observe
} })
)
imm.hide() imm.hide()
} }
@ -177,8 +168,7 @@ class SearchFragment : Fragment() {
} }
/** /**
* Function that handles when an [item] is selected. * Function that handles when an [item] is selected. Handles all datatypes that are selectable.
* Handles all datatypes that are selectable.
*/ */
private fun onItemSelection(item: Music, imm: InputMethodManager) { private fun onItemSelection(item: Music, imm: InputMethodManager) {
if (item is Song) { if (item is Song) {
@ -192,7 +182,8 @@ class SearchFragment : Fragment() {
logD("Navigating to the detail fragment for ${item.name}") logD("Navigating to the detail fragment for ${item.name}")
findNavController().navigate( findNavController()
.navigate(
when (item) { when (item) {
is Genre -> SearchFragmentDirections.actionShowGenre(item.id) is Genre -> SearchFragmentDirections.actionShowGenre(item.id)
is Artist -> SearchFragmentDirections.actionShowArtist(item.id) is Artist -> SearchFragmentDirections.actionShowArtist(item.id)
@ -204,8 +195,7 @@ class SearchFragment : Fragment() {
searchModel.setNavigating(false) searchModel.setNavigating(false)
return return
} }
} })
)
imm.hide() imm.hide()
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SearchViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.text.Normalizer
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Header 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.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import java.text.Normalizer
/** /**
* The [ViewModel] for the search functionality * The [ViewModel] for the search functionality
@ -47,9 +46,12 @@ class SearchViewModel : ViewModel() {
private var mLastQuery = "" private var mLastQuery = ""
/** Current search results from the last [search] call. */ /** Current search results from the last [search] call. */
val searchResults: LiveData<List<Item>> get() = mSearchResults val searchResults: LiveData<List<Item>>
val isNavigating: Boolean get() = mIsNavigating get() = mSearchResults
val filterMode: DisplayMode? get() = mFilterMode val isNavigating: Boolean
get() = mIsNavigating
val filterMode: DisplayMode?
get() = mFilterMode
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
@ -63,8 +65,7 @@ class SearchViewModel : ViewModel() {
} }
/** /**
* Use [query] to perform a search of the music library. * Use [query] to perform a search of the music library. Will push results to [searchResults].
* Will push results to [searchResults].
*/ */
fun search(query: String) { fun search(query: String) {
val musicStore = MusicStore.maybeGetInstance() val musicStore = MusicStore.maybeGetInstance()
@ -118,16 +119,15 @@ class SearchViewModel : ViewModel() {
} }
/** /**
* Update the current filter mode with a menu [id]. * Update the current filter mode with a menu [id]. New value will be pushed to [filterMode].
* New value will be pushed to [filterMode].
*/ */
fun updateFilterModeWithId(@IdRes id: Int) { fun updateFilterModeWithId(@IdRes id: Int) {
mFilterMode = when (id) { mFilterMode =
when (id) {
R.id.option_filter_songs -> DisplayMode.SHOW_SONGS R.id.option_filter_songs -> DisplayMode.SHOW_SONGS
R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS
R.id.option_filter_artists -> DisplayMode.SHOW_ARTISTS R.id.option_filter_artists -> DisplayMode.SHOW_ARTISTS
R.id.option_filter_genres -> DisplayMode.SHOW_GENRES R.id.option_filter_genres -> DisplayMode.SHOW_GENRES
else -> null else -> null
} }
@ -139,13 +139,14 @@ class SearchViewModel : ViewModel() {
} }
/** /**
* Shortcut that will run a ignoreCase filter on a list and only return * Shortcut that will run a ignoreCase filter on a list and only return a value if the resulting
* a value if the resulting list is empty. * list is empty.
*/ */
private fun <T : Music> List<T>.filterByOrNull(value: String): List<T>? { private fun <T : Music> List<T>.filterByOrNull(value: String): List<T>? {
val filtered = filter { val filtered = filter {
// Ensure the name we match with is correct. // Ensure the name we match with is correct.
val name = if (it is MusicParent) { val name =
if (it is MusicParent) {
it.resolvedName it.resolvedName
} else { } else {
it.name it.name
@ -182,8 +183,8 @@ class SearchViewModel : ViewModel() {
when (Character.getType(cp)) { when (Character.getType(cp)) {
// Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK were added // Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK were added
// by normalizer // by normalizer
6, 8 -> continue 6,
8 -> continue
else -> sb.appendCodePoint(cp) else -> sb.appendCodePoint(cp)
} }
} }
@ -191,9 +192,7 @@ class SearchViewModel : ViewModel() {
return sb.toString() return sb.toString()
} }
/** /** Update the current navigation status to [isNavigating] */
* Update the current navigation status to [isNavigating]
*/
fun setNavigating(isNavigating: Boolean) { fun setNavigating(isNavigating: Boolean) {
mIsNavigating = isNavigating mIsNavigating = isNavigating
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AboutFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -59,9 +58,7 @@ class AboutFragment : Fragment() {
insets insets
} }
binding.aboutToolbar.setNavigationOnClickListener { binding.aboutToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
findNavController().navigateUp()
}
binding.aboutVersion.text = BuildConfig.VERSION_NAME binding.aboutVersion.text = BuildConfig.VERSION_NAME
binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_CODEBASE) } binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_CODEBASE) }
@ -69,9 +66,7 @@ class AboutFragment : Fragment() {
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) } binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
homeModel.songs.observe(viewLifecycleOwner) { songs -> homeModel.songs.observe(viewLifecycleOwner) { songs ->
binding.aboutSongCount.text = getString( binding.aboutSongCount.text = getString(R.string.fmt_songs_loaded, songs.size)
R.string.fmt_songs_loaded, songs.size
)
} }
logD("Dialog created") logD("Dialog created")
@ -79,15 +74,12 @@ class AboutFragment : Fragment() {
return binding.root 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) { private fun openLinkInBrowser(link: String) {
logD("Opening $link") logD("Opening $link")
val browserIntent = Intent(Intent.ACTION_VIEW, link.toUri()).setFlags( val browserIntent =
Intent.FLAG_ACTIVITY_NEW_TASK Intent(Intent.ACTION_VIEW, link.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11 seems to now handle the app chooser situations on its own now // 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 // 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 // case, we will try to manually handle these cases before we try to launch the
// browser. // browser.
val pkgName = requireContext().packageManager.resolveActivity( val pkgName =
browserIntent, PackageManager.MATCH_DEFAULT_ONLY requireContext()
)?.activityInfo?.packageName .packageManager
.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo
?.packageName
if (pkgName != null) { if (pkgName != null) {
if (pkgName == "android") { if (pkgName == "android") {
@ -130,7 +125,8 @@ class AboutFragment : Fragment() {
} }
private fun openAppChooser(intent: Intent) { private fun openAppChooser(intent: Intent) {
val chooserIntent = Intent(Intent.ACTION_CHOOSER) val chooserIntent =
Intent(Intent.ACTION_CHOOSER)
.putExtra(Intent.EXTRA_INTENT, intent) .putExtra(Intent.EXTRA_INTENT, intent)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SettingsCompat.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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)) 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 { private object OldKeys {
const val KEY_ACCENT2 = "KEY_ACCENT2" const val KEY_ACCENT2 = "KEY_ACCENT2"
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SettingsFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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) val binding = FragmentSettingsBinding.inflate(inflater)
binding.settingsToolbar.apply { binding.settingsToolbar.apply {
setNavigationOnClickListener { setNavigationOnClickListener { findNavController().navigateUp() }
findNavController().navigateUp()
}
} }
binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SettingsListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 coil.Coil
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.accent.AccentCustomizeDialog import org.oxycblt.auxio.accent.AccentCustomizeDialog
import org.oxycblt.auxio.music.excluded.ExcludedDialog
import org.oxycblt.auxio.home.tabs.TabCustomizeDialog import org.oxycblt.auxio.home.tabs.TabCustomizeDialog
import org.oxycblt.auxio.music.excluded.ExcludedDialog
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.pref.IntListPrefDialog import org.oxycblt.auxio.settings.pref.IntListPrefDialog
import org.oxycblt.auxio.settings.pref.IntListPreference 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) { private fun recursivelyHandlePreference(preference: Preference) {
if (!preference.isVisible) return if (!preference.isVisible) return
@ -100,15 +97,16 @@ class SettingsListFragment : PreferenceFragmentCompat() {
SettingsManager.KEY_THEME -> { SettingsManager.KEY_THEME -> {
setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon()) setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon())
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, value -> onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, value ->
AppCompatDelegate.setDefaultNightMode(value as Int) AppCompatDelegate.setDefaultNightMode(value as Int)
setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon()) setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon())
true true
} }
} }
SettingsManager.KEY_BLACK_THEME -> { SettingsManager.KEY_BLACK_THEME -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener { onPreferenceClickListener =
Preference.OnPreferenceClickListener {
if (requireContext().isNight) { if (requireContext().isNight) {
requireActivity().recreate() requireActivity().recreate()
} }
@ -116,35 +114,34 @@ class SettingsListFragment : PreferenceFragmentCompat() {
true true
} }
} }
SettingsManager.KEY_ACCENT -> { SettingsManager.KEY_ACCENT -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener { onPreferenceClickListener =
AccentCustomizeDialog().show(childFragmentManager, AccentCustomizeDialog.TAG) Preference.OnPreferenceClickListener {
AccentCustomizeDialog()
.show(childFragmentManager, AccentCustomizeDialog.TAG)
true true
} }
summary = context.getString(settingsManager.accent.name) summary = context.getString(settingsManager.accent.name)
} }
SettingsManager.KEY_LIB_TABS -> { SettingsManager.KEY_LIB_TABS -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener { onPreferenceClickListener =
Preference.OnPreferenceClickListener {
TabCustomizeDialog().show(childFragmentManager, TabCustomizeDialog.TAG) TabCustomizeDialog().show(childFragmentManager, TabCustomizeDialog.TAG)
true true
} }
} }
SettingsManager.KEY_SHOW_COVERS, SettingsManager.KEY_QUALITY_COVERS -> { SettingsManager.KEY_SHOW_COVERS, SettingsManager.KEY_QUALITY_COVERS -> {
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> onPreferenceChangeListener =
Coil.imageLoader(requireContext()).apply { Preference.OnPreferenceChangeListener { _, _ ->
this.memoryCache?.clear() Coil.imageLoader(requireContext()).apply { this.memoryCache?.clear() }
}
true true
} }
} }
SettingsManager.KEY_SAVE_STATE -> { SettingsManager.KEY_SAVE_STATE -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener { onPreferenceClickListener =
Preference.OnPreferenceClickListener {
playbackModel.savePlaybackState(requireContext()) { playbackModel.savePlaybackState(requireContext()) {
requireContext().showToast(R.string.lbl_state_saved) requireContext().showToast(R.string.lbl_state_saved)
} }
@ -152,9 +149,9 @@ class SettingsListFragment : PreferenceFragmentCompat() {
true true
} }
} }
SettingsManager.KEY_RELOAD -> { SettingsManager.KEY_RELOAD -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener { onPreferenceClickListener =
Preference.OnPreferenceClickListener {
playbackModel.savePlaybackState(requireContext()) { playbackModel.savePlaybackState(requireContext()) {
requireContext().hardRestart() requireContext().hardRestart()
} }
@ -162,9 +159,9 @@ class SettingsListFragment : PreferenceFragmentCompat() {
true true
} }
} }
SettingsManager.KEY_EXCLUDED -> { SettingsManager.KEY_EXCLUDED -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener { onPreferenceClickListener =
Preference.OnPreferenceClickListener {
ExcludedDialog().show(childFragmentManager, ExcludedDialog.TAG) ExcludedDialog().show(childFragmentManager, ExcludedDialog.TAG)
true 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 @DrawableRes
private fun Int.toThemeIcon(): Int { private fun Int.toThemeIcon(): Int {
return when (this) { return when (this) {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SettingsManager.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. * Whether to display the LoopMode or the shuffle status on the notification. False if loop,
* False if loop, true if shuffle. * true if shuffle.
*/ */
val useAltNotifAction: Boolean val useAltNotifAction: Boolean
get() = prefs.getBoolean(KEY_USE_ALT_NOTIFICATION_ACTION, false) get() = prefs.getBoolean(KEY_USE_ALT_NOTIFICATION_ACTION, false)
/** The current library tabs preferred by the user. */ /** The current library tabs preferred by the user. */
var libTabs: Array<Tab> 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)!! ?: Tab.fromSequence(Tab.SEQUENCE_DEFAULT)!!
set(value) { set(value) {
prefs.edit { prefs.edit {
@ -103,12 +103,14 @@ class SettingsManager private constructor(context: Context) :
/** The current ReplayGain configuration */ /** The current ReplayGain configuration */
val replayGainMode: ReplayGainMode 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 ?: ReplayGainMode.OFF
/** What queue to create when a song is selected (ex. From All Songs or Search) */ /** What queue to create when a song is selected (ex. From All Songs or Search) */
val songPlaybackMode: PlaybackMode 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 ?: PlaybackMode.ALL_SONGS
/** Whether shuffle should stay on when a new song is selected. */ /** Whether shuffle should stay on when a new song is selected. */
@ -119,7 +121,9 @@ class SettingsManager private constructor(context: Context) :
val rewindWithPrev: Boolean val rewindWithPrev: Boolean
get() = prefs.getBoolean(KEY_PREV_REWIND, true) 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 val pauseOnLoop: Boolean
get() = prefs.getBoolean(KEY_LOOP_PAUSE, false) 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 var libSongSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
?: Sort.ByName(true)
set(value) { set(value) {
prefs.edit { prefs.edit {
putInt(KEY_LIB_SONGS_SORT, value.toInt()) 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 var libAlbumSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
?: Sort.ByName(true)
set(value) { set(value) {
prefs.edit { prefs.edit {
putInt(KEY_LIB_ALBUMS_SORT, value.toInt()) 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 var libArtistSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
?: Sort.ByName(true)
set(value) { set(value) {
prefs.edit { prefs.edit {
putInt(KEY_LIB_ARTISTS_SORT, value.toInt()) 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 var libGenreSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
?: Sort.ByName(true)
set(value) { set(value) {
prefs.edit { prefs.edit {
putInt(KEY_LIB_GENRES_SORT, value.toInt()) 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 var detailAlbumSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE)) get() =
?: Sort.ByName(true) Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) { set(value) {
prefs.edit { prefs.edit {
putInt(KEY_DETAIL_ALBUM_SORT, value.toInt()) 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 var detailArtistSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE)) get() =
?: Sort.ByYear(false) Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE)) ?: Sort.ByYear(false)
set(value) { set(value) {
prefs.edit { prefs.edit {
putInt(KEY_DETAIL_ARTIST_SORT, value.toInt()) 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 var detailGenreSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE)) get() =
?: Sort.ByName(true) Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) { set(value) {
prefs.edit { prefs.edit {
putInt(KEY_DETAIL_GENRE_SORT, value.toInt()) putInt(KEY_DETAIL_GENRE_SORT, value.toInt())
@ -226,29 +226,13 @@ class SettingsManager private constructor(context: Context) :
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) { when (key) {
KEY_USE_ALT_NOTIFICATION_ACTION -> callbacks.forEach { KEY_USE_ALT_NOTIFICATION_ACTION ->
it.onNotifActionUpdate(useAltNotifAction) callbacks.forEach { it.onNotifActionUpdate(useAltNotifAction) }
} KEY_SHOW_COVERS -> callbacks.forEach { it.onShowCoverUpdate(showCovers) }
KEY_QUALITY_COVERS -> callbacks.forEach { it.onQualityCoverUpdate(useQualityCovers) }
KEY_SHOW_COVERS -> callbacks.forEach { KEY_LIB_TABS -> callbacks.forEach { it.onLibTabsUpdate(libTabs) }
it.onShowCoverUpdate(showCovers) KEY_AUDIO_FOCUS -> callbacks.forEach { it.onAudioFocusUpdate(doAudioFocus) }
} KEY_REPLAY_GAIN -> callbacks.forEach { it.onReplayGainUpdate(replayGainMode) }
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_ARTIST_SORT = "auxio_artist_sort"
const val KEY_DETAIL_GENRE_SORT = "auxio_genre_sort" const val KEY_DETAIL_GENRE_SORT = "auxio_genre_sort"
@Volatile @Volatile private var INSTANCE: SettingsManager? = null
private var INSTANCE: SettingsManager? = null
/** /**
* Init the single instance of [SettingsManager]. Done so that every object * Init the single instance of [SettingsManager]. Done so that every object can have access
* can have access to it regardless of if it has a context. * to it regardless of if it has a context.
*/ */
fun init(context: Context): SettingsManager { fun init(context: Context): SettingsManager {
if (INSTANCE == null) { if (INSTANCE == null) {
synchronized(this) { synchronized(this) { INSTANCE = SettingsManager(context) }
INSTANCE = SettingsManager(context)
}
} }
return getInstance() return getInstance()
} }
/** /** Get the single instance of [SettingsManager]. */
* Get the single instance of [SettingsManager].
*/
fun getInstance(): SettingsManager { fun getInstance(): SettingsManager {
val instance = INSTANCE val instance = INSTANCE

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* IntListPrefDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.BuildConfig
import org.oxycblt.auxio.ui.LifecycleDialog 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() { class IntListPrefDialog : LifecycleDialog() {
override fun onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
// Since we have to store the preference key as an argument, we have to find the // Since we have to store the preference key as an argument, we have to find the
// preference we need to use manually. // preference we need to use manually.
val pref = requireNotNull( val pref =
(parentFragment as PreferenceFragmentCompat).preferenceManager requireNotNull(
.findPreference<IntListPreference>(requireArguments().getString(ARG_KEY, null)) (parentFragment as PreferenceFragmentCompat).preferenceManager.findPreference<
) IntListPreference>(requireArguments().getString(ARG_KEY, null)))
builder.setTitle(pref.title) builder.setTitle(pref.title)
@ -52,9 +49,7 @@ class IntListPrefDialog : LifecycleDialog() {
fun from(pref: IntListPreference): IntListPrefDialog { fun from(pref: IntListPreference): IntListPrefDialog {
return IntListPrefDialog().apply { return IntListPrefDialog().apply {
arguments = Bundle().apply { arguments = Bundle().apply { putString(ARG_KEY, pref.key) }
putString(ARG_KEY, pref.key)
}
} }
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* IntListPreference.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 androidx.preference.Preference
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
class IntListPreference @JvmOverloads constructor( class IntListPreference
@JvmOverloads
constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle, defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
defStyleRes: Int = 0 defStyleRes: Int = 0
) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { ) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) {
// Reflect into Preference to get the (normally inaccessible) default value. // Reflect into Preference to get the (normally inaccessible) default value.
private val defValueField = Preference::class.java.getDeclaredField("mDefaultValue").apply { private val defValueField =
isAccessible = true Preference::class.java.getDeclaredField("mDefaultValue").apply { isAccessible = true }
}
val entries: Array<CharSequence> val entries: Array<CharSequence>
val values: IntArray val values: IntArray
private var currentValue: Int? = null 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 { init {
val prefAttrs = context.obtainStyledAttributes( val prefAttrs =
attrs, R.styleable.IntListPreference, defStyleAttr, defStyleRes context.obtainStyledAttributes(
) attrs, R.styleable.IntListPreference, defStyleAttr, defStyleRes)
entries = prefAttrs.getTextArray(R.styleable.IntListPreference_entries) entries = prefAttrs.getTextArray(R.styleable.IntListPreference_entries)
values = context.resources.getIntArray( values =
prefAttrs.getResourceId(R.styleable.IntListPreference_entryValues, -1) context.resources.getIntArray(
) prefAttrs.getResourceId(R.styleable.IntListPreference_entryValues, -1))
prefAttrs.recycle() prefAttrs.recycle()
@ -80,9 +81,7 @@ class IntListPreference @JvmOverloads constructor(
return -1 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) { fun setValueIndex(index: Int) {
setValue(values[index]) setValue(values[index])
} }

View file

@ -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 package org.oxycblt.auxio.settings.pref
import android.content.Context import android.content.Context
@ -11,10 +28,12 @@ import org.oxycblt.auxio.util.getColorStateListSafe
import org.oxycblt.auxio.util.getDrawableSafe import org.oxycblt.auxio.util.getDrawableSafe
/** /**
* A [SwitchPreferenceCompat] that emulates the M3 switches until the design team * A [SwitchPreferenceCompat] that emulates the M3 switches until the design team actually bothers
* actually bothers to add them to MDC. * to add them to MDC.
*/ */
class M3SwitchPreference @JvmOverloads constructor( class M3SwitchPreference
@JvmOverloads
constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.switchPreferenceCompatStyle, defStyleAttr: Int = R.attr.switchPreferenceCompatStyle,

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ActionMenu.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 activity [AppCompatActivity] required as both a context and ViewModelStore owner.
* @param anchor [View] This should be centered around * @param anchor [View] This should be centered around
* @param data [Item] this menu corresponds to * @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 * @throws IllegalStateException When there is no menu for this specific datatype/flag
* @author OxygenCobalt * @author OxygenCobalt TODO: Stop scrolling when a menu is open TODO: Prevent duplicate menus from
* TODO: Stop scrolling when a menu is open * showing up TODO: Maybe replace this with a bottom sheet?
* TODO: Prevent duplicate menus from showing up
* TODO: Maybe replace this with a bottom sheet?
*/ */
class ActionMenu( class ActionMenu(
activity: AppCompatActivity, 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 @MenuRes
private fun determineMenu(): Int { private fun determineMenu(): Int {
return when (data) { return when (data) {
@ -109,31 +105,23 @@ class ActionMenu(
FLAG_NONE, FLAG_IN_GENRE -> R.menu.menu_song_actions FLAG_NONE, FLAG_IN_GENRE -> R.menu.menu_song_actions
FLAG_IN_ALBUM -> R.menu.menu_album_song_actions FLAG_IN_ALBUM -> R.menu.menu_album_song_actions
FLAG_IN_ARTIST -> R.menu.menu_artist_song_actions FLAG_IN_ARTIST -> R.menu.menu_artist_song_actions
else -> -1 else -> -1
} }
} }
is Album -> { is Album -> {
when (flag) { when (flag) {
FLAG_NONE -> R.menu.menu_album_actions FLAG_NONE -> R.menu.menu_album_actions
FLAG_IN_ARTIST -> R.menu.menu_artist_album_actions FLAG_IN_ARTIST -> R.menu.menu_artist_album_actions
else -> -1 else -> -1
} }
} }
is Artist -> R.menu.menu_artist_actions is Artist -> R.menu.menu_artist_actions
is Genre -> R.menu.menu_genre_actions is Genre -> R.menu.menu_genre_actions
else -> -1 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) { private fun onMenuClick(@IdRes id: Int) {
when (id) { when (id) {
R.id.action_play -> { R.id.action_play -> {
@ -141,59 +129,48 @@ class ActionMenu(
is Album -> playbackModel.playAlbum(data, false) is Album -> playbackModel.playAlbum(data, false)
is Artist -> playbackModel.playArtist(data, false) is Artist -> playbackModel.playArtist(data, false)
is Genre -> playbackModel.playGenre(data, false) is Genre -> playbackModel.playGenre(data, false)
else -> {} else -> {}
} }
} }
R.id.action_shuffle -> { R.id.action_shuffle -> {
when (data) { when (data) {
is Album -> playbackModel.playAlbum(data, true) is Album -> playbackModel.playAlbum(data, true)
is Artist -> playbackModel.playArtist(data, true) is Artist -> playbackModel.playArtist(data, true)
is Genre -> playbackModel.playGenre(data, true) is Genre -> playbackModel.playGenre(data, true)
else -> {} else -> {}
} }
} }
R.id.action_play_next -> { R.id.action_play_next -> {
when (data) { when (data) {
is Song -> { is Song -> {
playbackModel.playNext(data) playbackModel.playNext(data)
context.showToast(R.string.lbl_queue_added) context.showToast(R.string.lbl_queue_added)
} }
is Album -> { is Album -> {
playbackModel.playNext(data) playbackModel.playNext(data)
context.showToast(R.string.lbl_queue_added) context.showToast(R.string.lbl_queue_added)
} }
else -> {} else -> {}
} }
} }
R.id.action_queue_add -> { R.id.action_queue_add -> {
when (data) { when (data) {
is Song -> { is Song -> {
playbackModel.addToQueue(data) playbackModel.addToQueue(data)
context.showToast(R.string.lbl_queue_added) context.showToast(R.string.lbl_queue_added)
} }
is Album -> { is Album -> {
playbackModel.addToQueue(data) playbackModel.addToQueue(data)
context.showToast(R.string.lbl_queue_added) context.showToast(R.string.lbl_queue_added)
} }
else -> {} else -> {}
} }
} }
R.id.action_go_album -> { R.id.action_go_album -> {
if (data is Song) { if (data is Song) {
detailModel.navToItem(data.album) detailModel.navToItem(data.album)
} }
} }
R.id.action_go_artist -> { R.id.action_go_artist -> {
if (data is Song) { if (data is Song) {
detailModel.navToItem(data.album.artist) detailModel.navToItem(data.album.artist)
@ -205,13 +182,22 @@ class ActionMenu(
} }
companion object { companion object {
/** No Flags **/ /** No Flags */
const val FLAG_NONE = -1 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 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 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 const val FLAG_IN_GENRE = 2
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* DiffCallback.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.music.Item
/** /**
* A re-usable diff callback for all [Item] implementations. * A re-usable diff callback for all [Item] implementations. **Use this instead of creating a
* **Use this instead of creating a DiffCallback for each adapter.** * DiffCallback for each adapter.**
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() { class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* DisplayMode.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.R
/** /**
* An enum for determining what items to show in a given list. * An enum for determining what items to show in a given list. Note: **DO NOT RE-ARRANGE THE ENUM**.
* Note: **DO NOT RE-ARRANGE THE ENUM**. The ordinals are used to store library tabs, so doing * The ordinals are used to store library tabs, so doing changing them would also change the meaning
* changing them would also change the meaning of tab instances. * of tab instances.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
enum class DisplayMode { enum class DisplayMode {
@ -32,14 +31,18 @@ enum class DisplayMode {
SHOW_ARTISTS, SHOW_ARTISTS,
SHOW_GENRES; SHOW_GENRES;
val string: Int get() = when (this) { val string: Int
get() =
when (this) {
SHOW_SONGS -> R.string.lbl_songs SHOW_SONGS -> R.string.lbl_songs
SHOW_ALBUMS -> R.string.lbl_albums SHOW_ALBUMS -> R.string.lbl_albums
SHOW_ARTISTS -> R.string.lbl_artists SHOW_ARTISTS -> R.string.lbl_artists
SHOW_GENRES -> R.string.lbl_genres 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_SONGS -> R.drawable.ic_song
SHOW_ALBUMS -> R.drawable.ic_album SHOW_ALBUMS -> R.drawable.ic_album
SHOW_ARTISTS -> R.drawable.ic_artist SHOW_ARTISTS -> R.drawable.ic_artist
@ -54,8 +57,8 @@ enum class DisplayMode {
private const val INT_SHOW_SONGS = 0xA10B private const val INT_SHOW_SONGS = 0xA10B
/** /**
* Convert this enum into an integer for filtering. * Convert this enum into an integer for filtering. In this context, a null value means to
* In this context, a null value means to filter nothing. * filter nothing.
* @return An integer constant for that display mode, or a constant for a null [DisplayMode] * @return An integer constant for that display mode, or a constant for a null [DisplayMode]
*/ */
fun toFilterInt(value: DisplayMode?): Int { fun toFilterInt(value: DisplayMode?): Int {
@ -69,8 +72,8 @@ enum class DisplayMode {
} }
/** /**
* Convert a filtering integer to a [DisplayMode]. * Convert a filtering integer to a [DisplayMode]. In this context, a null value means to
* In this context, a null value means to filter nothing. * filter nothing.
* @return A [DisplayMode] for this constant (including null) * @return A [DisplayMode] for this constant (including null)
*/ */
fun fromFilterInt(value: Int): DisplayMode? { fun fromFilterInt(value: Int): DisplayMode? {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* LiftAppBarLayout.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* An [AppBarLayout] that fixes a bug with the default implementation where the lifted state * An [AppBarLayout] that fixes a bug with the default implementation where the lifted state will
* will not properly respond to RecyclerView events. * not properly respond to RecyclerView events. **Note:** This layout relies on
* **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what * [AppBarLayout.liftOnScrollTargetViewId] to figure out what scrolling view to use. Failure to
* scrolling view to use. Failure to specify this will result in the layout not working. * specify this will result in the layout not working.
*/ */
open class EdgeAppBarLayout @JvmOverloads constructor( open class EdgeAppBarLayout
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@AttrRes defStyleAttr: Int = 0 AppBarLayout(context, attrs, defStyleAttr) {
) : AppBarLayout(context, attrs, defStyleAttr) {
private var scrollingChild: View? = null private var scrollingChild: View? = null
private val tConsumed = IntArray(2) private val tConsumed = IntArray(2)
private val onPreDraw = ViewTreeObserver.OnPreDrawListener { private val onPreDraw =
ViewTreeObserver.OnPreDrawListener {
val child = findScrollingChild() val child = findScrollingChild()
if (child != null) { if (child != null) {
val coordinator = parent as CoordinatorLayout val coordinator = parent as CoordinatorLayout
(layoutParams as CoordinatorLayout.LayoutParams).behavior?.onNestedPreScroll( (layoutParams as CoordinatorLayout.LayoutParams).behavior?.onNestedPreScroll(
coordinator, this, coordinator, 0, 0, tConsumed, 0 coordinator, this, coordinator, 0, 0, tConsumed, 0)
)
} }
true true

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* FuckedCoordinatorLayout.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import androidx.core.view.children
/** /**
* Class that fixes an issue where [CoordinatorLayout] will override [onApplyWindowInsets] * Class that fixes an issue where [CoordinatorLayout] will override [onApplyWindowInsets] and
* and delegate the job to ***LAYOUT BEHAVIOR INSTANCES*** instead of the actual views. * delegate the job to ***LAYOUT BEHAVIOR INSTANCES*** instead of the actual views.
* *
* I can't believe I have to do this. * I can't believe I have to do this.
*/ */
class EdgeCoordinatorLayout @JvmOverloads constructor( class EdgeCoordinatorLayout
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@AttrRes defStyleAttr: Int = 0 CoordinatorLayout(context, attrs, defStyleAttr) {
) : CoordinatorLayout(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
for (child in children) { for (child in children) {
child.onApplyWindowInsets(insets) child.onApplyWindowInsets(insets)

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* EdgeRecyclerView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /** A [RecyclerView] that automatically applies insets to itself. */
* A [RecyclerView] that automatically applies insets to itself. class EdgeRecyclerView
*/ @JvmOverloads
class EdgeRecyclerView @JvmOverloads constructor( constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
context: Context, RecyclerView(context, attrs, defStyleAttr) {
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarInsetsCompat.bottom) updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets return insets

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* LifecycleDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import com.google.android.material.dialog.MaterialAlertDialogBuilder
/** /**
* A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle * A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle override
* override [onCreateView] and [onDestroyView], but with a proper dialog being created. * [onCreateView] and [onDestroyView], but with a proper dialog being created.
*/ */
abstract class LifecycleDialog : AppCompatDialogFragment() { abstract class LifecycleDialog : AppCompatDialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Sort.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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] */ /** Sort by the year of an item, only supported by [Album] and [Song] */
class ByYear(override val isAscending: Boolean) : Sort(isAscending) class ByYear(override val isAscending: Boolean) : Sort(isAscending)
/** /** Get the corresponding item id for this sort. */
* Get the corresponding item id for this sort. val itemId: Int
*/ get() =
val itemId: Int get() = when (this) { when (this) {
is ByName -> R.id.option_sort_name is ByName -> R.id.option_sort_name
is ByArtist -> R.id.option_sort_artist is ByArtist -> R.id.option_sort_artist
is ByAlbum -> R.id.option_sort_album 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> { fun sortSongs(songs: Collection<Song>): List<Song> {
return when (this) { return when (this) {
is ByName -> songs.stringSort { it.name } is ByName -> songs.stringSort { it.name }
else ->
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
album.songs.intSort(true) { it.track ?: 0 } 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> { fun sortAlbums(albums: Collection<Album>): List<Album> {
return when (this) { return when (this) {
is ByName, is ByAlbum -> albums.stringSort { it.resolvedName } is ByName, is ByAlbum -> albums.stringSort { it.resolvedName }
is ByArtist ->
is ByArtist -> sortParents(albums.groupBy { it.artist }.keys) sortParents(albums.groupBy { it.artist }.keys).flatMap {
.flatMap { ByYear(false).sortAlbums(it.albums) } ByYear(false).sortAlbums(it.albums)
}
is ByYear -> albums.intSort { it.year ?: 0 } is ByYear -> albums.intSort { it.year ?: 0 }
} }
} }
@ -158,9 +157,7 @@ sealed class Sort(open val isAscending: Boolean) {
return sortSongs(genre.songs) return sortSongs(genre.songs)
} }
/** /** Convert this sort to it's integer representation. */
* Convert this sort to it's integer representation.
*/
fun toInt(): Int { fun toInt(): Int {
return when (this) { return when (this) {
is ByName -> INT_NAME is ByName -> INT_NAME
@ -175,11 +172,10 @@ sealed class Sort(open val isAscending: Boolean) {
selector: (T) -> String selector: (T) -> String
): List<T> { ): List<T> {
// Chain whatever item call with sliceArticle for correctness // Chain whatever item call with sliceArticle for correctness
val chained: (T) -> String = { val chained: (T) -> String = { selector(it).sliceArticle() }
selector(it).sliceArticle()
}
val comparator = if (asc) { val comparator =
if (asc) {
compareBy(String.CASE_INSENSITIVE_ORDER, chained) compareBy(String.CASE_INSENSITIVE_ORDER, chained)
} else { } else {
compareByDescending(String.CASE_INSENSITIVE_ORDER, chained) compareByDescending(String.CASE_INSENSITIVE_ORDER, chained)
@ -192,7 +188,8 @@ sealed class Sort(open val isAscending: Boolean) {
asc: Boolean = isAscending, asc: Boolean = isAscending,
selector: (T) -> Int, selector: (T) -> Int,
): List<T> { ): List<T> {
val comparator = if (asc) { val comparator =
if (asc) {
compareBy(selector) compareBy(selector)
} else { } else {
compareByDescending(selector) 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. * Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
* This is hilariously anglo-centric, but its mostly for MediaStore compat and hopefully * anglo-centric, but its mostly for MediaStore compat and hopefully shouldn't run with other
* shouldn't run with other languages. * languages.
*/ */
fun String.sliceArticle(): String { fun String.sliceArticle(): String {
if (length > 5 && startsWith("the ", true)) { if (length > 5 && startsWith("the ", true)) {

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SortHeaderViewHolder.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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) { ) : RecyclerView.ViewHolder(binding.root) {
init { init {
// Force the layout to *actually* be the screen width // Force the layout to *actually* be the screen width
binding.root.layoutParams = RecyclerView.LayoutParams( binding.root.layoutParams =
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT RecyclerView.LayoutParams(
) RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
} }
/** /**
* Bind the viewholder with whatever [Item] instance that has been specified. * Bind the viewholder with whatever [Item] instance that has been specified. Will call [onBind]
* Will call [onBind] on the inheriting ViewHolder. * on the inheriting ViewHolder.
* @param data Data that the viewholder should be bound with * @param data Data that the viewholder should be bound with
*/ */
fun bind(data: T) { fun bind(data: T) {
doOnClick?.let { onClick -> doOnClick?.let { onClick -> binding.root.setOnClickListener { onClick(data) } }
binding.root.setOnClickListener {
onClick(data)
}
}
doOnLongClick?.let { onLongClick -> doOnLongClick?.let { onLongClick ->
binding.root.setOnLongClickListener { view -> binding.root.setOnLongClickListener { view ->
@ -84,16 +79,15 @@ abstract class BaseViewHolder<T : Item>(
} }
/** /**
* Function that performs binding operations unique to the inheriting viewholder. * Function that performs binding operations unique to the inheriting viewholder. Add any
* Add any specialized code to an override of this instead of [BaseViewHolder] itself. * specialized code to an override of this instead of [BaseViewHolder] itself.
*/ */
protected abstract fun onBind(data: T) protected abstract fun onBind(data: T)
} }
/** /** The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. */
* The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. class SongViewHolder
*/ private constructor(
class SongViewHolder private constructor(
private val binding: ItemSongBinding, private val binding: ItemSongBinding,
doOnClick: (data: Song) -> Unit, doOnClick: (data: Song) -> Unit,
doOnLongClick: (view: View, data: Song) -> Unit doOnLongClick: (view: View, data: Song) -> Unit
@ -109,26 +103,21 @@ class SongViewHolder private constructor(
companion object { companion object {
const val ITEM_TYPE = 0xA000 const val ITEM_TYPE = 0xA000
/** /** Create an instance of [SongViewHolder] */
* Create an instance of [SongViewHolder]
*/
fun from( fun from(
context: Context, context: Context,
doOnClick: (data: Song) -> Unit, doOnClick: (data: Song) -> Unit,
doOnLongClick: (view: View, data: Song) -> Unit doOnLongClick: (view: View, data: Song) -> Unit
): SongViewHolder { ): SongViewHolder {
return SongViewHolder( return SongViewHolder(
ItemSongBinding.inflate(context.inflater), ItemSongBinding.inflate(context.inflater), doOnClick, doOnLongClick)
doOnClick, doOnLongClick
)
} }
} }
} }
/** /** The Shared ViewHolder for a [Album]. Instantiation should be done with [from]. */
* The Shared ViewHolder for a [Album]. Instantiation should be done with [from]. class AlbumViewHolder
*/ private constructor(
class AlbumViewHolder private constructor(
private val binding: ItemAlbumBinding, private val binding: ItemAlbumBinding,
doOnClick: (data: Album) -> Unit, doOnClick: (data: Album) -> Unit,
doOnLongClick: (view: View, data: Album) -> Unit doOnLongClick: (view: View, data: Album) -> Unit
@ -142,26 +131,21 @@ class AlbumViewHolder private constructor(
companion object { companion object {
const val ITEM_TYPE = 0xA001 const val ITEM_TYPE = 0xA001
/** /** Create an instance of [AlbumViewHolder] */
* Create an instance of [AlbumViewHolder]
*/
fun from( fun from(
context: Context, context: Context,
doOnClick: (data: Album) -> Unit, doOnClick: (data: Album) -> Unit,
doOnLongClick: (view: View, data: Album) -> Unit doOnLongClick: (view: View, data: Album) -> Unit
): AlbumViewHolder { ): AlbumViewHolder {
return AlbumViewHolder( return AlbumViewHolder(
ItemAlbumBinding.inflate(context.inflater), ItemAlbumBinding.inflate(context.inflater), doOnClick, doOnLongClick)
doOnClick, doOnLongClick
)
} }
} }
} }
/** /** The Shared ViewHolder for a [Artist]. Instantiation should be done with [from]. */
* The Shared ViewHolder for a [Artist]. Instantiation should be done with [from]. class ArtistViewHolder
*/ private constructor(
class ArtistViewHolder private constructor(
private val binding: ItemArtistBinding, private val binding: ItemArtistBinding,
doOnClick: (Artist) -> Unit, doOnClick: (Artist) -> Unit,
doOnLongClick: (view: View, data: Artist) -> Unit doOnLongClick: (view: View, data: Artist) -> Unit
@ -175,26 +159,21 @@ class ArtistViewHolder private constructor(
companion object { companion object {
const val ITEM_TYPE = 0xA002 const val ITEM_TYPE = 0xA002
/** /** Create an instance of [ArtistViewHolder] */
* Create an instance of [ArtistViewHolder]
*/
fun from( fun from(
context: Context, context: Context,
doOnClick: (Artist) -> Unit, doOnClick: (Artist) -> Unit,
doOnLongClick: (view: View, data: Artist) -> Unit doOnLongClick: (view: View, data: Artist) -> Unit
): ArtistViewHolder { ): ArtistViewHolder {
return ArtistViewHolder( return ArtistViewHolder(
ItemArtistBinding.inflate(context.inflater), ItemArtistBinding.inflate(context.inflater), doOnClick, doOnLongClick)
doOnClick, doOnLongClick
)
} }
} }
} }
/** /** The Shared ViewHolder for a [Genre]. Instantiation should be done with [from]. */
* The Shared ViewHolder for a [Genre]. Instantiation should be done with [from]. class GenreViewHolder
*/ private constructor(
class GenreViewHolder private constructor(
private val binding: ItemGenreBinding, private val binding: ItemGenreBinding,
doOnClick: (Genre) -> Unit, doOnClick: (Genre) -> Unit,
doOnLongClick: (view: View, data: Genre) -> Unit doOnLongClick: (view: View, data: Genre) -> Unit
@ -208,28 +187,21 @@ class GenreViewHolder private constructor(
companion object { companion object {
const val ITEM_TYPE = 0xA003 const val ITEM_TYPE = 0xA003
/** /** Create an instance of [GenreViewHolder] */
* Create an instance of [GenreViewHolder]
*/
fun from( fun from(
context: Context, context: Context,
doOnClick: (Genre) -> Unit, doOnClick: (Genre) -> Unit,
doOnLongClick: (view: View, data: Genre) -> Unit doOnLongClick: (view: View, data: Genre) -> Unit
): GenreViewHolder { ): GenreViewHolder {
return GenreViewHolder( return GenreViewHolder(
ItemGenreBinding.inflate(context.inflater), ItemGenreBinding.inflate(context.inflater), doOnClick, doOnLongClick)
doOnClick, doOnLongClick
)
} }
} }
} }
/** /** The Shared ViewHolder for a [Header]. Instantiation should be done with [from] */
* The Shared ViewHolder for a [Header]. Instantiation should be done with [from] class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
*/ BaseViewHolder<Header>(binding) {
class HeaderViewHolder private constructor(
private val binding: ItemHeaderBinding
) : BaseViewHolder<Header>(binding) {
override fun onBind(data: Header) { override fun onBind(data: Header) {
binding.header = data binding.header = data
@ -238,23 +210,16 @@ class HeaderViewHolder private constructor(
companion object { companion object {
const val ITEM_TYPE = 0xA004 const val ITEM_TYPE = 0xA004
/** /** Create an instance of [HeaderViewHolder] */
* Create an instance of [HeaderViewHolder]
*/
fun from(context: Context): HeaderViewHolder { fun from(context: Context): HeaderViewHolder {
return HeaderViewHolder( return HeaderViewHolder(ItemHeaderBinding.inflate(context.inflater))
ItemHeaderBinding.inflate(context.inflater)
)
} }
} }
} }
/** /** The Shared ViewHolder for an [ActionHeader]. Instantiation should be done with [from] */
* The Shared ViewHolder for an [ActionHeader]. Instantiation should be done with [from] class ActionHeaderViewHolder private constructor(private val binding: ItemActionHeaderBinding) :
*/ BaseViewHolder<ActionHeader>(binding) {
class ActionHeaderViewHolder private constructor(
private val binding: ItemActionHeaderBinding
) : BaseViewHolder<ActionHeader>(binding) {
override fun onBind(data: ActionHeader) { override fun onBind(data: ActionHeader) {
binding.header = data binding.header = data
@ -271,9 +236,7 @@ class ActionHeaderViewHolder private constructor(
companion object { companion object {
const val ITEM_TYPE = 0xA005 const val ITEM_TYPE = 0xA005
/** /** Create an instance of [ActionHeaderViewHolder] */
* Create an instance of [ActionHeaderViewHolder]
*/
fun from(context: Context): ActionHeaderViewHolder { fun from(context: Context): ActionHeaderViewHolder {
return ActionHeaderViewHolder(ItemActionHeaderBinding.inflate(context.inflater)) return ActionHeaderViewHolder(ItemActionHeaderBinding.inflate(context.inflater))
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ContextUtil.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.Px
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.oxycblt.auxio.MainActivity
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.system.exitProcess import kotlin.system.exitProcess
import org.oxycblt.auxio.MainActivity
const val INTENT_REQUEST_CODE = 0xA0A0 const val INTENT_REQUEST_CODE = 0xA0A0
/** /** Shortcut to get a [LayoutInflater] from a [Context] */
* Shortcut to get a [LayoutInflater] from a [Context] val Context.inflater: LayoutInflater
*/ get() = LayoutInflater.from(this)
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 * Returns whether the current UI is in night mode or not. This will work if the theme is automatic
* automatic as well. * as well.
*/ */
val Context.isNight: Boolean get() = val Context.isNight: Boolean
get() =
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
/** /** Returns if this device is in landscape. */
* Returns if this device is in landscape. val Context.isLandscape
*/ get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val Context.isLandscape get() =
resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
/** /**
* Convenience method for getting a plural. * Convenience method for getting a plural.
@ -117,7 +114,8 @@ fun Context.getAttrColorSafe(@AttrRes attr: Int): Int {
theme.resolveAttribute(attr, resolvedAttr, true) theme.resolveAttribute(attr, resolvedAttr, true)
// Then convert it to a proper color // Then convert it to a proper color
val color = if (resolvedAttr.resourceId != 0) { val color =
if (resolvedAttr.resourceId != 0) {
resolvedAttr.resourceId resolvedAttr.resourceId
} else { } else {
resolvedAttr.data resolvedAttr.data
@ -183,9 +181,8 @@ fun Context.getDimenOffsetSafe(@DimenRes dimen: Int): Int {
@Px @Px
fun Context.pxOfDp(@Dimension dp: Float): Int { fun Context.pxOfDp(@Dimension dp: Float): Int {
return TypedValue.applyDimension( return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics .toInt()
).toInt()
} }
private fun <T> Context.handleResourceFailure(e: Exception, what: String, default: T): T { 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) { fun Context.showToast(@StringRes str: Int) {
Toast.makeText(applicationContext, getString(str), Toast.LENGTH_SHORT).show() 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 { fun Context.newMainIntent(): PendingIntent {
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, INTENT_REQUEST_CODE, Intent(this, MainActivity::class.java), this,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) INTENT_REQUEST_CODE,
PendingIntent.FLAG_IMMUTABLE Intent(this, MainActivity::class.java),
else 0 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 { fun Context.newBroadcastIntent(what: String): PendingIntent {
return PendingIntent.getBroadcast( return PendingIntent.getBroadcast(
this, INTENT_REQUEST_CODE, Intent(what), this,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) INTENT_REQUEST_CODE,
PendingIntent.FLAG_IMMUTABLE Intent(what),
else 0 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() { fun Context.hardRestart() {
// Instead of having to do a ton of cleanup and horrible code changes // 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 // 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. // 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) .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent) startActivity(intent)
exitProcess(0) exitProcess(0)

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* DbUtil.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import android.os.Looper
/** /**
* Shortcut for querying all items in a database and running [block] with the cursor returned. * Shortcut for querying all items in a database and running [block] with the cursor returned. Will
* Will not run if the cursor is null. * not run if the cursor is null.
*/ */
fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) = fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
query(tableName, null, null, null, null, null, null)?.use(block) 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() { fun assertBackgroundThread() {
check(Looper.myLooper() != Looper.getMainLooper()) { check(Looper.myLooper() != Looper.getMainLooper()) {
"This operation must be ran on a background thread" "This operation must be ran on a background thread"

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* LogUtil.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. // 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) { fun Any.logD(obj: Any) {
logD(obj.toString()) 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) { fun Any.logD(msg: String) {
if (BuildConfig.DEBUG) { 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) { fun Any.logW(msg: String) {
Log.w(getName(), msg) 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) { fun Any.logE(msg: String) {
Log.e(getName(), msg) 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. * 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 * 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 * 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 * penny on every AdMob impression you get? You could do so many great things if you simply had the
* the courage to come up with an idea of your own. If you still want to go on, I guess the only * courage to come up with an idea of your own. If you still want to go on, I guess the only thing I
* thing I can say is this: JUNE 1989 TIANAMEN SQUARE PROTESTS AND MASSACRE 六四事件 * can say is this: JUNE 1989 TIANAMEN SQUARE PROTESTS AND MASSACRE 六四事件
*/ */
private fun basedCopyleftNotice() { private fun basedCopyleftNotice() {
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" && if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug" BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
) {
Log.d( Log.d(
"Auxio Project", "Auxio Project",
"Friendly reminder: Auxio is licensed under the " + "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!")
)
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ViewUtil.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
/** /** Converts this color to a single-color [ColorStateList]. */
* Converts this color to a single-color [ColorStateList]. val @receiver:ColorRes Int.stateList
*/ get() = ColorStateList.valueOf(this)
val @receiver:ColorRes Int.stateList get() = ColorStateList.valueOf(this)
/** /**
* Apply the recommended spans for a [RecyclerView]. * Apply the recommended spans for a [RecyclerView].
@ -47,7 +45,8 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
val mgr = GridLayoutManager(context, spans) val mgr = GridLayoutManager(context, spans)
if (shouldBeFullWidth != null) { if (shouldBeFullWidth != null) {
mgr.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { mgr.spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int { override fun getSpanSize(position: Int): Int {
return if (shouldBeFullWidth(position)) spans else 1 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 fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height
/** /**
* Disables drop shadows on a view programmatically in a version-compatible manner. * Disables drop shadows on a view programmatically in a version-compatible manner. This only works
* This only works on Android 9 and above. Below that version, shadows will remain visible. * on Android 9 and above. Below that version, shadows will remain visible.
*/ */
fun View.disableDropShadowCompat() { fun View.disableDropShadowCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 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 * Resolve system bar insets in a version-aware manner. This can be used to apply padding to a view
* a view that properly follows all the frustrating changes that were made between 8-11. * 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 { return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
getInsets(WindowInsets.Type.systemBars()).run { getInsets(WindowInsets.Type.systemBars()).run { Rect(left, top, right, bottom) }
Rect(left, top, right, bottom)
} }
}
else -> { else -> {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
Rect( Rect(
systemWindowInsetLeft, systemWindowInsetLeft,
systemWindowInsetTop, systemWindowInsetTop,
systemWindowInsetRight, systemWindowInsetRight,
systemWindowInsetBottom systemWindowInsetBottom)
) }
} }
} }
}
/** /**
* Replaces the system bar insets in a version-aware manner. This can be used to modify the insets * 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. * 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 { return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
WindowInsets.Builder(this) WindowInsets.Builder(this)
.setInsets( .setInsets(WindowInsets.Type.systemBars(), Insets.of(left, top, right, bottom))
WindowInsets.Type.systemBars(),
Insets.of(left, top, right, bottom)
)
.build() .build()
} }
else -> { else -> {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") replaceSystemWindowInsets(left, top, right, bottom)
replaceSystemWindowInsets(
left, top, right, bottom
)
} }
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Forms.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 import org.oxycblt.auxio.util.newMainIntent
/** /**
* The default widget is displayed whenever there is no music playing. It just shows the * The default widget is displayed whenever there is no music playing. It just shows the message "No
* message "No music playing". * music playing".
*/ */
fun createDefaultWidget(context: Context): RemoteViews { fun createDefaultWidget(context: Context): RemoteViews {
return createViews(context, R.layout.widget_default) 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. * The small widget is for 2x2 widgets and just shows the cover art and playback controls. This is
* This is generally because a Medium widget is too large for this widget size and a text-only * generally because a Medium widget is too large for this widget size and a text-only widget is too
* widget is too small for this widget size. * small for this widget size.
*/ */
fun createSmallWidget(context: Context, state: WidgetState): RemoteViews { fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_small) 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 * The medium widget is for 2x3 widgets and shows the cover art, title/artist, and three controls.
* controls. This is the default widget configuration. * This is the default widget configuration.
*/ */
fun createMediumWidget(context: Context, state: WidgetState): RemoteViews { fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_medium) return createViews(context, R.layout.widget_medium)
@ -66,34 +65,24 @@ fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
.applyBasicControls(context, state) .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 { fun createWideWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_wide) return createViews(context, R.layout.widget_wide)
.applyCover(context, state) .applyCover(context, state)
.applyFullControls(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 { fun createLargeWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_large) return createViews(context, R.layout.widget_large)
.applyMeta(context, state) .applyMeta(context, state)
.applyFullControls(context, state) .applyFullControls(context, state)
} }
private fun createViews( private fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews {
context: Context,
@LayoutRes layout: Int
): RemoteViews {
val views = RemoteViews(context.packageName, layout) val views = RemoteViews(context.packageName, layout)
views.setOnClickPendingIntent( views.setOnClickPendingIntent(android.R.id.background, context.newMainIntent())
android.R.id.background,
context.newMainIntent()
)
return views return views
} }
@ -112,8 +101,7 @@ private fun RemoteViews.applyCover(context: Context, state: WidgetState): Remote
setImageViewBitmap(R.id.widget_cover, state.albumArt) setImageViewBitmap(R.id.widget_cover, state.albumArt)
setContentDescription( setContentDescription(
R.id.widget_cover, 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 { } else {
setImageViewResource(R.id.widget_cover, R.drawable.ic_widget_album) setImageViewResource(R.id.widget_cover, R.drawable.ic_widget_album)
setContentDescription(R.id.widget_cover, context.getString(R.string.desc_no_cover)) 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 { private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState): RemoteViews {
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_play_pause, R.id.widget_play_pause, context.newBroadcastIntent(PlaybackService.ACTION_PLAY_PAUSE))
context.newBroadcastIntent(
PlaybackService.ACTION_PLAY_PAUSE
)
)
setImageViewResource( setImageViewResource(
R.id.widget_play_pause, R.id.widget_play_pause,
@ -136,8 +120,7 @@ private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState):
R.drawable.ic_pause R.drawable.ic_pause
} else { } else {
R.drawable.ic_play R.drawable.ic_play
} })
)
return this return this
} }
@ -146,18 +129,10 @@ private fun RemoteViews.applyBasicControls(context: Context, state: WidgetState)
applyPlayControls(context, state) applyPlayControls(context, state)
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_skip_prev, R.id.widget_skip_prev, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_PREV))
context.newBroadcastIntent(
PlaybackService.ACTION_SKIP_PREV
)
)
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_skip_next, R.id.widget_skip_next, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_NEXT))
context.newBroadcastIntent(
PlaybackService.ACTION_SKIP_NEXT
)
)
return this return this
} }
@ -166,27 +141,21 @@ private fun RemoteViews.applyFullControls(context: Context, state: WidgetState):
applyBasicControls(context, state) applyBasicControls(context, state)
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_loop, R.id.widget_loop, context.newBroadcastIntent(PlaybackService.ACTION_LOOP))
context.newBroadcastIntent(
PlaybackService.ACTION_LOOP
)
)
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_shuffle, R.id.widget_shuffle, context.newBroadcastIntent(PlaybackService.ACTION_SHUFFLE))
context.newBroadcastIntent(
PlaybackService.ACTION_SHUFFLE
)
)
// Like notifications, use the remote variants of icons since we really don't want to hack // Like notifications, use the remote variants of icons since we really don't want to hack
// indicators. // indicators.
val shuffleRes = when { val shuffleRes =
when {
state.isShuffled -> R.drawable.ic_remote_shuffle_on state.isShuffled -> R.drawable.ic_remote_shuffle_on
else -> R.drawable.ic_remote_shuffle_off 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.NONE -> R.drawable.ic_remote_loop_off
LoopMode.ALL -> R.drawable.ic_loop_on LoopMode.ALL -> R.drawable.ic_loop_on
LoopMode.TRACK -> R.drawable.ic_loop_one LoopMode.TRACK -> R.drawable.ic_loop_one

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* WidgetController.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * 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 * 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 * result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound to
* to without being released. * without being released.
*/ */
class WidgetController(private val context: Context) : class WidgetController(private val context: Context) :
PlaybackStateManager.Callback, PlaybackStateManager.Callback, SettingsManager.Callback {
SettingsManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val widget = WidgetProvider() private val widget = WidgetProvider()

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* WidgetProvider.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import kotlin.math.min
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.coil.SquareFrameTransform import org.oxycblt.auxio.coil.SquareFrameTransform
import org.oxycblt.auxio.music.Song 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.isLandscape
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import kotlin.math.min
/** /**
* Auxio's one and only appwidget. This widget follows a more unorthodox approach, effectively * 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 -> loadWidgetBitmap(context, song) { bitmap ->
val state = WidgetState( val state =
WidgetState(
song, song,
bitmap, bitmap,
playbackManager.isPlaying, playbackManager.isPlaying,
playbackManager.isShuffling, playbackManager.isShuffling,
playbackManager.loopMode playbackManager.loopMode)
)
// Map each widget form to the cells where it would look at least okay. // 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, 100f) to createTinyWidget(context, state),
SizeF(180f, 152f) to createSmallWidget(context, state), SizeF(180f, 152f) to createSmallWidget(context, state),
SizeF(272f, 152f) to createWideWidget(context, state), SizeF(272f, 152f) to createWideWidget(context, state),
SizeF(180f, 270f) to createMediumWidget(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) appWidgetManager.applyViewsCompat(context, views)
} }
} }
/** /**
* Custom function for loading bitmaps to the widget in a way that works with the * Custom function for loading bitmaps to the widget in a way that works with the widget
* widget ImageView instances. * ImageView instances.
*/ */
private fun loadWidgetBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) { private fun loadWidgetBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
val coverRequest = ImageRequest.Builder(context) val coverRequest =
ImageRequest.Builder(context)
.data(song.album) .data(song.album)
.target( .target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
)
// The widget has two distinct styles that we must transform the album art to accommodate: // 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 // - 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 // 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 // ratio on widget ImageViews doesn't actually result in a square ImageView, so
// clipToOutline won't work. // clipToOutline won't work.
val transform = RoundedCornersTransformation( val transform =
context.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius) RoundedCornersTransformation(
.toFloat() context
) .getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius)
.toFloat())
// The output of RoundedCornersTransformation is dimension-dependent, so scale up the // The output of RoundedCornersTransformation is dimension-dependent, so scale up the
// image to the screen size to ensure consistent radii. // image to the screen size to ensure consistent radii.
val metrics = context.resources.displayMetrics val metrics = context.resources.displayMetrics
coverRequest.transformations(SquareFrameTransform(), transform) coverRequest
.transformations(SquareFrameTransform(), transform)
.size(min(metrics.widthPixels, metrics.heightPixels)) .size(min(metrics.widthPixels, metrics.heightPixels))
} else { } else {
coverRequest.transformations(SquareFrameTransform()) coverRequest.transformations(SquareFrameTransform())
@ -132,9 +131,8 @@ class WidgetProvider : AppWidgetProvider() {
fun reset(context: Context) { fun reset(context: Context) {
logD("Resetting widget") logD("Resetting widget")
AppWidgetManager.getInstance(context).updateAppWidget( AppWidgetManager.getInstance(context)
ComponentName(context, this::class.java), createDefaultWidget(context) .updateAppWidget(ComponentName(context, this::class.java), createDefaultWidget(context))
)
} }
// --- OVERRIDES --- // --- OVERRIDES ---
@ -170,8 +168,7 @@ class WidgetProvider : AppWidgetProvider() {
private fun requestUpdate(context: Context) { private fun requestUpdate(context: Context) {
logD("Sending update intent to PlaybackService") logD("Sending update intent to PlaybackService")
val intent = Intent(ACTION_WIDGET_UPDATE) val intent = Intent(ACTION_WIDGET_UPDATE).addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
@ -243,9 +240,8 @@ class WidgetProvider : AppWidgetProvider() {
// Default to the smallest view if no layout fits // Default to the smallest view if no layout fits
logW("No good widget layout found") logW("No good widget layout found")
val minimum = requireNotNull( val minimum =
views.minByOrNull { it.key.width * it.key.height }?.value requireNotNull(views.minByOrNull { it.key.width * it.key.height }?.value)
)
updateAppWidget(id, minimum) updateAppWidget(id, minimum)
} }

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* WidgetState.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -12,6 +12,7 @@ buildscript {
classpath 'com.android.tools.build:gradle:7.1.2' classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_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 // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files