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/Meta
- Switched to spotless and ktfmt instead of ktlint
## v2.2.2
#### What's New
- New spanish translations and metadata [courtesy of n-berenice]

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* AuxioApp.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -31,10 +30,12 @@ import org.oxycblt.auxio.settings.SettingsManager
/**
* TODO: Plan for a general UI rework
* ```
* - Refactor fragment class
* - Remove databinding and dedup layouts
* - Rework RecyclerView management and item dragging
* - Rework sealed classes to minimize whens and maximize overrides
* ```
*/
@Suppress("UNUSED")
class AuxioApp : Application(), ImageLoaderFactory {

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* Accent.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,9 +19,11 @@ package org.oxycblt.auxio.accent
import org.oxycblt.auxio.R
val ACCENT_COUNT: Int get() = ACCENT_NAMES.size
val ACCENT_COUNT: Int
get() = ACCENT_NAMES.size
private val ACCENT_NAMES = arrayOf(
private val ACCENT_NAMES =
arrayOf(
R.string.clr_red,
R.string.clr_pink,
R.string.clr_purple,
@ -41,7 +42,8 @@ private val ACCENT_NAMES = arrayOf(
R.string.clr_grey,
)
private val ACCENT_THEMES = arrayOf(
private val ACCENT_THEMES =
arrayOf(
R.style.Theme_Auxio_Red,
R.style.Theme_Auxio_Pink,
R.style.Theme_Auxio_Purple,
@ -60,7 +62,8 @@ private val ACCENT_THEMES = arrayOf(
R.style.Theme_Auxio_Grey,
)
private val ACCENT_BLACK_THEMES = arrayOf(
private val ACCENT_BLACK_THEMES =
arrayOf(
R.style.Theme_Auxio_Black_Red,
R.style.Theme_Auxio_Black_Pink,
R.style.Theme_Auxio_Black_Purple,
@ -79,7 +82,8 @@ private val ACCENT_BLACK_THEMES = arrayOf(
R.style.Theme_Auxio_Black_Grey,
)
private val ACCENT_PRIMARY_COLORS = arrayOf(
private val ACCENT_PRIMARY_COLORS =
arrayOf(
R.color.red_primary,
R.color.pink_primary,
R.color.purple_primary,
@ -99,9 +103,9 @@ private val ACCENT_PRIMARY_COLORS = arrayOf(
)
/**
* The data object for an accent. In the UI this is known as a "Color Scheme."
* This can be nominally used to gleam some attributes about a given color scheme, but this
* is not recommended. Attributes are the better option in nearly all cases.
* The data object for an accent. In the UI this is known as a "Color Scheme." This can be nominally
* used to gleam some attributes about a given color scheme, but this is not recommended. Attributes
* are the better option in nearly all cases.
*
* @property name The name of this accent
* @property theme The theme resource for this accent
@ -110,8 +114,12 @@ private val ACCENT_PRIMARY_COLORS = arrayOf(
* @author OxygenCobalt
*/
data class Accent(val index: Int) {
val name: Int get() = ACCENT_NAMES[index]
val theme: Int get() = ACCENT_THEMES[index]
val blackTheme: Int get() = ACCENT_BLACK_THEMES[index]
val primary: Int get() = ACCENT_PRIMARY_COLORS[index]
val name: Int
get() = ACCENT_NAMES[index]
val theme: Int
get() = ACCENT_THEMES[index]
val blackTheme: Int
get() = ACCENT_BLACK_THEMES[index]
val primary: Int
get() = ACCENT_PRIMARY_COLORS[index]
}

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* AutoGridLayoutManager.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -22,13 +21,13 @@ import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.pxOfDp
import kotlin.math.max
import org.oxycblt.auxio.util.pxOfDp
/**
* A sub-class of [GridLayoutManager] that automatically sets the spans so that they fit the width
* of the RecyclerView.
* Adapted from this StackOverflow answer: https://stackoverflow.com/a/30256880/14143986
* of the RecyclerView. Adapted from this StackOverflow answer:
* https://stackoverflow.com/a/30256880/14143986
*/
class AccentGridLayoutManager(
context: Context,

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* CoilUtils.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -38,27 +37,19 @@ import org.oxycblt.auxio.music.Song
// --- BINDING ADAPTERS ---
/**
* Bind the album art for a [song].
*/
/** Bind the album art for a [song]. */
@BindingAdapter("albumArt")
fun ImageView.bindAlbumArt(song: Song?) = load(song, R.drawable.ic_album)
/**
* Bind the album art for an [album].
*/
/** Bind the album art for an [album]. */
@BindingAdapter("albumArt")
fun ImageView.bindAlbumArt(album: Album?) = load(album, R.drawable.ic_album)
/**
* Bind the image for an [artist]
*/
/** Bind the image for an [artist] */
@BindingAdapter("artistImage")
fun ImageView.bindArtistImage(artist: Artist?) = load(artist, R.drawable.ic_artist)
/**
* Bind the image for a [genre]
*/
/** Bind the image for a [genre] */
@BindingAdapter("genreImage")
fun ImageView.bindGenreImage(genre: Genre?) = load(genre, R.drawable.ic_genre)
@ -74,23 +65,14 @@ fun <T : Music> ImageView.load(music: T?, @DrawableRes error: Int) {
/**
* Get a bitmap for a [song]. [onDone] will be called with the loaded bitmap, or null if loading
* failed/shouldn't occur.
* **This not meant for UIs, instead use the Binding Adapters.**
* failed/shouldn't occur. **This not meant for UIs, instead use the Binding Adapters.**
*/
fun loadBitmap(
context: Context,
song: Song,
onDone: (Bitmap?) -> Unit
) {
fun loadBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(song.album)
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform())
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
)
.build()
)
.target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
.build())
}

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

View file

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

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* EdgeFloatingActionButton.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -30,11 +29,10 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* A container for a FloatingActionButton that enables edge-to-edge support.
* @author OxygenCobalt
*/
class EdgeFabContainer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
class EdgeFabContainer
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
init {
clipToPadding = false
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* HomeListFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -38,9 +37,7 @@ abstract class HomeListFragment : Fragment() {
protected val homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
/**
* The popup provider to use for the fast scroller view.
*/
/** The popup provider to use for the fast scroller view. */
abstract val listPopupProvider: (Int) -> String
protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler(
@ -56,18 +53,15 @@ abstract class HomeListFragment : Fragment() {
applySpans()
popupProvider = listPopupProvider
onDragListener = { dragging ->
homeModel.updateFastScrolling(dragging)
}
onDragListener = { dragging -> homeModel.updateFastScrolling(dragging) }
}
// Make sure that this RecyclerView has data before startup
homeData.observe(viewLifecycleOwner) { data ->
homeAdapter.updateData(data)
}
homeData.observe(viewLifecycleOwner) { data -> homeAdapter.updateData(data) }
}
abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
protected var data = listOf<T>()
@SuppressLint("NotifyDataSetChanged")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* BlacklistEntryAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -28,9 +27,8 @@ import org.oxycblt.auxio.util.inflater
* Adapter that shows the excluded directories and their "Clear" button.
* @author OxygenCobalt
*/
class ExcludedEntryAdapter(
private val onClear: (String) -> Unit
) : RecyclerView.Adapter<ExcludedEntryAdapter.ViewHolder>() {
class ExcludedEntryAdapter(private val onClear: (String) -> Unit) :
RecyclerView.Adapter<ExcludedEntryAdapter.ViewHolder>() {
private var paths = mutableListOf<String>()
override fun getItemCount() = paths.size
@ -49,21 +47,18 @@ class ExcludedEntryAdapter(
notifyDataSetChanged()
}
inner class ViewHolder(
private val binding: ItemExcludedDirBinding
) : RecyclerView.ViewHolder(binding.root) {
inner class ViewHolder(private val binding: ItemExcludedDirBinding) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.root.layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT
)
binding.root.layoutParams =
RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
}
fun bind(path: String) {
binding.excludedPath.text = path
binding.excludedPath.requestLayout()
binding.excludedClear.setOnClickListener {
onClear(path)
}
binding.excludedClear.setOnClickListener { onClear(path) }
}
}
}

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* LoopMode.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,11 +22,11 @@ package org.oxycblt.auxio.playback.state
* @author OxygenCobalt
*/
enum class LoopMode {
NONE, ALL, TRACK;
NONE,
ALL,
TRACK;
/**
* Increment the LoopMode, e.g from [NONE] to [ALL]
*/
/** Increment the LoopMode, e.g from [NONE] to [ALL] */
fun increment(): LoopMode {
return when (this) {
NONE -> ALL
@ -53,15 +52,12 @@ enum class LoopMode {
private const val INT_ALL = 0xA101
private const val INT_TRACK = 0xA102
/**
* Convert an int [constant] into a LoopMode, or null if it isn't valid.
*/
/** Convert an int [constant] into a LoopMode, or null if it isn't valid. */
fun fromInt(constant: Int): LoopMode? {
return when (constant) {
INT_NONE -> NONE
INT_ALL -> ALL
INT_TRACK -> TRACK
else -> null
}
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* SearchAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -58,24 +57,15 @@ class SearchAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
GenreViewHolder.ITEM_TYPE -> GenreViewHolder.from(
parent.context, doOnClick, doOnLongClick
)
ArtistViewHolder.ITEM_TYPE -> ArtistViewHolder.from(
parent.context, doOnClick, doOnLongClick
)
AlbumViewHolder.ITEM_TYPE -> AlbumViewHolder.from(
parent.context, doOnClick, doOnLongClick
)
SongViewHolder.ITEM_TYPE -> SongViewHolder.from(
parent.context, doOnClick, doOnLongClick
)
GenreViewHolder.ITEM_TYPE ->
GenreViewHolder.from(parent.context, doOnClick, doOnLongClick)
ArtistViewHolder.ITEM_TYPE ->
ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick)
AlbumViewHolder.ITEM_TYPE ->
AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick)
SongViewHolder.ITEM_TYPE ->
SongViewHolder.from(parent.context, doOnClick, doOnLongClick)
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type")
}
}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* SettingsCompat.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -53,9 +52,7 @@ fun handleAccentCompat(prefs: SharedPreferences): Accent {
return Accent(prefs.getInt(SettingsManager.KEY_ACCENT, 5))
}
/**
* Cache of the old keys used in Auxio.
*/
/** Cache of the old keys used in Auxio. */
private object OldKeys {
const val KEY_ACCENT2 = "KEY_ACCENT2"
}

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* SettingsFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -39,9 +38,7 @@ class SettingsFragment : Fragment() {
val binding = FragmentSettingsBinding.inflate(inflater)
binding.settingsToolbar.apply {
setNavigationOnClickListener {
findNavController().navigateUp()
}
setNavigationOnClickListener { findNavController().navigateUp() }
}
binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* SettingsListFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -32,8 +31,8 @@ import androidx.recyclerview.widget.RecyclerView
import coil.Coil
import org.oxycblt.auxio.R
import org.oxycblt.auxio.accent.AccentCustomizeDialog
import org.oxycblt.auxio.music.excluded.ExcludedDialog
import org.oxycblt.auxio.home.tabs.TabCustomizeDialog
import org.oxycblt.auxio.music.excluded.ExcludedDialog
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.pref.IntListPrefDialog
import org.oxycblt.auxio.settings.pref.IntListPreference
@ -83,9 +82,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
}
}
/**
* Recursively handle a preference, doing any specific actions on it.
*/
/** Recursively handle a preference, doing any specific actions on it. */
private fun recursivelyHandlePreference(preference: Preference) {
if (!preference.isVisible) return
@ -100,15 +97,16 @@ class SettingsListFragment : PreferenceFragmentCompat() {
SettingsManager.KEY_THEME -> {
setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon())
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, value ->
onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, value ->
AppCompatDelegate.setDefaultNightMode(value as Int)
setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon())
true
}
}
SettingsManager.KEY_BLACK_THEME -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
onPreferenceClickListener =
Preference.OnPreferenceClickListener {
if (requireContext().isNight) {
requireActivity().recreate()
}
@ -116,35 +114,34 @@ class SettingsListFragment : PreferenceFragmentCompat() {
true
}
}
SettingsManager.KEY_ACCENT -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
AccentCustomizeDialog().show(childFragmentManager, AccentCustomizeDialog.TAG)
onPreferenceClickListener =
Preference.OnPreferenceClickListener {
AccentCustomizeDialog()
.show(childFragmentManager, AccentCustomizeDialog.TAG)
true
}
summary = context.getString(settingsManager.accent.name)
}
SettingsManager.KEY_LIB_TABS -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
onPreferenceClickListener =
Preference.OnPreferenceClickListener {
TabCustomizeDialog().show(childFragmentManager, TabCustomizeDialog.TAG)
true
}
}
SettingsManager.KEY_SHOW_COVERS, SettingsManager.KEY_QUALITY_COVERS -> {
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ ->
Coil.imageLoader(requireContext()).apply {
this.memoryCache?.clear()
}
onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
Coil.imageLoader(requireContext()).apply { this.memoryCache?.clear() }
true
}
}
SettingsManager.KEY_SAVE_STATE -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
onPreferenceClickListener =
Preference.OnPreferenceClickListener {
playbackModel.savePlaybackState(requireContext()) {
requireContext().showToast(R.string.lbl_state_saved)
}
@ -152,9 +149,9 @@ class SettingsListFragment : PreferenceFragmentCompat() {
true
}
}
SettingsManager.KEY_RELOAD -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
onPreferenceClickListener =
Preference.OnPreferenceClickListener {
playbackModel.savePlaybackState(requireContext()) {
requireContext().hardRestart()
}
@ -162,9 +159,9 @@ class SettingsListFragment : PreferenceFragmentCompat() {
true
}
}
SettingsManager.KEY_EXCLUDED -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
onPreferenceClickListener =
Preference.OnPreferenceClickListener {
ExcludedDialog().show(childFragmentManager, ExcludedDialog.TAG)
true
}
@ -173,9 +170,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
}
}
/**
* Convert an theme integer into an icon that can be used.
*/
/** Convert an theme integer into an icon that can be used. */
@DrawableRes
private fun Int.toThemeIcon(): Int {
return when (this) {

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* SettingsManager.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -64,15 +63,16 @@ class SettingsManager private constructor(context: Context) :
}
/**
* Whether to display the LoopMode or the shuffle status on the notification.
* False if loop, true if shuffle.
* Whether to display the LoopMode or the shuffle status on the notification. False if loop,
* true if shuffle.
*/
val useAltNotifAction: Boolean
get() = prefs.getBoolean(KEY_USE_ALT_NOTIFICATION_ACTION, false)
/** The current library tabs preferred by the user. */
var libTabs: Array<Tab>
get() = Tab.fromSequence(prefs.getInt(KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
get() =
Tab.fromSequence(prefs.getInt(KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
?: Tab.fromSequence(Tab.SEQUENCE_DEFAULT)!!
set(value) {
prefs.edit {
@ -103,12 +103,14 @@ class SettingsManager private constructor(context: Context) :
/** The current ReplayGain configuration */
val replayGainMode: ReplayGainMode
get() = ReplayGainMode.fromInt(prefs.getInt(KEY_REPLAY_GAIN, Int.MIN_VALUE))
get() =
ReplayGainMode.fromInt(prefs.getInt(KEY_REPLAY_GAIN, Int.MIN_VALUE))
?: ReplayGainMode.OFF
/** What queue to create when a song is selected (ex. From All Songs or Search) */
val songPlaybackMode: PlaybackMode
get() = PlaybackMode.fromInt(prefs.getInt(KEY_SONG_PLAYBACK_MODE, Int.MIN_VALUE))
get() =
PlaybackMode.fromInt(prefs.getInt(KEY_SONG_PLAYBACK_MODE, Int.MIN_VALUE))
?: PlaybackMode.ALL_SONGS
/** Whether shuffle should stay on when a new song is selected. */
@ -119,7 +121,9 @@ class SettingsManager private constructor(context: Context) :
val rewindWithPrev: Boolean
get() = prefs.getBoolean(KEY_PREV_REWIND, true)
/** Whether [org.oxycblt.auxio.playback.state.LoopMode.TRACK] should pause when the track repeats */
/**
* Whether [org.oxycblt.auxio.playback.state.LoopMode.TRACK] should pause when the track repeats
*/
val pauseOnLoop: Boolean
get() = prefs.getBoolean(KEY_LOOP_PAUSE, false)
@ -133,10 +137,9 @@ class SettingsManager private constructor(context: Context) :
}
}
/** The song sort mode on HomeFragment **/
/** The song sort mode on HomeFragment */
var libSongSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) {
prefs.edit {
putInt(KEY_LIB_SONGS_SORT, value.toInt())
@ -144,10 +147,9 @@ class SettingsManager private constructor(context: Context) :
}
}
/** The album sort mode on HomeFragment **/
/** The album sort mode on HomeFragment */
var libAlbumSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) {
prefs.edit {
putInt(KEY_LIB_ALBUMS_SORT, value.toInt())
@ -155,10 +157,9 @@ class SettingsManager private constructor(context: Context) :
}
}
/** The artist sort mode on HomeFragment **/
/** The artist sort mode on HomeFragment */
var libArtistSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) {
prefs.edit {
putInt(KEY_LIB_ARTISTS_SORT, value.toInt())
@ -166,10 +167,9 @@ class SettingsManager private constructor(context: Context) :
}
}
/** The genre sort mode on HomeFragment **/
/** The genre sort mode on HomeFragment */
var libGenreSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) {
prefs.edit {
putInt(KEY_LIB_GENRES_SORT, value.toInt())
@ -177,10 +177,10 @@ class SettingsManager private constructor(context: Context) :
}
}
/** The detail album sort mode **/
/** The detail album sort mode */
var detailAlbumSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
get() =
Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) {
prefs.edit {
putInt(KEY_DETAIL_ALBUM_SORT, value.toInt())
@ -188,10 +188,10 @@ class SettingsManager private constructor(context: Context) :
}
}
/** The detail artist sort mode **/
/** The detail artist sort mode */
var detailArtistSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE))
?: Sort.ByYear(false)
get() =
Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE)) ?: Sort.ByYear(false)
set(value) {
prefs.edit {
putInt(KEY_DETAIL_ARTIST_SORT, value.toInt())
@ -199,10 +199,10 @@ class SettingsManager private constructor(context: Context) :
}
}
/** The detail genre sort mode **/
/** The detail genre sort mode */
var detailGenreSort: Sort
get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
get() =
Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE)) ?: Sort.ByName(true)
set(value) {
prefs.edit {
putInt(KEY_DETAIL_GENRE_SORT, value.toInt())
@ -226,29 +226,13 @@ class SettingsManager private constructor(context: Context) :
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
KEY_USE_ALT_NOTIFICATION_ACTION -> callbacks.forEach {
it.onNotifActionUpdate(useAltNotifAction)
}
KEY_SHOW_COVERS -> callbacks.forEach {
it.onShowCoverUpdate(showCovers)
}
KEY_QUALITY_COVERS -> callbacks.forEach {
it.onQualityCoverUpdate(useQualityCovers)
}
KEY_LIB_TABS -> callbacks.forEach {
it.onLibTabsUpdate(libTabs)
}
KEY_AUDIO_FOCUS -> callbacks.forEach {
it.onAudioFocusUpdate(doAudioFocus)
}
KEY_REPLAY_GAIN -> callbacks.forEach {
it.onReplayGainUpdate(replayGainMode)
}
KEY_USE_ALT_NOTIFICATION_ACTION ->
callbacks.forEach { it.onNotifActionUpdate(useAltNotifAction) }
KEY_SHOW_COVERS -> callbacks.forEach { it.onShowCoverUpdate(showCovers) }
KEY_QUALITY_COVERS -> callbacks.forEach { it.onQualityCoverUpdate(useQualityCovers) }
KEY_LIB_TABS -> callbacks.forEach { it.onLibTabsUpdate(libTabs) }
KEY_AUDIO_FOCUS -> callbacks.forEach { it.onAudioFocusUpdate(doAudioFocus) }
KEY_REPLAY_GAIN -> callbacks.forEach { it.onReplayGainUpdate(replayGainMode) }
}
}
@ -304,26 +288,21 @@ class SettingsManager private constructor(context: Context) :
const val KEY_DETAIL_ARTIST_SORT = "auxio_artist_sort"
const val KEY_DETAIL_GENRE_SORT = "auxio_genre_sort"
@Volatile
private var INSTANCE: SettingsManager? = null
@Volatile private var INSTANCE: SettingsManager? = null
/**
* Init the single instance of [SettingsManager]. Done so that every object
* can have access to it regardless of if it has a context.
* Init the single instance of [SettingsManager]. Done so that every object can have access
* to it regardless of if it has a context.
*/
fun init(context: Context): SettingsManager {
if (INSTANCE == null) {
synchronized(this) {
INSTANCE = SettingsManager(context)
}
synchronized(this) { INSTANCE = SettingsManager(context) }
}
return getInstance()
}
/**
* Get the single instance of [SettingsManager].
*/
/** Get the single instance of [SettingsManager]. */
fun getInstance(): SettingsManager {
val instance = INSTANCE

View file

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

View file

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

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* ActionMenu.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -52,12 +51,11 @@ fun Fragment.newMenu(anchor: View, data: Item, flag: Int = ActionMenu.FLAG_NONE)
* @param activity [AppCompatActivity] required as both a context and ViewModelStore owner.
* @param anchor [View] This should be centered around
* @param data [Item] this menu corresponds to
* @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM], [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
* @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM],
* [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
* @throws IllegalStateException When there is no menu for this specific datatype/flag
* @author OxygenCobalt
* TODO: Stop scrolling when a menu is open
* TODO: Prevent duplicate menus from showing up
* TODO: Maybe replace this with a bottom sheet?
* @author OxygenCobalt TODO: Stop scrolling when a menu is open TODO: Prevent duplicate menus from
* showing up TODO: Maybe replace this with a bottom sheet?
*/
class ActionMenu(
activity: AppCompatActivity,
@ -98,9 +96,7 @@ class ActionMenu(
}
}
/**
* Figure out what menu to use here, based on the data & flags
*/
/** Figure out what menu to use here, based on the data & flags */
@MenuRes
private fun determineMenu(): Int {
return when (data) {
@ -109,31 +105,23 @@ class ActionMenu(
FLAG_NONE, FLAG_IN_GENRE -> R.menu.menu_song_actions
FLAG_IN_ALBUM -> R.menu.menu_album_song_actions
FLAG_IN_ARTIST -> R.menu.menu_artist_song_actions
else -> -1
}
}
is Album -> {
when (flag) {
FLAG_NONE -> R.menu.menu_album_actions
FLAG_IN_ARTIST -> R.menu.menu_artist_album_actions
else -> -1
}
}
is Artist -> R.menu.menu_artist_actions
is Genre -> R.menu.menu_genre_actions
else -> -1
}
}
/**
* Determine what to do when a MenuItem is clicked.
*/
/** Determine what to do when a MenuItem is clicked. */
private fun onMenuClick(@IdRes id: Int) {
when (id) {
R.id.action_play -> {
@ -141,59 +129,48 @@ class ActionMenu(
is Album -> playbackModel.playAlbum(data, false)
is Artist -> playbackModel.playArtist(data, false)
is Genre -> playbackModel.playGenre(data, false)
else -> {}
}
}
R.id.action_shuffle -> {
when (data) {
is Album -> playbackModel.playAlbum(data, true)
is Artist -> playbackModel.playArtist(data, true)
is Genre -> playbackModel.playGenre(data, true)
else -> {}
}
}
R.id.action_play_next -> {
when (data) {
is Song -> {
playbackModel.playNext(data)
context.showToast(R.string.lbl_queue_added)
}
is Album -> {
playbackModel.playNext(data)
context.showToast(R.string.lbl_queue_added)
}
else -> {}
}
}
R.id.action_queue_add -> {
when (data) {
is Song -> {
playbackModel.addToQueue(data)
context.showToast(R.string.lbl_queue_added)
}
is Album -> {
playbackModel.addToQueue(data)
context.showToast(R.string.lbl_queue_added)
}
else -> {}
}
}
R.id.action_go_album -> {
if (data is Song) {
detailModel.navToItem(data.album)
}
}
R.id.action_go_artist -> {
if (data is Song) {
detailModel.navToItem(data.album.artist)
@ -205,13 +182,22 @@ class ActionMenu(
}
companion object {
/** No Flags **/
/** No Flags */
const val FLAG_NONE = -1
/** Flag for when a menu is opened from an artist (See [org.oxycblt.auxio.detail.ArtistDetailFragment]) **/
/**
* Flag for when a menu is opened from an artist (See
* [org.oxycblt.auxio.detail.ArtistDetailFragment])
*/
const val FLAG_IN_ARTIST = 0
/** Flag for when a menu is opened from an album (See [org.oxycblt.auxio.detail.AlbumDetailFragment]) **/
/**
* Flag for when a menu is opened from an album (See
* [org.oxycblt.auxio.detail.AlbumDetailFragment])
*/
const val FLAG_IN_ALBUM = 1
/** Flag for when a menu is opened from a genre (See [org.oxycblt.auxio.detail.GenreDetailFragment]) **/
/**
* Flag for when a menu is opened from a genre (See
* [org.oxycblt.auxio.detail.GenreDetailFragment])
*/
const val FLAG_IN_GENRE = 2
}
}

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* DiffCallback.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -22,8 +21,8 @@ import androidx.recyclerview.widget.DiffUtil
import org.oxycblt.auxio.music.Item
/**
* A re-usable diff callback for all [Item] implementations.
* **Use this instead of creating a DiffCallback for each adapter.**
* A re-usable diff callback for all [Item] implementations. **Use this instead of creating a
* DiffCallback for each adapter.**
* @author OxygenCobalt
*/
class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* EdgeRecyclerView.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -26,14 +25,11 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [RecyclerView] that automatically applies insets to itself.
*/
class EdgeRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
/** A [RecyclerView] that automatically applies insets to itself. */
class EdgeRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
RecyclerView(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* LifecycleDialog.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -27,8 +26,8 @@ import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
* A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle
* override [onCreateView] and [onDestroyView], but with a proper dialog being created.
* A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle override
* [onCreateView] and [onDestroyView], but with a proper dialog being created.
*/
abstract class LifecycleDialog : AppCompatDialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* ContextUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -39,30 +38,28 @@ import androidx.annotation.PluralsRes
import androidx.annotation.Px
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import org.oxycblt.auxio.MainActivity
import kotlin.reflect.KClass
import kotlin.system.exitProcess
import org.oxycblt.auxio.MainActivity
const val INTENT_REQUEST_CODE = 0xA0A0
/**
* Shortcut to get a [LayoutInflater] from a [Context]
*/
val Context.inflater: LayoutInflater get() = LayoutInflater.from(this)
/** Shortcut to get a [LayoutInflater] from a [Context] */
val Context.inflater: LayoutInflater
get() = LayoutInflater.from(this)
/**
* Returns whether the current UI is in night mode or not. This will work if the theme is
* automatic as well.
* Returns whether the current UI is in night mode or not. This will work if the theme is automatic
* as well.
*/
val Context.isNight: Boolean get() =
val Context.isNight: Boolean
get() =
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
/**
* Returns if this device is in landscape.
*/
val Context.isLandscape get() =
resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
/** Returns if this device is in landscape. */
val Context.isLandscape
get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
/**
* Convenience method for getting a plural.
@ -117,7 +114,8 @@ fun Context.getAttrColorSafe(@AttrRes attr: Int): Int {
theme.resolveAttribute(attr, resolvedAttr, true)
// Then convert it to a proper color
val color = if (resolvedAttr.resourceId != 0) {
val color =
if (resolvedAttr.resourceId != 0) {
resolvedAttr.resourceId
} else {
resolvedAttr.data
@ -183,9 +181,8 @@ fun Context.getDimenOffsetSafe(@DimenRes dimen: Int): Int {
@Px
fun Context.pxOfDp(@Dimension dp: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics
).toInt()
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
.toInt()
}
private fun <T> Context.handleResourceFailure(e: Exception, what: String, default: T): T {
@ -207,45 +204,36 @@ fun <T : Any> Context.getSystemServiceSafe(serviceClass: KClass<T>): T {
}
}
/**
* Create a toast using the provided string resource.
*/
/** Create a toast using the provided string resource. */
fun Context.showToast(@StringRes str: Int) {
Toast.makeText(applicationContext, getString(str), Toast.LENGTH_SHORT).show()
}
/**
* Create a [PendingIntent] that leads to Auxio's [MainActivity]
*/
/** Create a [PendingIntent] that leads to Auxio's [MainActivity] */
fun Context.newMainIntent(): PendingIntent {
return PendingIntent.getActivity(
this, INTENT_REQUEST_CODE, Intent(this, MainActivity::class.java),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
PendingIntent.FLAG_IMMUTABLE
else 0
)
this,
INTENT_REQUEST_CODE,
Intent(this, MainActivity::class.java),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
}
/**
* Create a broadcast [PendingIntent]
*/
/** Create a broadcast [PendingIntent] */
fun Context.newBroadcastIntent(what: String): PendingIntent {
return PendingIntent.getBroadcast(
this, INTENT_REQUEST_CODE, Intent(what),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
PendingIntent.FLAG_IMMUTABLE
else 0
)
this,
INTENT_REQUEST_CODE,
Intent(what),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
}
/**
* Hard-restarts the app. Useful for forcing the app to reload music.
*/
/** Hard-restarts the app. Useful for forcing the app to reload music. */
fun Context.hardRestart() {
// Instead of having to do a ton of cleanup and horrible code changes
// to restart this application non-destructively, I just restart the UI task [There is only
// one, after all] and then kill the application using exitProcess. Works well enough.
val intent = Intent(applicationContext, MainActivity::class.java)
val intent =
Intent(applicationContext, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
exitProcess(0)

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* DbUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,15 +22,13 @@ import android.database.sqlite.SQLiteDatabase
import android.os.Looper
/**
* Shortcut for querying all items in a database and running [block] with the cursor returned.
* Will not run if the cursor is null.
* Shortcut for querying all items in a database and running [block] with the cursor returned. Will
* not run if the cursor is null.
*/
fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
query(tableName, null, null, null, null, null, null)?.use(block)
/**
* Assert that we are on a background thread.
*/
/** Assert that we are on a background thread. */
fun assertBackgroundThread() {
check(Looper.myLooper() != Looper.getMainLooper()) {
"This operation must be ran on a background thread"

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* LogUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -25,14 +24,16 @@ import org.oxycblt.auxio.BuildConfig
// Yes, I know timber exists but this does what I need.
/**
* Shortcut method for logging a non-string [obj] to debug. Should only be used for debug preferably.
* Shortcut method for logging a non-string [obj] to debug. Should only be used for debug
* preferably.
*/
fun Any.logD(obj: Any) {
logD(obj.toString())
}
/**
* Shortcut method for logging [msg] to the debug console. Handles debug builds and anonymous objects
* Shortcut method for logging [msg] to the debug console. Handles debug builds and anonymous
* objects
*/
fun Any.logD(msg: String) {
if (BuildConfig.DEBUG) {
@ -41,16 +42,12 @@ fun Any.logD(msg: String) {
}
}
/**
* Shortcut method for logging [msg] as a warning to the console. Handles anonymous objects
*/
/** Shortcut method for logging [msg] as a warning to the console. Handles anonymous objects */
fun Any.logW(msg: String) {
Log.w(getName(), msg)
}
/**
* Shortcut method for logging [msg] as an error to the console. Handles anonymous objects
*/
/** Shortcut method for logging [msg] as an error to the console. Handles anonymous objects */
fun Any.logE(msg: String) {
Log.e(getName(), msg)
}
@ -77,18 +74,16 @@ private fun Any.getName(): String = "Auxio.${this::class.simpleName ?: "Anonymou
* I know that this will not stop you, but consider what you are doing with your life, plagiarizers.
* Do you want to live a fulfilling existence on this planet? Or do you want to spend your life
* taking work others did and making it objectively worse so you could arbitrage a fraction of a
* penny on every AdMob impression you get? You could do so many great things if you simply had
* the courage to come up with an idea of your own. If you still want to go on, I guess the only
* thing I can say is this: JUNE 1989 TIANAMEN SQUARE PROTESTS AND MASSACRE 六四事件
* penny on every AdMob impression you get? You could do so many great things if you simply had the
* courage to come up with an idea of your own. If you still want to go on, I guess the only thing I
* can say is this: JUNE 1989 TIANAMEN SQUARE PROTESTS AND MASSACRE 六四事件
*/
private fun basedCopyleftNotice() {
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug"
) {
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
Log.d(
"Auxio Project",
"Friendly reminder: Auxio is licensed under the " +
"GPLv3 and all modifications must be made open source!"
)
"GPLv3 and all modifications must be made open source!")
}
}

View file

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

View file

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

View file

@ -1,6 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* WidgetController.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -28,12 +27,11 @@ import org.oxycblt.auxio.util.logD
/**
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound
* to without being released.
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound to
* without being released.
*/
class WidgetController(private val context: Context) :
PlaybackStateManager.Callback,
SettingsManager.Callback {
PlaybackStateManager.Callback, SettingsManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val widget = WidgetProvider()

View file

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

View file

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

View file

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