diff --git a/CHANGELOG.md b/CHANGELOG.md
index 891991233..cf657f5de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,54 @@
# Changelog
-## dev [v2.2.1 or v2.3.0]
+## dev [v2.2.3, v2.3.0, or v3.0.0]
+
+## v2.2.2
+#### What's New
+- New spanish translations and metadata [courtesy of n-berenice]
+
+#### What's Improved
+- Rounded images are more nuanced
+- Shuffle and Repeat mode buttons now have more contrast when they are turned on
+
+#### What's Fixed
+- Fixed crash on certain devices running Android 10 and lower when a differing theme
+from the system theme was used [#80]
+- Fixed music loading failure that would occur when certain paths were parsed [#84]
+- Fixed incorrect track numbers when the tag was formatted as NN/TT [#88]
+- Fixed years deliberately set as "0" showing up as "No Date"
+- Fixed headset management unexpectedly starting audio when the app initially opens
+- Fixed crash that would occur during a playback restore with specific queue states [#89]
+- Partially fixed buggy behavior when multiple queue items were dragged in quick
+succession
+
+#### What's Changed
+- All cover art is now cropped to a 1:1 aspect ratio
+- Headset focus has been replaced with headset autoplay. It can no longer be disabled.
+
+#### Dev/Meta
+- Enabled elevation drop shadows below Android P for consistency
+- Switches now have a disabled state
+- Reworked dynamic color usage
+- Reworked logging
+- Upgrade ExoPlayer to v2.17.0 [Eliminates custom fork]
+
+## v2.2.1
+#### What's Improved
+- Updated chinese translations [courtesy of cccClyde]
+- Use proper material you top app bars
+- Use body typography in correct places
+- Expose file opening functionality better
+
+#### What's Fixed
+- Fixed issue where playback would start unexpectedly when opening the app
+
+#### What's Changed
+- Disabled audio focus customization on Android 12 [#75]
## v2.2.0
#### What's New:
-- Added arabic translations [courtesy of hasanpasha]
-- Better russian translations [courtesy of lisiczka43]
+- Added Arabic translations [Courtesy of hasanpasha]
+- Improved Russian translations [Courtesy of lisiczka43]
- Added option to reload the music library
#### What's Improved:
@@ -18,9 +61,10 @@ artist they are grouped up in
#### What's Fixed:
- Fixed crash on some devices configured to use French or Czech translations
-- Malformed indicies should now be corrected when the playback state is restored
+- Malformed indices should now be corrected when the playback state is restored
- Fixed issue where track numbers would not be shown in the native language's numeric format
- Fixed issue where the preference view would apply the M3 switches inconsistently
+- Fixed issue where the now playing indicator on the playback screen would use an internal name
#### Dev/Meta:
- Removed 1.4.X compat
@@ -178,7 +222,7 @@ to when using gesture navigation
- Fixed issue where the scroll thumb would briefly display on the Songs UI
- Fixed issue where fast scrolling could be triggered outside the bounds of the indicators
- Fixed issue where the wrong playing item would be highlighted if the names were identical
-- Fixed a crash when the thumb was moved above the fast scroller [Backported to 1.3.3, included in this release officially]
+- Fixed a crash when the thumb was moved above the fast scroller [Back-ported to 1.3.3, included in this release officially]
#### Dev/Meta
- Migrated fully to material design
diff --git a/README.md b/README.md
index f1f94c1a6..6257a2a02 100644
--- a/README.md
+++ b/README.md
@@ -3,14 +3,14 @@
A simple, rational music player for android.
-
+
-
diff --git a/app/build.gradle b/app/build.gradle
index 2579639c4..6131877bc 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -9,8 +9,8 @@ android {
defaultConfig {
applicationId "org.oxycblt.auxio"
- versionName "2.2.0"
- versionCode 12
+ versionName "2.2.2"
+ versionCode 14
minSdkVersion 21
targetSdkVersion 32
@@ -73,7 +73,7 @@ dependencies {
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
// Lifecycle
- def lifecycle_version = "2.4.0"
+ def lifecycle_version = "2.4.1"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@@ -85,7 +85,7 @@ dependencies {
// Media
// TODO: Dumpster this for Media3
- implementation "androidx.media:media:1.4.3"
+ implementation "androidx.media:media:1.5.0"
// Preferences
implementation "androidx.preference:preference-ktx:1.2.0"
@@ -93,32 +93,28 @@ dependencies {
// --- THIRD PARTY ---
// Exoplayer
- // WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE CUSTOM AAR BLOBS.
+ // WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION.
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
- def exoplayerVersion = '2.16.1'
- implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion") {
- exclude group: "com.google.android.exoplayer", module: "exoplayer-extractor"
- }
- implementation fileTree(dir: "libs", include: ["library-*.aar"])
+ def exoplayerVersion = '2.17.0'
+ implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion")
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
// Image loading
- implementation 'io.coil-kt:coil:2.0.0-alpha06'
+ implementation 'io.coil-kt:coil:2.0.0-alpha09'
// Material
- implementation 'com.google.android.material:material:1.6.0-alpha02'
+ implementation 'com.google.android.material:material:1.6.0-alpha03'
// --- DEBUG ---
// Lint
- ktlint 'com.pinterest:ktlint:0.43.2'
+ 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
@@ -127,6 +123,5 @@ 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"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 36f4201b1..17c808f45 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -51,9 +51,12 @@
+
+
+
@@ -66,7 +69,7 @@
android:roundIcon="@mipmap/ic_launcher" />
playbackModel.playWithUri(fileUri, this)
}
@@ -94,26 +95,29 @@ class MainActivity : AppCompatActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12, let dynamic colors be our accent and only enable the black theme option
if (isNight && settingsManager.useBlackTheme) {
+ logD("Applying black theme [dynamic colors]")
setTheme(R.style.Theme_Auxio_Black)
}
} else {
// Below android 12, load the accent and enable theme customization
AppCompatDelegate.setDefaultNightMode(settingsManager.theme)
- val newAccent = Accent.set(settingsManager.accent)
+ val accent = settingsManager.accent
// The black theme has a completely separate set of styles since style attributes cannot
// be modified at runtime.
if (isNight && settingsManager.useBlackTheme) {
- setTheme(newAccent.blackTheme)
+ logD("Applying black theme [accent $accent]")
+ setTheme(accent.blackTheme)
} else {
- setTheme(newAccent.theme)
+ logD("Applying normal theme [accent $accent]")
+ setTheme(accent.theme)
}
}
}
private fun applyEdgeToEdgeWindow(binding: ViewBinding) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- logD("Doing R+ edge-to-edge.")
+ logD("Doing R+ edge-to-edge")
window?.setDecorFitsSystemWindows(false)
@@ -136,7 +140,7 @@ class MainActivity : AppCompatActivity() {
}
} else {
// Do old edge-to-edge otherwise.
- logD("Doing legacy edge-to-edge.")
+ logD("Doing legacy edge-to-edge")
@Suppress("DEPRECATION")
binding.root.apply {
@@ -158,7 +162,7 @@ class MainActivity : AppCompatActivity() {
right = bars.right
)
- return replaceInsetsCompat(0, bars.top, 0, bars.bottom)
+ return replaceSystemBarInsetsCompat(0, bars.top, 0, bars.bottom)
}
companion object {
diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index 0da9ef221..ac0657cf3 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -36,11 +36,13 @@ import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
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.
*/
class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
@@ -78,10 +80,6 @@ class MainFragment : Fragment() {
// but for some insane reason google decided to cripple the window APIs one could use
// to limit it's size. So, we just have our own special layout that is shown whenever
// the screen is too small because of course we have to.
- // Another fun fact: smallestScreenWidthDp is completely bugged and uses the total
- // screen size, even when the window is smaller. This basically borks split screen
- // even more than it already does. Fun!
-
if (requireActivity().isInMultiWindowMode) {
val config = resources.configuration
@@ -110,7 +108,7 @@ class MainFragment : Fragment() {
// Error, show the error to the user
is MusicStore.Response.Err -> {
- logD("Received Error")
+ logW("Received Error")
val errorRes = when (response.kind) {
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music
@@ -142,7 +140,7 @@ class MainFragment : Fragment() {
}
}
- logD("Fragment Created.")
+ logD("Fragment Created")
return binding.root
}
diff --git a/app/src/main/java/org/oxycblt/auxio/accent/Accent.kt b/app/src/main/java/org/oxycblt/auxio/accent/Accent.kt
index ccec41a24..e496f8193 100644
--- a/app/src/main/java/org/oxycblt/auxio/accent/Accent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/accent/Accent.kt
@@ -100,6 +100,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.
+ *
* @property name The name of this accent
* @property theme The theme resource for this accent
* @property blackTheme The black theme resource for this accent
@@ -111,36 +114,4 @@ data class Accent(val index: Int) {
val theme: Int get() = ACCENT_THEMES[index]
val blackTheme: Int get() = ACCENT_BLACK_THEMES[index]
val primary: Int get() = ACCENT_PRIMARY_COLORS[index]
-
- companion object {
- @Volatile
- private var CURRENT: Accent? = null
-
- /**
- * Get the current accent.
- * @return The current accent
- * @throws IllegalStateException When the accent has not been set.
- */
- fun get(): Accent {
- val cur = CURRENT
-
- if (cur != null) {
- return cur
- }
-
- error("Accent must be set before retrieving it.")
- }
-
- /**
- * Set the current accent.
- * @return The new accent
- */
- fun set(accent: Accent): Accent {
- synchronized(this) {
- CURRENT = accent
- }
-
- return accent
- }
- }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt b/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt
index f42ecb3c9..0eaa56242 100644
--- a/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt
@@ -77,12 +77,10 @@ class AccentAdapter(
val context = binding.accent.context
binding.accent.isEnabled = !isSelected
-
binding.accent.imageTintList = if (isSelected) {
// Switch out the currently selected ViewHolder with this one.
selectedViewHolder?.setSelected(false)
selectedViewHolder = this
-
context.getAttrColorSafe(R.attr.colorSurface).stateList
} else {
context.getColorSafe(android.R.color.transparent).stateList
diff --git a/app/src/main/java/org/oxycblt/auxio/accent/AccentDialog.kt b/app/src/main/java/org/oxycblt/auxio/accent/AccentCustomizeDialog.kt
similarity index 90%
rename from app/src/main/java/org/oxycblt/auxio/accent/AccentDialog.kt
rename to app/src/main/java/org/oxycblt/auxio/accent/AccentCustomizeDialog.kt
index 3a6df6535..6c2321e5a 100644
--- a/app/src/main/java/org/oxycblt/auxio/accent/AccentDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/accent/AccentCustomizeDialog.kt
@@ -34,9 +34,9 @@ import org.oxycblt.auxio.util.logD
* Dialog responsible for showing the list of accents to select.
* @author OxygenCobalt
*/
-class AccentDialog : LifecycleDialog() {
+class AccentCustomizeDialog : LifecycleDialog() {
private val settingsManager = SettingsManager.getInstance()
- private var pendingAccent = Accent.get()
+ private var pendingAccent = settingsManager.accent
override fun onCreateView(
inflater: LayoutInflater,
@@ -53,18 +53,18 @@ class AccentDialog : LifecycleDialog() {
binding.accentRecycler.apply {
adapter = AccentAdapter(pendingAccent) { accent ->
+ logD("Switching selected accent to $accent")
pendingAccent = accent
}
}
- logD("Dialog created.")
+ logD("Dialog created")
return binding.root
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
-
outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index)
}
@@ -72,9 +72,9 @@ class AccentDialog : LifecycleDialog() {
builder.setTitle(R.string.set_accent)
builder.setPositiveButton(android.R.string.ok) { _, _ ->
- if (pendingAccent != Accent.get()) {
+ if (pendingAccent != settingsManager.accent) {
+ logD("Applying new accent")
settingsManager.accent = pendingAccent
-
requireActivity().recreate()
}
diff --git a/app/src/main/java/org/oxycblt/auxio/accent/AutoGridLayoutManager.kt b/app/src/main/java/org/oxycblt/auxio/accent/AccentGridLayoutManager.kt
similarity index 98%
rename from app/src/main/java/org/oxycblt/auxio/accent/AutoGridLayoutManager.kt
rename to app/src/main/java/org/oxycblt/auxio/accent/AccentGridLayoutManager.kt
index 4d371927e..48ed253ee 100644
--- a/app/src/main/java/org/oxycblt/auxio/accent/AutoGridLayoutManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/accent/AccentGridLayoutManager.kt
@@ -30,7 +30,7 @@ import kotlin.math.max
* of the RecyclerView.
* Adapted from this StackOverflow answer: https://stackoverflow.com/a/30256880/14143986
*/
-class AutoGridLayoutManager(
+class AccentGridLayoutManager(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int,
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt
similarity index 89%
rename from app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt
rename to app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt
index 910e4c43c..71801b6d1 100644
--- a/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt
+++ b/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt
@@ -18,8 +18,8 @@ import coil.size.pxOrElse
import com.google.android.exoplayer2.MediaItem
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 com.google.android.exoplayer2.metadata.vorbis.PictureFrame
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.buffer
@@ -27,6 +27,7 @@ import okio.source
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
@@ -34,8 +35,9 @@ import android.util.Size as AndroidSize
/**
* The base implementation for all image fetchers in Auxio.
* @author OxygenCobalt
+ * TODO: Artist images
*/
-abstract class AuxioFetcher : Fetcher {
+abstract class BaseFetcher : Fetcher {
private val settingsManager = SettingsManager.getInstance()
/**
@@ -55,6 +57,7 @@ abstract class AuxioFetcher : Fetcher {
fetchMediaStoreCovers(context, album)
}
} catch (e: Exception) {
+ logW("Unable to extract album art due to an error")
null
}
}
@@ -80,7 +83,6 @@ abstract class AuxioFetcher : Fetcher {
// music app which relies on proprietary OneUI extensions instead of AOSP. That means
// we have to have another layer of redundancy to retain quality. Thanks samsung. Prick.
val result = fetchAospMetadataCovers(context, album)
-
if (result != null) {
return result
}
@@ -88,7 +90,6 @@ abstract class AuxioFetcher : Fetcher {
// Our next fallback is to rely on ExoPlayer's largely half-baked and undocumented
// metadata system.
val exoResult = fetchExoplayerCover(context, album)
-
if (exoResult != null) {
return exoResult
}
@@ -97,7 +98,6 @@ abstract class AuxioFetcher : Fetcher {
// going against the point of this setting. The previous two calls are just too unreliable
// and we can't do any filesystem traversing due to scoped storage.
val mediaStoreResult = fetchMediaStoreCovers(context, album)
-
if (mediaStoreResult != null) {
return mediaStoreResult
}
@@ -107,16 +107,14 @@ abstract class AuxioFetcher : Fetcher {
}
private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
- val extractor = MediaMetadataRetriever()
-
- extractor.use { ext ->
+ MediaMetadataRetriever().use { ext ->
// This call is time-consuming but it also doesn't seem to hold up the main thread,
// so it's probably fine not to wrap it.
ext.setDataSource(context, album.songs[0].uri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
- // If its null [a.k.a there is no embedded cover], than just ignore it and move on
+ // If its null [i.e there is no embedded cover], than just ignore it and move on
return ext.embeddedPicture?.let { coverBytes ->
ByteArrayInputStream(coverBytes)
}
@@ -125,7 +123,6 @@ abstract class AuxioFetcher : Fetcher {
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri
-
val future = MetadataRetriever.retrieveMetadata(
context, MediaItem.fromUri(uri)
)
@@ -192,8 +189,7 @@ abstract class AuxioFetcher : Fetcher {
} else if (stream != null) {
// In the case a front cover is not found, use the first image in the tag instead.
// This can be corrected later on if a front cover frame is found.
- logD("No front cover image, using image of type $type instead")
-
+ logW("No front cover image, using image of type $type instead")
stream = ByteArrayInputStream(pic)
}
}
@@ -205,7 +201,7 @@ abstract class AuxioFetcher : Fetcher {
* Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph
*/
- protected fun createMosaic(context: Context, streams: List, size: Size): FetchResult? {
+ protected suspend fun createMosaic(context: Context, streams: List, size: Size): FetchResult? {
if (streams.size < 4) {
return streams.firstOrNull()?.let { stream ->
return SourceResult(
@@ -220,12 +216,15 @@ abstract class AuxioFetcher : 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 increment = AndroidSize(mosaicSize.width / 2, mosaicSize.height / 2)
-
- val mosaicBitmap = Bitmap.createBitmap(
- mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888
+ val mosaicFrameSize = Size(
+ Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2)
)
+ val mosaicBitmap = Bitmap.createBitmap(
+ mosaicSize.width,
+ mosaicSize.height,
+ Bitmap.Config.ARGB_8888
+ )
val canvas = Canvas(mosaicBitmap)
var x = 0
@@ -238,20 +237,21 @@ abstract class AuxioFetcher : Fetcher {
break
}
- val bitmap = Bitmap.createScaledBitmap(
- BitmapFactory.decodeStream(stream),
- increment.width,
- increment.height,
- true
- )
+ // 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
+ )
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
- x += increment.width
+ x += bitmap.width
if (x == mosaicSize.width) {
x = 0
- y += increment.height
+ y += bitmap.height
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt
index 6a9ba4f5c..256ff621f 100644
--- a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt
+++ b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt
@@ -35,7 +35,6 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.settings.SettingsManager
// --- BINDING ADAPTERS ---
@@ -65,24 +64,9 @@ fun ImageView.bindGenreImage(genre: Genre?) = load(genre, R.drawable.ic_genre)
fun ImageView.load(music: T?, @DrawableRes error: Int) {
dispose()
-
- // We don't round album covers by default as it desecrates album artwork, but we do provide
- // an option if one wants it.
- // As for why we use clipToOutline instead of coil's RoundedCornersTransformation, the transform
- // uses the dimensions of the image to create the corners, which results in inconsistent corners
- // across loaded cover art.
- val settingsManager = SettingsManager.getInstance()
-
- if (settingsManager.roundCovers && background == null) {
- setBackgroundResource(R.drawable.ui_rounded_cutout)
- clipToOutline = true
- } else if (!settingsManager.roundCovers && background != null) {
- background = null
- clipToOutline = false
- }
-
load(music) {
error(error)
+ transformations(SquareFrameTransform.INSTANCE)
}
}
@@ -102,6 +86,7 @@ fun loadBitmap(
ImageRequest.Builder(context)
.data(song.album)
.size(Size.ORIGINAL)
+ .transformations(SquareFrameTransform())
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/ErrorCrossfadeFactory.kt b/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeFactory.kt
similarity index 94%
rename from app/src/main/java/org/oxycblt/auxio/coil/ErrorCrossfadeFactory.kt
rename to app/src/main/java/org/oxycblt/auxio/coil/CrossfadeFactory.kt
index 8cc0184d5..8b2f03716 100644
--- a/app/src/main/java/org/oxycblt/auxio/coil/ErrorCrossfadeFactory.kt
+++ b/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeFactory.kt
@@ -13,7 +13,7 @@ import coil.transition.TransitionTarget
* You know. Like they used to.
* @author Coil Team
*/
-class ErrorCrossfadeFactory : Transition.Factory {
+class CrossfadeFactory : Transition.Factory {
override fun create(target: TransitionTarget, result: ImageResult): Transition {
// Don't animate if the request was fulfilled by the memory cache.
if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) {
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt
index 418083170..7e56c4ddf 100644
--- a/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt
+++ b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt
@@ -43,7 +43,7 @@ import kotlin.math.min
class AlbumArtFetcher private constructor(
private val context: Context,
private val album: Album
-) : AuxioFetcher() {
+) : BaseFetcher() {
override suspend fun fetch(): FetchResult? {
return fetchArt(context, album)?.let { stream ->
SourceResult(
@@ -75,11 +75,10 @@ class ArtistImageFetcher private constructor(
private val context: Context,
private val size: Size,
private val artist: Artist,
-) : AuxioFetcher() {
+) : BaseFetcher() {
override suspend fun fetch(): FetchResult? {
val albums = Sort.ByName(true)
.sortAlbums(artist.albums)
-
val results = albums.mapAtMost(4) { album ->
fetchArt(context, album)
}
@@ -102,7 +101,7 @@ class GenreImageFetcher private constructor(
private val context: Context,
private val size: Size,
private val genre: Genre,
-) : AuxioFetcher() {
+) : BaseFetcher() {
override suspend fun fetch(): FetchResult? {
// We don't need to sort here, as the way we
val albums = genre.songs.groupBy { it.album }.keys
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt b/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt
index a4aece706..bcc7e1902 100644
--- a/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt
@@ -11,6 +11,7 @@ import org.oxycblt.auxio.music.Song
class MusicKeyer : Keyer {
override fun key(data: Music, options: Options): String {
return if (data is Song) {
+ // Group up song covers with album covers for better caching
key(data.album, options)
} else {
"${data::class.simpleName}: ${data.id}"
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/RoundableImageView.kt b/app/src/main/java/org/oxycblt/auxio/coil/RoundableImageView.kt
new file mode 100644
index 000000000..49ff7f46f
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/coil/RoundableImageView.kt
@@ -0,0 +1,47 @@
+package org.oxycblt.auxio.coil
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.annotation.AttrRes
+import androidx.appcompat.widget.AppCompatImageView
+import com.google.android.material.shape.MaterialShapeDrawable
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.settings.SettingsManager
+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.
+ */
+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 {
+ setCornerSize(cornerRadius)
+ fillColor = context.getColorSafe(android.R.color.transparent).stateList
+ }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+
+ // Use clipToOutline and a background drawable to crop images. While Coil's transformation
+ // could theoretically be used to round corners, the corner radius is dependent on the
+ // dimensions of the image, which will result in inconsistent corners across different
+ // album covers unless we resize all covers to be the same size. clipToOutline is both
+ // cheaper and more elegant.
+ if (!isInEditMode) {
+ val settingsManager = SettingsManager.getInstance()
+ clipToOutline = settingsManager.roundCovers
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/SquareFrameTransform.kt b/app/src/main/java/org/oxycblt/auxio/coil/SquareFrameTransform.kt
new file mode 100644
index 000000000..1ef87f18c
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/coil/SquareFrameTransform.kt
@@ -0,0 +1,46 @@
+package org.oxycblt.auxio.coil
+
+import android.graphics.Bitmap
+import coil.size.Size
+import coil.size.pxOrElse
+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.
+ * @author OxygenCobalt
+ */
+class SquareFrameTransform : Transformation {
+ override val cacheKey: String
+ get() = "SquareFrameTransform"
+
+ override suspend fun transform(input: Bitmap, size: Size): Bitmap {
+ // Find the smaller dimension and then take a center portion of the image that
+ // has that size.
+ val dstSize = min(input.width, input.height)
+ val x = (input.width - dstSize) / 2
+ val y = (input.height - dstSize) / 2
+
+ val wantedWidth = size.width.pxOrElse { dstSize }
+ val wantedHeight = size.height.pxOrElse { dstSize }
+
+ val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
+
+ if (dstSize != wantedWidth || dstSize != wantedHeight) {
+ // Desired size differs from the cropped size, resize the bitmap.
+ return Bitmap.createScaledBitmap(
+ dst,
+ wantedWidth,
+ wantedHeight,
+ true
+ )
+ }
+
+ return dst
+ }
+
+ companion object {
+ val INSTANCE = SquareFrameTransform()
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
index 689f8113d..44d940065 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
@@ -40,6 +40,7 @@ import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.showToast
/**
@@ -56,6 +57,7 @@ class AlbumDetailFragment : DetailFragment() {
): View {
detailModel.setAlbum(args.albumId)
+ val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = AlbumDetailAdapter(
playbackModel, detailModel,
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) },
@@ -66,7 +68,7 @@ class AlbumDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner
- setupToolbar(detailModel.curAlbum.value!!, R.menu.menu_album_detail) { itemId ->
+ setupToolbar(detailModel.curAlbum.value!!, binding, R.menu.menu_album_detail) { itemId ->
when (itemId) {
R.id.action_play_next -> {
playbackModel.playNext(detailModel.curAlbum.value!!)
@@ -84,7 +86,7 @@ class AlbumDetailFragment : DetailFragment() {
}
}
- setupRecycler(detailAdapter) { pos ->
+ setupRecycler(binding, detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Album
}
@@ -111,10 +113,11 @@ class AlbumDetailFragment : DetailFragment() {
// fragment should be launched otherwise.
is Song -> {
if (detailModel.curAlbum.value!!.id == item.album.id) {
- scrollToItem(item.id, detailAdapter)
-
+ logD("Navigating to a song in this album")
+ scrollToItem(item.id, binding, detailAdapter)
detailModel.finishNavToItem()
} else {
+ logD("Navigating to another album")
findNavController().navigate(
AlbumDetailFragmentDirections.actionShowAlbum(item.album.id)
)
@@ -125,9 +128,11 @@ class AlbumDetailFragment : DetailFragment() {
// detail fragment.
is Album -> {
if (detailModel.curAlbum.value!!.id == item.id) {
+ logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0)
detailModel.finishNavToItem()
} else {
+ logD("Navigating to another album")
findNavController().navigate(
AlbumDetailFragmentDirections.actionShowAlbum(item.id)
)
@@ -136,13 +141,14 @@ class AlbumDetailFragment : DetailFragment() {
// Always launch a new ArtistDetailFragment.
is Artist -> {
+ logD("Navigating to another artist")
findNavController().navigate(
AlbumDetailFragmentDirections.actionShowArtist(item.id)
)
}
- else -> {
- }
+ null -> {}
+ else -> logW("Unsupported navigation item ${item::class.java}")
}
}
@@ -161,7 +167,7 @@ class AlbumDetailFragment : DetailFragment() {
}
}
- logD("Fragment created.")
+ logD("Fragment created")
return binding.root
}
@@ -180,7 +186,11 @@ class AlbumDetailFragment : DetailFragment() {
/**
* Scroll to an song using its [id].
*/
- private fun scrollToItem(id: Long, adapter: AlbumDetailAdapter) {
+ private fun scrollToItem(
+ id: Long,
+ binding: FragmentDetailBinding,
+ adapter: AlbumDetailAdapter
+ ) {
// Calculate where the item for the currently played song is
val pos = adapter.currentList.indexOfFirst { it.id == id && it is Song }
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
index eaa875e0b..f908d2fe5 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
@@ -25,6 +25,7 @@ import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
@@ -35,6 +36,7 @@ import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.logW
/**
* The [DetailFragment] for an artist.
@@ -50,6 +52,7 @@ class ArtistDetailFragment : DetailFragment() {
): View {
detailModel.setArtist(args.artistId)
+ val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = ArtistDetailAdapter(
playbackModel,
doOnClick = { data ->
@@ -73,8 +76,8 @@ class ArtistDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner
- setupToolbar(detailModel.curArtist.value!!)
- setupRecycler(detailAdapter) { pos ->
+ setupToolbar(detailModel.curArtist.value!!, binding)
+ setupRecycler(binding, detailAdapter) { pos ->
// If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Artist
@@ -98,25 +101,33 @@ class ArtistDetailFragment : DetailFragment() {
when (item) {
is Artist -> {
if (item.id == detailModel.curArtist.value?.id) {
+ logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0)
detailModel.finishNavToItem()
} else {
+ logD("Navigating to another artist")
findNavController().navigate(
ArtistDetailFragmentDirections.actionShowArtist(item.id)
)
}
}
- is Album -> findNavController().navigate(
- ArtistDetailFragmentDirections.actionShowAlbum(item.id)
- )
-
- is Song -> findNavController().navigate(
- ArtistDetailFragmentDirections.actionShowAlbum(item.album.id)
- )
-
- else -> {
+ is Album -> {
+ logD("Navigating to another album")
+ findNavController().navigate(
+ ArtistDetailFragmentDirections.actionShowAlbum(item.id)
+ )
}
+
+ is Song -> {
+ logD("Navigating to another album")
+ findNavController().navigate(
+ ArtistDetailFragmentDirections.actionShowAlbum(item.album.id)
+ )
+ }
+
+ null -> {}
+ else -> logW("Unsupported navigation item ${item::class.java}")
}
}
@@ -141,7 +152,7 @@ class ArtistDetailFragment : DetailFragment() {
}
}
- logD("Fragment created.")
+ logD("Fragment created")
return binding.root
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
index 516eff0d6..9aaf16b6f 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
@@ -5,7 +5,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
-import androidx.annotation.StyleRes
+import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatTextView
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
@@ -14,6 +14,9 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
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
@@ -25,7 +28,7 @@ import org.oxycblt.auxio.ui.EdgeAppBarLayout
class DetailAppBarLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- @StyleRes defStyleAttr: Int = -1
+ @AttrRes defStyleAttr: Int = 0
) : EdgeAppBarLayout(context, attrs, defStyleAttr) {
private var mTitleView: AppCompatTextView? = null
private var mRecycler: RecyclerView? = null
@@ -35,13 +38,11 @@ class DetailAppBarLayout @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
-
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
}
- private fun findTitleView(): AppCompatTextView {
+ private fun findTitleView(): AppCompatTextView? {
val titleView = mTitleView
-
if (titleView != null) {
return titleView
}
@@ -49,13 +50,18 @@ class DetailAppBarLayout @JvmOverloads constructor(
val toolbar = findViewById(R.id.detail_toolbar)
// Reflect to get the actual title view to do transformations on
- val newTitleView = Toolbar::class.java.getDeclaredField("mTitleTextView").run {
- isAccessible = true
- get(toolbar) as AppCompatTextView
+ val newTitleView = try {
+ Toolbar::class.java.getDeclaredField("mTitleTextView").run {
+ isAccessible = true
+ get(toolbar) as AppCompatTextView
+ }
+ } catch (e: Exception) {
+ logE("Could not get toolbar title view (likely an internal code change)")
+ e.logTraceOrThrow()
+ return null
}
newTitleView.alpha = 0f
-
mTitleView = newTitleView
return newTitleView
}
@@ -95,14 +101,14 @@ class DetailAppBarLayout @JvmOverloads constructor(
to = 0f
}
- if (titleView.alpha == to) return
+ if (titleView?.alpha == to) return
mTitleAnimator = ValueAnimator.ofFloat(from, to).apply {
addUpdateListener {
- titleView.alpha = it.animatedValue as Float
+ titleView?.alpha = it.animatedValue as Float
}
- duration = resources.getInteger(R.integer.app_bar_elevation_anim_duration).toLong()
+ duration = resources.getInteger(R.integer.detail_app_bar_title_anim_duration).toLong()
start()
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt
index b085c946f..a3a5c643b 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt
@@ -29,8 +29,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.playback.PlaybackViewModel
-import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applySpans
+import org.oxycblt.auxio.util.logD
/**
* A Base [Fragment] implementing the base features shared across all detail fragments.
@@ -39,17 +39,14 @@ import org.oxycblt.auxio.util.applySpans
abstract class DetailFragment : Fragment() {
protected val detailModel: DetailViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
- protected val binding by memberBinding(FragmentDetailBinding::inflate)
override fun onResume() {
super.onResume()
-
detailModel.setNavigating(false)
}
override fun onStop() {
super.onStop()
-
// Cancel all pending menus when this fragment stops to prevent bugs/crashes
detailModel.finishShowMenu(null)
}
@@ -62,6 +59,7 @@ abstract class DetailFragment : Fragment() {
*/
protected fun setupToolbar(
data: MusicParent,
+ binding: FragmentDetailBinding,
@MenuRes menuId: Int = -1,
onMenuClick: ((itemId: Int) -> Boolean)? = null
) {
@@ -88,13 +86,13 @@ abstract class DetailFragment : Fragment() {
* Shortcut method for recyclerview setup
*/
protected fun setupRecycler(
+ binding: FragmentDetailBinding,
detailAdapter: RecyclerView.Adapter,
gridLookup: (Int) -> Boolean
) {
binding.detailRecycler.apply {
adapter = detailAdapter
setHasFixedSize(true)
-
applySpans(gridLookup)
}
}
@@ -105,6 +103,8 @@ abstract class DetailFragment : Fragment() {
* @param showItem Which menu items to keep
*/
protected fun showMenu(config: DetailViewModel.MenuConfig, showItem: ((Int) -> Boolean)? = null) {
+ logD("Launching menu [$config]")
+
PopupMenu(config.anchor.context, config.anchor).apply {
inflate(R.menu.menu_detail_sort)
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
index 3c1db8a1b..31a41dc57 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
@@ -26,13 +26,14 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
+import org.oxycblt.auxio.util.logD
/**
* ViewModel that stores data for the [DetailFragment]s. This includes:
@@ -48,41 +49,39 @@ class DetailViewModel : ViewModel() {
private val mCurGenre = MutableLiveData()
val curGenre: LiveData get() = mCurGenre
- private val mGenreData = MutableLiveData(listOf())
- val genreData: LiveData> = mGenreData
+ private val mGenreData = MutableLiveData(listOf- ())
+ val genreData: LiveData
> = mGenreData
private val mCurArtist = MutableLiveData()
val curArtist: LiveData get() = mCurArtist
- private val mArtistData = MutableLiveData(listOf())
- val artistData: LiveData> = mArtistData
+ private val mArtistData = MutableLiveData(listOf- ())
+ val artistData: LiveData
> = mArtistData
private val mCurAlbum = MutableLiveData()
val curAlbum: LiveData get() = mCurAlbum
- private val mAlbumData = MutableLiveData(listOf())
- val albumData: LiveData> get() = mAlbumData
+ private val mAlbumData = MutableLiveData(listOf- ())
+ val albumData: LiveData
> get() = mAlbumData
data class MenuConfig(val anchor: View, val sortMode: Sort)
private val mShowMenu = MutableLiveData(null)
val showMenu: LiveData = mShowMenu
- private val mNavToItem = MutableLiveData()
+ private val mNavToItem = MutableLiveData- ()
/** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */
- val navToItem: LiveData get() = mNavToItem
+ val navToItem: LiveData
- get() = mNavToItem
var isNavigating = false
private set
private var currentMenuContext: DisplayMode? = null
-
private val settingsManager = SettingsManager.getInstance()
fun setGenre(id: Long) {
if (mCurGenre.value?.id == id) return
-
val musicStore = MusicStore.requireInstance()
mCurGenre.value = musicStore.genres.find { it.id == id }
refreshGenreData()
@@ -90,7 +89,6 @@ class DetailViewModel : ViewModel() {
fun setArtist(id: Long) {
if (mCurArtist.value?.id == id) return
-
val musicStore = MusicStore.requireInstance()
mCurArtist.value = musicStore.artists.find { it.id == id }
refreshArtistData()
@@ -98,7 +96,6 @@ class DetailViewModel : ViewModel() {
fun setAlbum(id: Long) {
if (mCurAlbum.value?.id == id) return
-
val musicStore = MusicStore.requireInstance()
mCurAlbum.value = musicStore.albums.find { it.id == id }
refreshAlbumData()
@@ -112,6 +109,7 @@ class DetailViewModel : ViewModel() {
mShowMenu.value = null
if (newMode != null) {
+ logD("Applying new sort mode")
when (currentMenuContext) {
DisplayMode.SHOW_ALBUMS -> {
settingsManager.detailAlbumSort = newMode
@@ -135,7 +133,7 @@ class DetailViewModel : ViewModel() {
/**
* Navigate to an item, whether a song/album/artist
*/
- fun navToItem(item: BaseModel) {
+ fun navToItem(item: Item) {
mNavToItem.value = item
}
@@ -154,7 +152,9 @@ class DetailViewModel : ViewModel() {
}
private fun refreshGenreData() {
- val data = mutableListOf(curGenre.value!!)
+ logD("Refreshing genre data")
+ val genre = requireNotNull(curGenre.value)
+ val data = mutableListOf
- (genre)
data.add(
ActionHeader(
@@ -175,8 +175,9 @@ class DetailViewModel : ViewModel() {
}
private fun refreshArtistData() {
- val artist = curArtist.value!!
- val data = mutableListOf(artist)
+ logD("Refreshing artist data")
+ val artist = requireNotNull(curArtist.value)
+ val data = mutableListOf
- (artist)
data.add(
Header(
@@ -206,7 +207,9 @@ class DetailViewModel : ViewModel() {
}
private fun refreshAlbumData() {
- val data = mutableListOf(curAlbum.value!!)
+ logD("Refreshing album data")
+ val album = requireNotNull(curAlbum.value)
+ val data = mutableListOf
- (album)
data.add(
ActionHeader(
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
index 8bb6d9ac9..96bc663ac 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
@@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
+import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
@@ -35,6 +36,7 @@ import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.logW
/**
* The [DetailFragment] for a genre.
@@ -50,6 +52,7 @@ class GenreDetailFragment : DetailFragment() {
): View {
detailModel.setGenre(args.genreId)
+ val binding = FragmentDetailBinding.inflate(inflater)
val detailAdapter = GenreDetailAdapter(
playbackModel,
doOnClick = { song ->
@@ -64,8 +67,8 @@ class GenreDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner
- setupToolbar(detailModel.curGenre.value!!)
- setupRecycler(detailAdapter) { pos ->
+ setupToolbar(detailModel.curGenre.value!!, binding)
+ setupRecycler(binding, detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Genre
}
@@ -79,20 +82,29 @@ class GenreDetailFragment : DetailFragment() {
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
when (item) {
// All items will launch new detail fragments.
- is Artist -> findNavController().navigate(
- GenreDetailFragmentDirections.actionShowArtist(item.id)
- )
-
- is Album -> findNavController().navigate(
- GenreDetailFragmentDirections.actionShowAlbum(item.id)
- )
-
- is Song -> findNavController().navigate(
- GenreDetailFragmentDirections.actionShowAlbum(item.album.id)
- )
-
- else -> {
+ is Artist -> {
+ logD("Navigating to another artist")
+ findNavController().navigate(
+ GenreDetailFragmentDirections.actionShowArtist(item.id)
+ )
}
+
+ is Album -> {
+ logD("Navigating to another album")
+ findNavController().navigate(
+ GenreDetailFragmentDirections.actionShowAlbum(item.id)
+ )
+ }
+
+ is Song -> {
+ logD("Navigating to another song")
+ findNavController().navigate(
+ GenreDetailFragmentDirections.actionShowAlbum(item.album.id)
+ )
+ }
+
+ null -> {}
+ else -> logW("Unsupported navigation command ${item::class.java}")
}
}
@@ -115,7 +127,7 @@ class GenreDetailFragment : DetailFragment() {
}
}
- logD("Fragment created.")
+ logD("Fragment created")
return binding.root
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
index c48a069d3..f58733277 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
@@ -30,9 +30,8 @@ import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.BaseModel
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.toDate
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
@@ -49,7 +48,7 @@ class AlbumDetailAdapter(
private val detailModel: DetailViewModel,
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Song) -> Unit
-) : ListAdapter(DiffCallback()) {
+) : ListAdapter
- (DiffCallback()) {
private var currentSong: Song? = null
private var currentHolder: Highlightable? = null
@@ -58,7 +57,6 @@ class AlbumDetailAdapter(
is Album -> ALBUM_DETAIL_ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
is Song -> ALBUM_SONG_ITEM_TYPE
-
else -> -1
}
}
@@ -86,7 +84,6 @@ class AlbumDetailAdapter(
is Album -> (holder as AlbumDetailViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
-
else -> {
}
}
@@ -127,7 +124,6 @@ class AlbumDetailAdapter(
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
currentHolder = it as Highlightable
-
currentHolder?.setHighlighted(true)
}
}
@@ -148,21 +144,19 @@ class AlbumDetailAdapter(
binding.detailSubhead.apply {
text = data.artist.resolvedName
-
setOnClickListener {
detailModel.navToItem(data.artist)
}
}
- binding.detailInfo.text = binding.detailInfo.context.getString(
- R.string.fmt_three,
- data.year.toDate(binding.detailInfo.context),
- binding.detailInfo.context.getPluralSafe(
- R.plurals.fmt_song_count,
- data.songs.size
- ),
- data.totalDuration
- )
+ binding.detailInfo.apply {
+ 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
+ )
+ }
binding.detailPlayButton.setOnClickListener {
playbackModel.playAlbum(data, false)
@@ -183,7 +177,7 @@ class AlbumDetailAdapter(
// Hide the track number view if the track is zero, as generally a track number of
// zero implies that the song does not have a track number.
- val usePlaceholder = data.track < 1
+ val usePlaceholder = data.track == null
binding.songTrack.isInvisible = usePlaceholder
binding.songTrackPlaceholder.isInvisible = !usePlaceholder
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
index 02b89073c..e80eb2ce2 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
@@ -30,15 +30,15 @@ import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.bindArtistInfo
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder
-import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater
/**
@@ -49,8 +49,8 @@ class ArtistDetailAdapter(
private val playbackModel: PlaybackViewModel,
private val doOnClick: (data: Album) -> Unit,
private val doOnSongClick: (data: Song) -> Unit,
- private val doOnLongClick: (view: View, data: BaseModel) -> Unit,
-) : ListAdapter(DiffCallback()) {
+ private val doOnLongClick: (view: View, data: Item) -> Unit,
+) : ListAdapter
- (DiffCallback()) {
private var currentAlbum: Album? = null
private var currentAlbumHolder: Highlightable? = null
@@ -64,7 +64,6 @@ class ArtistDetailAdapter(
is Song -> ARTIST_SONG_ITEM_TYPE
is Header -> HeaderViewHolder.ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
-
else -> -1
}
}
@@ -174,7 +173,6 @@ class ArtistDetailAdapter(
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
currentSongHolder = it as Highlightable
-
currentSongHolder?.setHighlighted(true)
}
}
@@ -201,15 +199,11 @@ class ArtistDetailAdapter(
// Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre.
binding.detailSubhead.text = data.songs
- .groupBy { it.genre?.resolvedName }
+ .groupBy { it.genre.resolvedName }
.entries.maxByOrNull { it.value.size }
?.key ?: context.getString(R.string.def_genre)
- binding.detailInfo.text = context.getString(
- R.string.fmt_counts,
- context.getPluralSafe(R.plurals.fmt_album_count, data.albums.size),
- context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)
- )
+ binding.detailInfo.bindArtistInfo(data)
binding.detailPlayButton.setOnClickListener {
playbackModel.playArtist(data, false)
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
index 9f0e8366c..11b2affbf 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
@@ -27,14 +27,14 @@ import org.oxycblt.auxio.coil.bindGenreImage
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemGenreSongBinding
import org.oxycblt.auxio.music.ActionHeader
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.bindGenreInfo
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback
-import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater
/**
@@ -45,7 +45,7 @@ class GenreDetailAdapter(
private val playbackModel: PlaybackViewModel,
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Song) -> Unit
-) : ListAdapter(DiffCallback()) {
+) : ListAdapter
- (DiffCallback()) {
private var currentSong: Song? = null
private var currentHolder: Highlightable? = null
@@ -54,7 +54,6 @@ class GenreDetailAdapter(
is Genre -> GENRE_DETAIL_ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
is Song -> GENRE_SONG_ITEM_TYPE
-
else -> -1
}
}
@@ -121,7 +120,6 @@ class GenreDetailAdapter(
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
currentHolder = it as Highlightable
-
currentHolder?.setHighlighted(true)
}
}
@@ -143,11 +141,7 @@ class GenreDetailAdapter(
}
binding.detailName.text = data.resolvedName
-
- binding.detailSubhead.apply {
- text = context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)
- }
-
+ binding.detailSubhead.bindGenreInfo(data)
binding.detailInfo.text = data.totalDuration
binding.detailPlayButton.setOnClickListener {
diff --git a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDatabase.kt b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDatabase.kt
index a5d434f95..b3562d563 100644
--- a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDatabase.kt
+++ b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDatabase.kt
@@ -55,7 +55,6 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
writableDatabase.transaction {
delete(TABLE_NAME, null, null)
-
logD("Deleted paths db")
for (path in paths) {
@@ -66,6 +65,8 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
}
)
}
+
+ logD("Successfully wrote ${paths.size} paths to db")
}
}
@@ -76,17 +77,20 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
assertBackgroundThread()
val paths = mutableListOf()
-
readableDatabase.queryAll(TABLE_NAME) { cursor ->
while (cursor.moveToNext()) {
paths.add(cursor.getString(0))
}
}
+ logD("Successfully read ${paths.size} paths from db")
+
return paths
}
companion object {
+ // Blacklist is still used here for compatibility reasons, please don't get
+ // your pants in a twist about it.
const val DB_VERSION = 1
const val DB_NAME = "auxio_blacklist_database.db"
diff --git a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt
index bb339023d..c5b9be463 100644
--- a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt
@@ -77,13 +77,16 @@ class ExcludedDialog : LifecycleDialog() {
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
+ logD("Opening launcher")
launcher.launch(null)
}
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
if (excludedModel.isModified) {
+ logD("Committing changes")
saveAndRestart()
} else {
+ logD("Dropping changes")
dismiss()
}
}
@@ -93,11 +96,10 @@ class ExcludedDialog : LifecycleDialog() {
excludedModel.paths.observe(viewLifecycleOwner) { paths ->
adapter.submitList(paths)
-
binding.excludedEmpty.isVisible = paths.isEmpty()
}
- logD("Dialog created.")
+ logD("Dialog created")
return binding.root
}
@@ -114,6 +116,7 @@ class ExcludedDialog : LifecycleDialog() {
private fun addDocTreePath(uri: Uri?) {
// A null URI means that the user left the file picker without picking a directory
if (uri == null) {
+ logD("No URI given (user closed the dialog)")
return
}
@@ -142,6 +145,7 @@ class ExcludedDialog : LifecycleDialog() {
return getRootPath() + "/" + typeAndPath.last()
}
+ logD("Unsupported volume ${typeAndPath[0]}")
return null
}
@@ -156,7 +160,6 @@ class ExcludedDialog : LifecycleDialog() {
/**
* Get *just* the root path, nothing else is really needed.
*/
- @Suppress("DEPRECATION")
private fun getRootPath(): String {
return Environment.getExternalStorageDirectory().absolutePath
}
diff --git a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt
index 504cb4f73..8de9ccc9b 100644
--- a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt
@@ -27,6 +27,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.oxycblt.auxio.util.logD
/**
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal
@@ -73,10 +74,13 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
*/
fun save(onDone: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
+ val start = System.currentTimeMillis()
excludedDatabase.writePaths(mPaths.value!!)
dbPaths = mPaths.value!!
-
onDone()
+ this@ExcludedViewModel.logD(
+ "Path save completed successfully in ${System.currentTimeMillis() - start}ms"
+ )
}
}
@@ -85,11 +89,14 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
*/
private fun loadDatabasePaths() {
viewModelScope.launch(Dispatchers.IO) {
+ val start = System.currentTimeMillis()
dbPaths = excludedDatabase.readPaths()
-
withContext(Dispatchers.Main) {
mPaths.value = dbPaths.toMutableList()
}
+ this@ExcludedViewModel.logD(
+ "Path load completed successfully in ${System.currentTimeMillis() - start}ms"
+ )
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/AdaptiveFloatingActionButton.kt
deleted file mode 100644
index 434820910..000000000
--- a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveFloatingActionButton.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.oxycblt.auxio.home
-
-import android.content.Context
-import android.util.AttributeSet
-import com.google.android.material.floatingactionbutton.FloatingActionButton
-import org.oxycblt.auxio.util.getDimenSizeSafe
-import com.google.android.material.R as MaterialR
-
-/**
- * A FloatingActionButton that automatically switches to a normal or large FAB depending on the
- * screen size.
- */
-@Suppress("PrivateResource")
-class AdaptiveFloatingActionButton @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = MaterialR.style.Widget_Material3_FloatingActionButton_Primary
-) : FloatingActionButton(context, attrs, defStyleAttr) {
-
- init {
- size = SIZE_NORMAL
-
- if (resources.configuration.smallestScreenWidthDp >= 640) {
- val largeFabSize = context.getDimenSizeSafe(
- MaterialR.dimen.m3_large_fab_size
- )
-
- val largeImageSize = context.getDimenSizeSafe(
- MaterialR.dimen.m3_large_fab_max_image_size
- )
-
- // Use a large FAB on large screens, as it makes it easier to touch.
- customSize = largeFabSize
- setMaxImageSize(largeImageSize)
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt
index d81da4d20..e76f5941a 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt
@@ -3,6 +3,7 @@ package org.oxycblt.auxio.home
import android.content.Context
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
+import org.oxycblt.auxio.util.logD
/**
* A tag configuration strategy that automatically adapts the tab layout to the screen size.
@@ -20,15 +21,22 @@ class AdaptiveTabStrategy(
val tabMode = homeModel.tabs[position]
when {
- width < 370 ->
+ width < 370 -> {
+ logD("Using icon-only configuration")
tab.setIcon(tabMode.icon)
.setContentDescription(tabMode.string)
+ }
- width < 640 -> tab.setText(tabMode.string)
+ width < 640 -> {
+ logD("Using text-only configuration")
+ tab.setText(tabMode.string)
+ }
- else ->
+ else -> {
+ logD("Using icon-and-text configuration")
tab.setIcon(tabMode.icon)
.setText(tabMode.string)
+ }
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/FloatingActionButtonContainer.kt b/app/src/main/java/org/oxycblt/auxio/home/EdgeFabContainer.kt
similarity index 92%
rename from app/src/main/java/org/oxycblt/auxio/home/FloatingActionButtonContainer.kt
rename to app/src/main/java/org/oxycblt/auxio/home/EdgeFabContainer.kt
index 863306dc9..8828c62f6 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/FloatingActionButtonContainer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/EdgeFabContainer.kt
@@ -22,6 +22,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.WindowInsets
import android.widget.FrameLayout
+import androidx.annotation.AttrRes
import androidx.core.view.updatePadding
import org.oxycblt.auxio.util.systemBarInsetsCompat
@@ -29,10 +30,10 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* A container for a FloatingActionButton that enables edge-to-edge support.
* @author OxygenCobalt
*/
-class FloatingActionButtonContainer @JvmOverloads constructor(
+class EdgeFabContainer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = -1
+ @AttrRes defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
init {
clipToPadding = false
@@ -44,7 +45,6 @@ class FloatingActionButtonContainer @JvmOverloads constructor(
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
-
return insets
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
index 7143169f9..8a9d6af25 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
@@ -49,11 +49,14 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.util.logD
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
*/
class HomeFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
@@ -77,16 +80,19 @@ class HomeFragment : Fragment() {
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_search -> {
+ logD("Navigating to search")
findNavController().navigate(HomeFragmentDirections.actionShowSearch())
}
R.id.action_settings -> {
+ logD("Navigating to settings")
parentFragment?.parentFragment?.findNavController()?.navigate(
MainFragmentDirections.actionShowSettings()
)
}
R.id.action_about -> {
+ logD("Navigating to about")
parentFragment?.parentFragment?.findNavController()?.navigate(
MainFragmentDirections.actionShowAbout()
)
@@ -96,20 +102,16 @@ class HomeFragment : Fragment() {
R.id.option_sort_asc -> {
item.isChecked = !item.isChecked
-
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!)
.ascending(item.isChecked)
-
homeModel.updateCurrentSort(new)
}
// Sorting option was selected, mark it as selected and update the mode
else -> {
item.isChecked = true
-
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!)
.assignId(item.itemId)
-
homeModel.updateCurrentSort(requireNotNull(new))
}
}
@@ -141,8 +143,8 @@ class HomeFragment : Fragment() {
set(recycler, slop * 3) // 3x seems to be the best fit here
}
} catch (e: Exception) {
- logE("Unable to reduce ViewPager sensitivity")
- logE(e.stackTraceToString())
+ logE("Unable to reduce ViewPager sensitivity (likely an internal code change)")
+ e.logTraceOrThrow()
}
// We know that there will only be a fixed amount of tabs, so we manually set this
@@ -174,7 +176,7 @@ class HomeFragment : Fragment() {
is MusicStore.Response.Ok -> binding.homeFab.show()
// While loading or during an error, make sure we keep the shuffle fab hidden so
- // that any kind of loading is impossible. PlaybackStateManager also relies on this
+ // that any kind of playback is impossible. PlaybackStateManager also relies on this
// invariant, so please don't change it.
else -> binding.homeFab.hide()
}
@@ -207,7 +209,7 @@ class HomeFragment : Fragment() {
homeModel.curTab.observe(viewLifecycleOwner) { t ->
val tab = requireNotNull(t)
- // Make sure that we update the scrolling view and allowed menu items before whenever
+ // Make sure that we update the scrolling view and allowed menu items whenever
// the tab changes.
when (tab) {
DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab)
@@ -229,8 +231,9 @@ class HomeFragment : Fragment() {
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
- // The AppBarLayout gets confused and collapses when we navigate too fast, wait for it
- // to draw before we continue.
+ // The AppBarLayout gets confused when we navigate too fast, wait for it to draw
+ // before we navigate.
+ // This is only here just in case a collapsing toolbar is re-added.
binding.homeAppbar.post {
when (item) {
is Song -> findNavController().navigate(
@@ -255,7 +258,7 @@ class HomeFragment : Fragment() {
}
}
- logD("Fragment Created.")
+ logD("Fragment Created")
return binding.root
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
index 0765d3ee1..9089b23f4 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
@@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
+import org.oxycblt.auxio.util.logD
/**
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
@@ -78,7 +79,6 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
viewModelScope.launch {
val musicStore = MusicStore.awaitInstance()
-
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.sortParents(musicStore.artists)
@@ -90,6 +90,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
* 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]
}
@@ -110,6 +111,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
* Update the currently displayed item's [Sort].
*/
fun updateCurrentSort(sort: Sort) {
+ logD("Updating ${mCurTab.value} sort to $sort")
when (mCurTab.value) {
DisplayMode.SHOW_SONGS -> {
settingsManager.libSongSort = sort
diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
index 77a1ec5e2..bd08de937 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
@@ -32,6 +32,7 @@ import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout
import android.widget.TextView
+import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.math.MathUtils
import androidx.core.view.isInvisible
@@ -77,7 +78,7 @@ import kotlin.math.abs
class FastScrollRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = -1
+ @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
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
index 277279989..271283e22 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
@@ -24,9 +24,9 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.toDate
import org.oxycblt.auxio.ui.AlbumViewHolder
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
@@ -43,6 +43,10 @@ class AlbumListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
+ val binding = FragmentHomeListBinding.inflate(layoutInflater)
+
+ // / --- UI SETUP ---
+
binding.lifecycleOwner = viewLifecycleOwner
val adapter = AlbumAdapter(
@@ -54,7 +58,7 @@ class AlbumListFragment : HomeListFragment() {
::newMenu
)
- setupRecycler(R.id.home_album_list, adapter, homeModel.albums)
+ setupRecycler(R.id.home_album_list, binding, adapter, homeModel.albums)
return binding.root
}
@@ -74,7 +78,8 @@ class AlbumListFragment : HomeListFragment() {
.first().uppercase()
// Year -> Use Full Year
- is Sort.ByYear -> album.year.toDate(requireContext())
+ is Sort.ByYear -> album.year?.toString()
+ ?: getString(R.string.def_date)
// Unsupported sort, error gracefully
else -> ""
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
index b9a26711e..eb3d96f19 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
@@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ArtistViewHolder
@@ -40,6 +41,10 @@ class ArtistListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
+ val binding = FragmentHomeListBinding.inflate(layoutInflater)
+
+ // / --- UI SETUP ---
+
binding.lifecycleOwner = viewLifecycleOwner
val adapter = ArtistAdapter(
@@ -51,7 +56,7 @@ class ArtistListFragment : HomeListFragment() {
::newMenu
)
- setupRecycler(R.id.home_artist_list, adapter, homeModel.artists)
+ setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists)
return binding.root
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
index bfd44685d..93482d032 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
@@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.ui.GenreViewHolder
@@ -40,6 +41,10 @@ class GenreListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
+ val binding = FragmentHomeListBinding.inflate(layoutInflater)
+
+ // / --- UI SETUP ---
+
binding.lifecycleOwner = viewLifecycleOwner
val adapter = GenreAdapter(
@@ -51,7 +56,7 @@ class GenreListFragment : HomeListFragment() {
::newMenu
)
- setupRecycler(R.id.home_genre_list, adapter, homeModel.genres)
+ setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres)
return binding.root
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt
index 8b5f8ecc3..9bca96f19 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt
@@ -26,9 +26,8 @@ import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
-import org.oxycblt.auxio.music.BaseModel
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.playback.PlaybackViewModel
-import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applySpans
/**
@@ -36,10 +35,6 @@ import org.oxycblt.auxio.util.applySpans
* @author OxygenCobalt
*/
abstract class HomeListFragment : Fragment() {
- protected val binding: FragmentHomeListBinding by memberBinding(
- FragmentHomeListBinding::inflate
- )
-
protected val homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
@@ -48,8 +43,9 @@ abstract class HomeListFragment : Fragment() {
*/
abstract val listPopupProvider: (Int) -> String
- protected fun setupRecycler(
+ protected fun setupRecycler(
@IdRes uniqueId: Int,
+ binding: FragmentHomeListBinding,
homeAdapter: HomeAdapter,
homeData: LiveData
>,
) {
@@ -71,7 +67,7 @@ abstract class HomeListFragment : Fragment() {
}
}
- abstract class HomeAdapter : RecyclerView.Adapter() {
+ abstract class HomeAdapter : RecyclerView.Adapter() {
protected var data = listOf()
@SuppressLint("NotifyDataSetChanged")
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
index 93bb437d1..139d88809 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
@@ -23,8 +23,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.toDate
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.Sort
@@ -41,6 +41,10 @@ class SongListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
+ val binding = FragmentHomeListBinding.inflate(layoutInflater)
+
+ // / --- UI SETUP ---
+
binding.lifecycleOwner = viewLifecycleOwner
val adapter = SongsAdapter(
@@ -50,7 +54,7 @@ class SongListFragment : HomeListFragment() {
::newMenu
)
- setupRecycler(R.id.home_song_list, adapter, homeModel.songs)
+ setupRecycler(R.id.home_song_list, binding, adapter, homeModel.songs)
return binding.root
}
@@ -77,7 +81,8 @@ class SongListFragment : HomeListFragment() {
.first().uppercase()
// Year -> Use Full Year
- is Sort.ByYear -> song.album.year.toDate(requireContext())
+ is Sort.ByYear -> song.album.year?.toString()
+ ?: getString(R.string.def_date)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
index a4166a1ec..3f8a30686 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
@@ -109,7 +109,7 @@ sealed class Tab(open val mode: DisplayMode) {
// For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < SEQUENCE_LEN) {
- logE("Sequence size was ${distinct.size}, which is invalid.")
+ logE("Sequence size was ${distinct.size}, which is invalid")
return null
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
index 636cf5e38..401663c45 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
@@ -70,14 +70,19 @@ class TabAdapter(
isChecked = tab is Tab.Visible
}
+ // Roll our own drag handlers as the default ones suck
binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
binding.tabDragHandle.performClick()
-
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(this)
true
} else false
}
+
+ binding.root.setOnLongClickListener {
+ touchHelper.startDrag(this)
+ true
+ }
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
index 5dfa61a1f..93b478f7d 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
@@ -29,6 +29,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.settings.SettingsManager
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
@@ -49,7 +50,6 @@ class TabCustomizeDialog : LifecycleDialog() {
if (savedInstanceState != null) {
// Restore any pending tab configurations
val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
-
if (tabs != null) {
pendingTabs = tabs
}
@@ -66,10 +66,9 @@ class TabCustomizeDialog : LifecycleDialog() {
// of how ViewHolders are bound], but instead simply look for the mode in
// the list of pending tabs and update that instead.
val index = pendingTabs.indexOfFirst { it.mode == tab.mode }
-
if (index != -1) {
val curTab = pendingTabs[index]
-
+ logD("Updating tab $curTab to $tab")
pendingTabs[index] = when (curTab) {
is Tab.Visible -> Tab.Invisible(curTab.mode)
is Tab.Invisible -> Tab.Visible(curTab.mode)
@@ -93,7 +92,6 @@ class TabCustomizeDialog : LifecycleDialog() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
-
outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs))
}
@@ -101,6 +99,7 @@ class TabCustomizeDialog : LifecycleDialog() {
builder.setTitle(R.string.set_lib_tabs)
builder.setPositiveButton(android.R.string.ok) { _, _ ->
+ logD("Committing tab changes")
settingsManager.libTabs = pendingTabs
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt
index 4f3942615..f9de5ac79 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt
@@ -25,6 +25,8 @@ 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.
*/
class TabDragCallback(private val getTabs: () -> Array) : ItemTouchHelper.Callback() {
private val tabs: Array get() = getTabs()
@@ -70,6 +72,9 @@ class TabDragCallback(private val getTabs: () -> Array) : ItemTouchHelper.C
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
+ // We use a custom drag handle, so disable the long press action.
+ override fun isLongPressDragEnabled(): Boolean = false
+
/**
* Add the tab adapter to this callback.
* Done because there's a circular dependency between the two objects
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Models.kt b/app/src/main/java/org/oxycblt/auxio/music/Models.kt
index 78fefe345..ad78d4379 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Models.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Models.kt
@@ -28,33 +28,37 @@ import androidx.annotation.StringRes
// --- MUSIC MODELS ---
/**
- * The base data object for all music.
- * @property id A unique ID for this object. ***THIS IS NOT A MEDIASTORE ID!**
+ * The base for all items in Auxio.
*/
-sealed class BaseModel {
+sealed class Item {
+ /** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
abstract val id: Long
}
/**
- * A [BaseModel] variant that represents a music item.
- * @property name The raw name of this track
+ * [Item] variant that represents a music item.
+ * @property name
*/
-sealed class Music : BaseModel() {
+sealed class Music : Item() {
+ /** The raw name of this item. */
abstract val name: String
}
/**
* [Music] variant that denotes that this object is a parent of other data objects, such
* as an [Album] or [Artist]
- * @property resolvedName 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.
+ * @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.
+ */
abstract val resolvedName: String
}
/**
- * The data object for a song. Inherits [BaseModel].
+ * The data object for a song.
*/
data class Song(
override val name: String,
@@ -62,33 +66,33 @@ data class Song(
val fileName: String,
/** The total duration of this song, in millis. */
val duration: Long,
- /** The track number of this song. */
- val track: Int,
+ /** The track number of this song, null if there isn't any. */
+ val track: Int?,
/** Internal field. Do not use. */
- val _mediaStoreId: Long,
+ val internalMediaStoreId: Long,
/** Internal field. Do not use. */
- val _mediaStoreArtistName: String?,
+ val internalMediaStoreYear: Int?,
/** Internal field. Do not use. */
- val _mediaStoreAlbumArtistName: String?,
+ val internalMediaStoreAlbumName: String,
/** Internal field. Do not use. */
- val _mediaStoreAlbumId: Long,
+ val internalMediaStoreAlbumId: Long,
/** Internal field. Do not use. */
- val _mediaStoreAlbumName: String,
+ val internalMediaStoreArtistName: String?,
/** Internal field. Do not use. */
- val _mediaStoreYear: Int
+ val internalMediaStoreAlbumArtistName: String?,
) : Music() {
override val id: Long get() {
var result = name.hashCode().toLong()
result = 31 * result + album.name.hashCode()
result = 31 * result + album.artist.name.hashCode()
- result = 31 * result + track
+ result = 31 * result + (track ?: 0)
result = 31 * result + duration.hashCode()
return result
}
/** The URI for this song. */
val uri: Uri get() = ContentUris.withAppendedId(
- MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, _mediaStoreId
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, internalMediaStoreId
)
/** The duration of this song, in seconds (rounded down) */
val seconds: Long get() = duration / 1000
@@ -99,9 +103,9 @@ data class Song(
/** The album of this song. */
val album: Album get() = requireNotNull(mAlbum)
- var mGenre: Genre? = null
- /** The genre of this song. May be null due to MediaStore insanity. */
- val genre: Genre? get() = mGenre
+ 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)
/** An album name resolved to this song in particular. */
val resolvedAlbumName: String get() =
@@ -109,43 +113,61 @@ data class Song(
/** An artist name resolved to this song in particular. */
val resolvedArtistName: String get() =
- _mediaStoreArtistName ?: album.artist.resolvedName
+ internalMediaStoreArtistName ?: album.artist.resolvedName
+
+ /** Internal field. Do not use. */
+ val internalGroupingId: Int get() {
+ var result = internalGroupingArtistName.lowercase().hashCode()
+ result = 31 * result + internalMediaStoreAlbumName.lowercase().hashCode()
+ return result
+ }
+
+ /** Internal field. Do not use. */
+ val internalGroupingArtistName: String get() = internalMediaStoreAlbumArtistName
+ ?: internalMediaStoreArtistName ?: MediaStore.UNKNOWN_STRING
+
+ /** Internal field. Do not use. */
+ 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
/** Internal method. Do not use. */
- fun mediaStoreLinkAlbum(album: Album) {
+ fun internalLinkAlbum(album: Album) {
mAlbum = album
}
/** Internal method. Do not use. */
- fun mediaStoreLinkGenre(genre: Genre) {
+ fun internalLinkGenre(genre: Genre) {
mGenre = genre
}
}
/**
- * The data object for an album. Inherits [MusicParent].
+ * The data object for an album.
*/
data class Album(
override val name: String,
- /** The latest year of the songs in this album. */
- val year: Int,
+ /** The latest year of the songs in this album. Null if none of the songs had metadata. */
+ val year: Int?,
/** The URI for the cover art corresponding to this album. */
val albumCoverUri: Uri,
/** The songs of this album. */
val songs: List,
/** Internal field. Do not use. */
- val _mediaStoreArtistName: String,
+ val internalGroupingArtistName: String,
) : MusicParent() {
init {
for (song in songs) {
- song.mediaStoreLinkAlbum(this)
+ song.internalLinkAlbum(this)
}
}
override val id: Long get() {
var result = name.hashCode().toLong()
result = 31 * result + artist.name.hashCode()
- result = 31 * result + year
+ result = 31 * result + (year ?: 0)
return result
}
@@ -164,8 +186,11 @@ data class Album(
val resolvedArtistName: String get() =
artist.resolvedName
+ /** Internal field. Do not use. */
+ val internalIsMissingArtist: Boolean = mArtist != null
+
/** Internal method. Do not use. */
- fun mediaStoreLinkArtist(artist: Artist) {
+ fun internalLinkArtist(artist: Artist) {
mArtist = artist
}
}
@@ -182,7 +207,7 @@ data class Artist(
) : MusicParent() {
init {
for (album in albums) {
- album.mediaStoreLinkArtist(this)
+ album.internalLinkArtist(this)
}
}
@@ -193,7 +218,7 @@ data class Artist(
}
/**
- * The data object for a genre. Inherits [MusicParent]
+ * The data object for a genre.
*/
data class Genre(
override val name: String,
@@ -202,7 +227,7 @@ data class Genre(
) : MusicParent() {
init {
for (song in songs) {
- song.mediaStoreLinkGenre(this)
+ song.internalLinkGenre(this)
}
}
@@ -220,7 +245,7 @@ data class Header(
override val id: Long,
/** The string resource used for the header. */
@StringRes val string: Int
-) : BaseModel()
+) : Item()
/**
* A data object used for an action header. Like [Header], but with a button.
@@ -236,7 +261,7 @@ data class ActionHeader(
@StringRes val desc: Int,
/** A callback for when this item is clicked. */
val onClick: (View) -> Unit,
-) : BaseModel() {
+) : Item() {
// All lambdas are not equal to each-other, so we override equals/hashCode and exclude them.
override fun equals(other: Any?): Boolean {
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt
index d978a4a71..d861a3787 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt
@@ -4,11 +4,12 @@ import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
+import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
+import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R
import org.oxycblt.auxio.excluded.ExcludedDatabase
-import org.oxycblt.auxio.util.logE
-import java.lang.Exception
+import org.oxycblt.auxio.util.logD
/**
* This class acts as the base for most the black magic required to get a remotely sensible music
@@ -26,7 +27,7 @@ import java.lang.Exception
* 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 column is absolutely insane, and
+ * 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!
@@ -34,7 +35,7 @@ import java.lang.Exception
* 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 their metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or
+ * 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
@@ -45,7 +46,7 @@ import java.lang.Exception
* 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 deprecated method, and the supposedly "modern" method is SLOWER and
+ * 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
@@ -66,13 +67,12 @@ import java.lang.Exception
* I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
* probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
* Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
- * to your AlgoPop StreamMix™ instead.
+ * to your AlgoPop StreamMix™.
*
* I wish I was born in the neolithic.
*
* @author OxygenCobalt
*/
-@Suppress("InlinedApi")
class MusicLoader {
data class Library(
val genres: List,
@@ -89,13 +89,18 @@ class MusicLoader {
val artists = buildArtists(context, albums)
val genres = readGenres(context, songs)
- // Sanity check: Ensure that all songs are well-formed.
+ // Sanity check: Ensure that all songs are linked up to albums/artists/genres.
for (song in songs) {
- try {
- song.album.artist
- } catch (e: Exception) {
- logE("Found malformed song: ${song.name}")
- throw e
+ if (song.internalIsMissingAlbum ||
+ song.internalIsMissingArtist ||
+ song.internalIsMissingGenre
+ ) {
+ throw IllegalStateException(
+ "Found malformed song: ${song.name} [" +
+ "album: ${!song.internalIsMissingAlbum} " +
+ "artist: ${!song.internalIsMissingArtist} " +
+ "genre: ${!song.internalIsMissingGenre}]"
+ )
}
}
@@ -118,56 +123,76 @@ class MusicLoader {
// DATA was deprecated on Android 10, but is set to be un-deprecated in Android 12L.
// The only reason we'd want to change this is to add external partitions support, but
// that's less efficient and there's no demand for that right now.
+ // TODO: Determine if grokking the actual DATA value outside of SQL is more or less
+ // efficient than the current system
for (path in paths) {
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children
}
+ // TODO: Move all references to contentResolver into a single variable so we can
+ // avoid accidentally removing the applicationContext fix
context.applicationContext.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
+ MediaStore.Audio.AudioColumns.TRACK,
+ MediaStore.Audio.AudioColumns.DURATION,
+ MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM,
MediaStore.Audio.AudioColumns.ALBUM_ID,
MediaStore.Audio.AudioColumns.ARTIST,
- MediaStore.Audio.AudioColumns.ALBUM_ARTIST,
- MediaStore.Audio.AudioColumns.YEAR,
- MediaStore.Audio.AudioColumns.TRACK,
- MediaStore.Audio.AudioColumns.DURATION,
+ 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 trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
+ 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 artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
- val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ARTIST)
- val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
- val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
- val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
+ val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
+ val title = cursor.getString(titleIndex)
val fileName = cursor.getString(fileIndex)
- val title = cursor.getString(titleIndex) ?: fileName
+
+ // The TRACK field is for some reason formatted as DTTT, where D is the disk
+ // and T is the track. This is dumb and insane and forces me to mangle track
+ // numbers above 1000 but there is nothing we can do that won't break the app
+ // below API 30.
+ // TODO: Disk number support?
+ val track = cursor.getIntOrNull(trackIndex)?.mod(1000)
+
+ val duration = cursor.getLong(durationIndex)
+ val year = cursor.getIntOrNull(yearIndex)
+
val album = cursor.getString(albumIndex)
val albumId = cursor.getLong(albumIdIndex)
// If the artist field is , make it null. This makes handling the
// insanity of the artist field easier later on.
- val artist = cursor.getString(artistIndex).let {
- if (it != MediaStore.UNKNOWN_STRING) it else null
+ val artist = cursor.getStringOrNull(artistIndex)?.run {
+ if (this == MediaStore.UNKNOWN_STRING) {
+ null
+ } else {
+ this
+ }
}
val albumArtist = cursor.getStringOrNull(albumArtistIndex)
- val year = cursor.getInt(yearIndex)
- val track = cursor.getInt(trackIndex)
- val duration = cursor.getLong(durationIndex)
+ // Note: Directory parsing is currently disabled until artist images are added.
+ // val dirs = cursor.getStringOrNull(dataIndex)?.run {
+ // substringBeforeLast("/", "").ifEmpty { null }
+ // }
songs.add(
Song(
@@ -176,29 +201,28 @@ class MusicLoader {
duration,
track,
id,
+ year,
+ album,
+ albumId,
artist,
albumArtist,
- albumId,
- album,
- year,
)
)
}
}
+ // Deduplicate songs to prevent (most) deformed music clones
songs = songs.distinctBy {
- it.name to it._mediaStoreAlbumName to it._mediaStoreArtistName to it._mediaStoreAlbumArtistName to it.track to it.duration
+ it.name to it.internalMediaStoreAlbumName to it.internalMediaStoreArtistName to
+ it.internalMediaStoreAlbumArtistName to it.track to it.duration
}.toMutableList()
+ logD("Successfully loaded ${songs.size} songs")
+
return songs
}
private fun buildAlbums(songs: List): List {
- // When assigning an artist to an album, use the album artist first, then the
- // normal artist, and then the internal representation of an unknown artist name.
- fun Song.resolveAlbumArtistName() = _mediaStoreAlbumArtistName ?: _mediaStoreArtistName
- ?: MediaStore.UNKNOWN_STRING
-
// Group up songs by their lowercase artist and album name. 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"
@@ -209,9 +233,7 @@ class MusicLoader {
// the template, but it seems to work pretty well.
val albums = mutableListOf()
val songsByAlbum = songs.groupBy { song ->
- val albumName = song._mediaStoreAlbumName
- val artistName = song.resolveAlbumArtistName()
- Pair(albumName.lowercase(), artistName.lowercase())
+ song.internalGroupingId
}
for (entry in songsByAlbum) {
@@ -220,14 +242,17 @@ class MusicLoader {
// Use the song with the latest year as our metadata song.
// 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.
- val templateSong = requireNotNull(albumSongs.maxByOrNull { it._mediaStoreYear })
- val albumName = templateSong._mediaStoreAlbumName
- val albumYear = templateSong._mediaStoreYear
+ // TODO: Weigh songs with null years lower than songs with zero years
+ val templateSong = requireNotNull(
+ albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 }
+ )
+ val albumName = templateSong.internalMediaStoreAlbumName
+ val albumYear = templateSong.internalMediaStoreYear
val albumCoverUri = ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"),
- templateSong._mediaStoreAlbumId
+ templateSong.internalMediaStoreAlbumId
)
- val artistName = templateSong.resolveAlbumArtistName()
+ val artistName = templateSong.internalGroupingArtistName
albums.add(
Album(
@@ -240,12 +265,14 @@ class MusicLoader {
)
}
+ logD("Successfully built ${albums.size} albums")
+
return albums
}
private fun buildArtists(context: Context, albums: List): List {
val artists = mutableListOf()
- val albumsByArtist = albums.groupBy { it._mediaStoreArtistName }
+ val albumsByArtist = albums.groupBy { it.internalGroupingArtistName }
for (entry in albumsByArtist) {
val artistName = entry.key
@@ -255,14 +282,17 @@ class MusicLoader {
}
val artistAlbums = entry.value
- // Due to the black magic we do to get a good artist field, the ID is unreliable.
- // Take a hash of the artist name instead.
+ // Album deduplication does not eliminate every case of fragmented artists, do
+ // we deduplicate in the artist creation step as well.
+ // Note that we actually don't do this in groupBy. This is generally because using
+ // a template song may not result in the best possible artist name in all cases.
val previousArtistIndex = artists.indexOfFirst { artist ->
artist.name.lowercase() == artistName.lowercase()
}
if (previousArtistIndex > -1) {
val previousArtist = artists[previousArtistIndex]
+ logD("Merging duplicate artist into pre-existing artist ${previousArtist.name}")
artists[previousArtistIndex] = Artist(
previousArtist.name,
previousArtist.resolvedName,
@@ -279,13 +309,15 @@ class MusicLoader {
}
}
+ logD("Successfully built ${artists.size} artists")
+
return artists
}
private fun readGenres(context: Context, songs: List): List {
val genres = mutableListOf()
- val genreCursor = context.contentResolver.query(
+ val genreCursor = context.applicationContext.contentResolver.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Audio.Genres._ID,
@@ -305,7 +337,7 @@ class MusicLoader {
// so we skip genres that have them.
val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue
- val resolvedName = name.getGenreNameCompat() ?: name
+ val resolvedName = name.genreNameCompat ?: name
val genreSongs = queryGenreSongs(context, id, songs) ?: continue
genres.add(
@@ -318,7 +350,7 @@ class MusicLoader {
}
}
- val songsWithoutGenres = songs.filter { it.genre == null }
+ val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
if (songsWithoutGenres.isNotEmpty()) {
// Songs that don't have a genre will be thrown into an unknown genre.
@@ -331,6 +363,8 @@ class MusicLoader {
genres.add(unknownGenre)
}
+ logD("Successfully loaded ${genres.size} genres")
+
return genres
}
@@ -338,7 +372,7 @@ class MusicLoader {
val genreSongs = mutableListOf()
// Don't even bother blacklisting here as useless iterations are less expensive than IO
- val songCursor = context.contentResolver.query(
+ val songCursor = context.applicationContext.contentResolver.query(
MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID),
null, null, null
@@ -349,15 +383,87 @@ class MusicLoader {
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
-
- songs.find { it._mediaStoreId == id }?.let { song ->
+ songs.find { it.internalMediaStoreId == id }?.let { song ->
genreSongs.add(song)
}
}
}
- // Some genres might be empty due to MediaStore empty.
+ // Some genres might be empty due to MediaStore insanity.
// If that is the case, we drop them.
return genreSongs.ifEmpty { null }
}
+
+ private val String.genreNameCompat: String? get() {
+ if (isDigitsOnly()) {
+ // ID3v1, just parse as an integer
+ return legacyGenreTable.getOrNull(toInt())
+ }
+
+ if (startsWith('(') && endsWith(')')) {
+ // ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
+ // Any genres formatted as "(CHARS)" will be ignored.
+ val genreInt = substring(1 until lastIndex).toIntOrNull()
+ if (genreInt != null) {
+ return legacyGenreTable.getOrNull(genreInt)
+ }
+ }
+
+ // Current name is fine.
+ return null
+ }
+
+ 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.
+ */
+ @Suppress("InlinedApi")
+ const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
+
+ /**
+ * A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
+ * winamp extensions.
+ */
+ private val legacyGenreTable = 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",
+
+ // Winamp Extensions
+ "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, used by EasyTAG and friends
+ // The only reason I include this set is 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"
+ )
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
index 22addc747..d98671708 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
@@ -19,7 +19,6 @@
package org.oxycblt.auxio.music
import android.Manifest
-import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
@@ -36,6 +35,7 @@ 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]
* @author OxygenCobalt
*/
class MusicStore private constructor() {
@@ -55,7 +55,7 @@ class MusicStore private constructor() {
* Load/Sort the entire music library. Should always be ran on a coroutine.
*/
private fun load(context: Context): Response {
- logD("Starting initial music load...")
+ logD("Starting initial music load")
val notGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_EXTERNAL_STORAGE
@@ -69,18 +69,18 @@ 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
mArtists = library.artists
mGenres = library.genres
- logD("Music load completed successfully in ${System.currentTimeMillis() - start}ms.")
+ logD("Music load completed successfully in ${System.currentTimeMillis() - start}ms")
} catch (e: Exception) {
- logE("Something went horribly wrong.")
+ logE("Music loading failed.")
logE(e.stackTraceToString())
-
return Response.Err(ErrorKind.FAILED)
}
@@ -99,14 +99,15 @@ class MusicStore private constructor() {
* @return The corresponding [Song] for this [uri], null if there isn't one.
*/
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
- val cur = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
-
- cur?.use { cursor ->
+ resolver.query(
+ uri,
+ arrayOf(OpenableColumns.DISPLAY_NAME),
+ null, null, null
+ )?.use { cursor ->
cursor.moveToFirst()
-
- // Make studio shut up about "invalid ranges" that don't exist
- @SuppressLint("Range")
- val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
+ val fileName = cursor.getString(
+ cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
+ )
return songs.find { it.fileName == fileName }
}
@@ -117,6 +118,7 @@ class MusicStore private constructor() {
/**
* A response that [MusicStore] returns when loading music.
* And before you ask, yes, I do like rust.
+ * TODO: Replace this with the kotlin builtin
*/
sealed class Response {
class Ok(val musicStore: MusicStore) : Response()
@@ -145,11 +147,9 @@ class MusicStore private constructor() {
val response = withContext(Dispatchers.IO) {
val response = MusicStore().load(context)
-
synchronized(this) {
RESPONSE = response
}
-
response
}
@@ -201,7 +201,7 @@ class MusicStore private constructor() {
*/
fun requireInstance(): MusicStore {
return requireNotNull(maybeGetInstance()) {
- "Required MusicStore instance was not available."
+ "Required MusicStore instance was not available"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt
index cc3d87b49..5c03a5e95 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt
@@ -18,79 +18,16 @@
package org.oxycblt.auxio.music
-import android.content.Context
import android.text.format.DateUtils
import android.widget.TextView
-import androidx.core.text.isDigitsOnly
import androidx.databinding.BindingAdapter
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getPluralSafe
-
-/**
- * A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
- * winamp extensions.
- */
-private val ID3_GENRES = 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",
-
- // Winamp Extensions
- "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, used by EasyTAG and friends
- "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", // S I X T Y F I V E
- "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"
-)
+import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.logW
// --- EXTENSION FUNCTIONS ---
-/**
- * Convert legacy int-based ID3 genres to their human-readable genre
- * @return The named genre for this legacy genre, null if there is no need to parse it
- * or if the genre is invalid.
- */
-fun String.getGenreNameCompat(): String? {
- if (isDigitsOnly()) {
- // ID3v1, just parse as an integer
- return ID3_GENRES.getOrNull(toInt())
- }
-
- if (startsWith('(') && endsWith(')')) {
- // ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
- // Any genres formatted as "(CHARS)" will be ignored.
- val genreInt = substring(1 until lastIndex).toIntOrNull()
-
- if (genreInt != null) {
- return ID3_GENRES.getOrNull(genreInt)
- }
- }
-
- // Current name is fine.
- return null
-}
-
/**
* Convert a [Long] of seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then
@@ -98,6 +35,7 @@ fun String.getGenreNameCompat(): String? {
*/
fun Long.toDuration(isElapsed: Boolean): String {
if (!isElapsed && this == 0L) {
+ logD("Non-elapsed duration is zero, using --:--")
return "--:--"
}
@@ -111,24 +49,56 @@ fun Long.toDuration(isElapsed: Boolean): String {
return durationString
}
-fun Int.toDate(context: Context): String {
- return if (this == 0) {
- context.getString(R.string.def_date)
- } else {
- toString()
- }
-}
-
// --- BINDING ADAPTERS ---
-/**
- * Bind the album + song counts for an artist
- */
-@BindingAdapter("artistCounts")
-fun TextView.bindArtistCounts(artist: Artist) {
+@BindingAdapter("songInfo")
+fun TextView.bindSongInfo(song: Song?) {
+ if (song == null) {
+ logW("Song was null, not applying info")
+ return
+ }
+
text = context.getString(
- R.string.fmt_counts,
+ R.string.fmt_two,
+ song.resolvedArtistName,
+ song.resolvedAlbumName
+ )
+}
+
+@BindingAdapter("albumInfo")
+fun TextView.bindAlbumInfo(album: Album?) {
+ if (album == null) {
+ logW("Album was null, not applying info")
+ return
+ }
+
+ text = context.getString(
+ R.string.fmt_two,
+ album.resolvedArtistName,
+ context.getPluralSafe(R.plurals.fmt_song_count, album.songs.size)
+ )
+}
+
+@BindingAdapter("artistInfo")
+fun TextView.bindArtistInfo(artist: Artist?) {
+ if (artist == null) {
+ logW("Artist was null, not applying info")
+ return
+ }
+
+ 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)
)
}
+
+@BindingAdapter("genreInfo")
+fun TextView.bindGenreInfo(genre: Genre?) {
+ if (genre == null) {
+ logW("Genre was null, not applying info")
+ return
+ }
+
+ text = context.getPluralSafe(R.plurals.fmt_song_count, genre.songs.size)
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
index a71e76c19..b91adb12f 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
@@ -24,6 +24,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
+import org.oxycblt.auxio.util.logD
class MusicViewModel : ViewModel() {
private val mLoaderResponse = MutableLiveData(null)
@@ -37,6 +38,7 @@ class MusicViewModel : ViewModel() {
*/
fun loadMusic(context: Context) {
if (mLoaderResponse.value != null || isBusy) {
+ logD("Loader is busy/already completed, not reloading")
return
}
@@ -45,15 +47,14 @@ class MusicViewModel : ViewModel() {
viewModelScope.launch {
val result = MusicStore.initInstance(context)
-
- isBusy = false
mLoaderResponse.value = result
+ isBusy = false
}
}
fun reloadMusic(context: Context) {
+ logD("Reloading music library")
mLoaderResponse.value = null
-
loadMusic(context)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
index 695170e97..caf863c05 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
@@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = -1
+ defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewPlaybackBarBinding.inflate(context.inflater, this, true)
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackButton.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackButton.kt
new file mode 100644
index 000000000..4aea26ac2
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackButton.kt
@@ -0,0 +1,94 @@
+package org.oxycblt.auxio.playback
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.graphics.RectF
+import android.util.AttributeSet
+import androidx.annotation.AttrRes
+import androidx.appcompat.widget.AppCompatImageButton
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.util.getDimenSizeSafe
+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.
+ *
+ * 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) {
+ private val iconSize = context.getDimenSizeSafe(R.dimen.size_playback_icon)
+ private val centerMatrix = Matrix()
+ private val matrixSrc = RectF()
+ private val matrixDst = RectF()
+
+ private val indicatorDrawable = context.getDrawableSafe(R.drawable.ui_indicator)
+ private var hasIndicator = false
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ init {
+ val size = context.getDimenSizeSafe(R.dimen.size_btn_small)
+ minimumWidth = size
+ minimumHeight = size
+ scaleType = ScaleType.MATRIX
+ setBackgroundResource(R.drawable.ui_large_unbounded_ripple)
+
+ val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.PlaybackButton)
+ hasIndicator = styledAttrs.getBoolean(R.styleable.PlaybackButton_hasIndicator, false)
+ styledAttrs.recycle()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+
+ 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())
+ 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.
+ centerMatrix.postTranslate(
+ (measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f
+ )
+ }
+ }
+
+ // Put the indicator right below the icon.
+ val x = (measuredWidth - indicatorDrawable.intrinsicWidth) / 2
+ val y = ((measuredHeight - iconSize) / 2) + iconSize
+
+ indicatorDrawable.bounds.set(
+ x, y, x + indicatorDrawable.intrinsicWidth, y + indicatorDrawable.intrinsicHeight
+ )
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ // I would use onDrawForeground but apparently that isn't called by Lollipop devices.
+ // This is not referenced in the documentation at all.
+ if (hasIndicator && isActivated) {
+ indicatorDrawable.draw(canvas)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt
index a67f375d3..5e665e8f1 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt
@@ -32,7 +32,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.playback.state.LoopMode
-import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
@@ -40,21 +39,24 @@ 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
*/
class PlaybackFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
- private val binding by memberBinding(FragmentPlaybackBinding::inflate) {
- playbackSong.isSelected = false // Clear marquee to prevent a memory leak
- }
+ private var mLastBinding: FragmentPlaybackBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
+ val binding = FragmentPlaybackBinding.inflate(layoutInflater)
val queueItem: MenuItem
+ // See onDestroyView for why we do this
+ mLastBinding = binding
+
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
@@ -93,6 +95,7 @@ class PlaybackFragment : Fragment() {
binding.playbackSong.isSelected = true
binding.playbackSeekBar.onConfirmListener = playbackModel::setPosition
+ // Abuse the play/pause FAB (see style definition for more info)
binding.playbackPlayPause.post {
binding.playbackPlayPause.stateListAnimator = null
}
@@ -101,11 +104,11 @@ class PlaybackFragment : Fragment() {
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (song != null) {
- logD("Updating song display to ${song.name}.")
+ logD("Updating song display to ${song.name}")
binding.song = song
binding.playbackSeekBar.setDuration(song.seconds)
} else {
- logD("No song is being played, leaving.")
+ logD("No song is being played, leaving")
findNavController().navigateUp()
}
}
@@ -126,7 +129,10 @@ class PlaybackFragment : Fragment() {
LoopMode.TRACK -> R.drawable.ic_loop_one
}
- binding.playbackLoop.setImageResource(resId)
+ binding.playbackLoop.apply {
+ isActivated = loopMode != LoopMode.NONE
+ setImageResource(resId)
+ }
}
playbackModel.position.observe(viewLifecycleOwner) { pos ->
@@ -149,11 +155,20 @@ class PlaybackFragment : Fragment() {
}
}
- logD("Fragment Created.")
+ logD("Fragment Created")
return binding.root
}
+ override fun onDestroyView() {
+ super.onDestroyView()
+
+ // playbackSong will leak if we don't disable marquee, keep the binding around
+ // so that we can turn it off when we destroy the view.
+ mLastBinding?.playbackSong?.isSelected = false
+ mLastBinding = null
+ }
+
private fun navigateUp() {
// This is a dumb and fragile hack but this fragment isn't part of the navigation stack
// so we can't really do much
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackLayout.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackLayout.kt
index fb553cb8b..b458553ac 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackLayout.kt
@@ -23,11 +23,13 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenSafe
import org.oxycblt.auxio.util.getDrawableSafe
+import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.pxOfDp
-import org.oxycblt.auxio.util.replaceInsetsCompat
+import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
import org.oxycblt.auxio.util.stateList
import org.oxycblt.auxio.util.systemBarInsetsCompat
import kotlin.math.abs
@@ -46,6 +48,7 @@ import kotlin.math.min
* 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)
*/
class PlaybackLayout @JvmOverloads constructor(
context: Context,
@@ -98,6 +101,7 @@ class PlaybackLayout @JvmOverloads constructor(
private var initMotionX = 0f
private var initMotionY = 0f
private val tRect = Rect()
+
private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
/** See [isDragging] */
@@ -129,6 +133,8 @@ class PlaybackLayout @JvmOverloads constructor(
background = (context.getDrawableSafe(R.drawable.ui_panel_bg) as LayerDrawable).apply {
setDrawableByLayerId(R.id.panel_overlay, playbackContainerBg)
}
+
+ disableDropShadowCompat()
}
playbackBarView = PlaybackBarView(context).apply {
@@ -223,6 +229,8 @@ class PlaybackLayout @JvmOverloads constructor(
}
private fun applyState(state: PanelState) {
+ logD("Applying panel state $state")
+
// Dragging events are really complex and we don't want to mess up the state
// while we are in one.
if (state == panelState || panelState == PanelState.DRAGGING) {
@@ -355,10 +363,8 @@ class PlaybackLayout @JvmOverloads constructor(
// bottom navigation is consumed by a bar. To fix this, we modify the bottom insets
// to reflect the presence of the panel [at least in it's collapsed state]
playbackContainerView.dispatchApplyWindowInsets(insets)
-
lastInsets = insets
applyContentWindowInsets()
-
return insets
}
@@ -368,7 +374,6 @@ class PlaybackLayout @JvmOverloads constructor(
*/
private fun applyContentWindowInsets() {
val insets = lastInsets
-
if (insets != null) {
contentView.dispatchApplyWindowInsets(adjustInsets(insets))
}
@@ -384,8 +389,9 @@ class PlaybackLayout @JvmOverloads constructor(
val bars = insets.systemBarInsetsCompat
val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)
-
- return insets.replaceInsetsCompat(bars.left, bars.top, bars.right, adjustedBottomInset)
+ return insets.replaceSystemBarInsetsCompat(
+ bars.left, bars.top, bars.right, adjustedBottomInset
+ )
}
override fun onSaveInstanceState(): Parcelable = Bundle().apply {
@@ -584,6 +590,8 @@ class PlaybackLayout @JvmOverloads constructor(
(computePanelTopPosition(0f) - topPosition).toFloat() / panelRange
private fun smoothSlideTo(offset: Float) {
+ logD("Smooth sliding to $offset")
+
val okay = dragHelper.smoothSlideViewTo(
playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset)
)
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSeekBar.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSeekBar.kt
index f2e517cd0..6b7002141 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSeekBar.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSeekBar.kt
@@ -29,19 +29,21 @@ import org.oxycblt.auxio.databinding.ViewSeekBarBinding
import org.oxycblt.auxio.music.toDuration
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.inflater
+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]
* @author OxygenCobalt
*/
@SuppressLint("RestrictedApi")
class PlaybackSeekBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleRes: Int = -1
+ 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
@@ -73,6 +75,7 @@ class PlaybackSeekBar @JvmOverloads constructor(
// - The duration of the song was so low as to be rounded to zero when converted
// to seconds.
// In either of these cases, the seekbar is more or less useless. Disable it.
+ logD("Duration is 0, entering disabled state")
binding.seekBar.apply {
valueTo = 1f
isEnabled = false
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
index a741adafe..1fcb41a35 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
@@ -111,7 +111,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/
fun playAlbum(album: Album, shuffled: Boolean) {
if (album.songs.isEmpty()) {
- logE("Album is empty, Not playing.")
+ logE("Album is empty, Not playing")
return
}
@@ -125,7 +125,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/
fun playArtist(artist: Artist, shuffled: Boolean) {
if (artist.songs.isEmpty()) {
- logE("Artist is empty, Not playing.")
+ logE("Artist is empty, Not playing")
return
}
@@ -139,7 +139,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/
fun playGenre(genre: Genre, shuffled: Boolean) {
if (genre.songs.isEmpty()) {
- logE("Genre is empty, Not playing.")
+ logE("Genre is empty, Not playing")
return
}
@@ -156,7 +156,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
if (playbackManager.isRestored && MusicStore.loaded()) {
playWithUriInternal(uri, context)
} else {
- logD("Cant play this URI right now, waiting...")
+ logD("Cant play this URI right now, waiting")
mIntentUri = uri
}
@@ -213,12 +213,10 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* [apply] is called just before the change is committed so that the adapter can be updated.
*/
fun removeQueueDataItem(adapterIndex: Int, apply: () -> Unit) {
- val adjusted = adapterIndex + (playbackManager.queue.size - mNextUp.value!!.size)
- logD("$adjusted")
-
- if (adjusted in playbackManager.queue.indices) {
+ val index = adapterIndex + (playbackManager.queue.size - mNextUp.value!!.size)
+ if (index in playbackManager.queue.indices) {
apply()
- playbackManager.removeQueueItem(adjusted)
+ playbackManager.removeQueueItem(index)
}
}
/**
@@ -227,10 +225,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int, apply: () -> Unit): Boolean {
val delta = (playbackManager.queue.size - mNextUp.value!!.size)
-
val from = adapterFrom + delta
val to = adapterTo + delta
-
if (from in playbackManager.queue.indices && to in playbackManager.queue.indices) {
apply()
playbackManager.moveQueueItems(from, to)
@@ -332,7 +328,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* [PlaybackStateManager] instance.
*/
private fun restorePlaybackState() {
- logD("Attempting to restore playback state.")
+ logD("Attempting to restore playback state")
onSongUpdate(playbackManager.song)
onPositionUpdate(playbackManager.position)
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
index 4d1984a3d..7c362c5fb 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
@@ -30,13 +30,14 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.music.ActionHeader
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder
+import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.stateList
@@ -49,7 +50,7 @@ import org.oxycblt.auxio.util.stateList
class QueueAdapter(
private val touchHelper: ItemTouchHelper
) : RecyclerView.Adapter() {
- private var data = mutableListOf()
+ private var data = mutableListOf- ()
private var listDiffer = AsyncListDiffer(this, DiffCallback())
override fun getItemCount(): Int = data.size
@@ -69,11 +70,9 @@ class QueueAdapter(
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.")
+ else -> error("Invalid ViewHolder item type $viewType")
}
}
@@ -82,8 +81,7 @@ class QueueAdapter(
is Song -> (holder as QueueSongViewHolder).bind(item)
is Header -> (holder as HeaderViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
-
- else -> logE("Bad data given to QueueAdapter.")
+ else -> logE("Bad data given to QueueAdapter")
}
}
@@ -91,10 +89,9 @@ class QueueAdapter(
* Submit data using [AsyncListDiffer].
* **Only use this if you have no idea what changes occurred to the data**
*/
- fun submitList(newData: MutableList) {
+ fun submitList(newData: MutableList
- ) {
if (data != newData) {
data = newData
-
listDiffer.submitList(newData)
}
}
@@ -132,6 +129,8 @@ class QueueAdapter(
).apply {
fillColor = (binding.body.background as ColorDrawable).color.stateList
}
+
+ binding.root.disableDropShadowCompat()
}
@SuppressLint("ClickableViewAccessibility")
@@ -143,14 +142,19 @@ class QueueAdapter(
binding.songName.requestLayout()
binding.songInfo.requestLayout()
+ // Roll our own drag handlers as the default ones suck
binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick()
-
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(this)
true
} else false
}
+
+ binding.body.setOnLongClickListener {
+ touchHelper.startDrag(this)
+ true
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt
index b18bbcca3..a010fadf8 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt
@@ -27,6 +27,7 @@ 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
@@ -86,13 +87,13 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
// themselves when being dragged. Too bad google's implementation of this doesn't even
// work! To emulate it on my own, I check if this child is in a drag state and then animate
// an elevation change.
-
val holder = viewHolder as QueueAdapter.QueueSongViewHolder
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
+ logD("Lifting queue item")
+
val bg = holder.bodyView.background as MaterialShapeDrawable
val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_small)
-
holder.itemView.animate()
.translationZ(elevation)
.setDuration(100)
@@ -127,9 +128,10 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
// When an elevated item is cleared, we reset the elevation using another animation.
val holder = viewHolder as QueueAdapter.QueueSongViewHolder
- if (holder.itemView.translationZ != 0.0f) {
- val bg = holder.bodyView.background as MaterialShapeDrawable
+ if (holder.itemView.translationZ != 0f) {
+ logD("Dropping queue item")
+ val bg = holder.bodyView.background as MaterialShapeDrawable
holder.itemView.animate()
.translationZ(0.0f)
.setDuration(100)
@@ -163,6 +165,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
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt
index cc9d2bb27..0337c54c7 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt
@@ -28,6 +28,7 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
+import org.oxycblt.auxio.util.logD
/**
* A [Fragment] that shows the queue and enables editing as well.
@@ -42,15 +43,13 @@ class QueueFragment : Fragment() {
savedInstanceState: Bundle?
): View {
val binding = FragmentQueueBinding.inflate(inflater)
-
val callback = QueueDragCallback(playbackModel)
-
val helper = ItemTouchHelper(callback)
val queueAdapter = QueueAdapter(helper)
- var lastShuffle = playbackModel.isShuffling.value
-
callback.addQueueAdapter(queueAdapter)
+ var lastShuffle = playbackModel.isShuffling.value
+
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
@@ -77,9 +76,11 @@ class QueueFragment : Fragment() {
}
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
+ // Try to prevent the queue adapter from going spastic during reshuffle events
+ // by just scrolling back to the top.
if (isShuffling != lastShuffle) {
+ logD("Reshuffle event, scrolling to top")
lastShuffle = isShuffling
-
binding.queueRecycler.scrollToPosition(0)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt
index 5ed2a2b35..3450096f3 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt
@@ -48,10 +48,10 @@ class PlaybackStateDatabase(context: Context) :
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
private fun nuke(db: SQLiteDatabase) {
+ logD("Nuking database")
db.apply {
execSQL("DROP TABLE IF EXISTS $TABLE_NAME_STATE")
execSQL("DROP TABLE IF EXISTS $TABLE_NAME_QUEUE")
-
onCreate(this)
}
}
@@ -103,34 +103,6 @@ class PlaybackStateDatabase(context: Context) :
// --- INTERFACE FUNCTIONS ---
- /**
- * Clear the previously written [SavedState] and write a new one.
- */
- fun writeState(state: SavedState) {
- assertBackgroundThread()
-
- writableDatabase.transaction {
- delete(TABLE_NAME_STATE, null, null)
-
- this@PlaybackStateDatabase.logD("Wiped state db.")
-
- val stateData = ContentValues(10).apply {
- put(StateColumns.COLUMN_ID, 0)
- put(StateColumns.COLUMN_SONG_HASH, state.song?.id)
- put(StateColumns.COLUMN_POSITION, state.position)
- put(StateColumns.COLUMN_PARENT_HASH, state.parent?.id)
- put(StateColumns.COLUMN_QUEUE_INDEX, state.queueIndex)
- put(StateColumns.COLUMN_PLAYBACK_MODE, state.playbackMode.toInt())
- put(StateColumns.COLUMN_IS_SHUFFLING, state.isShuffling)
- put(StateColumns.COLUMN_LOOP_MODE, state.loopMode.toInt())
- }
-
- insert(TABLE_NAME_STATE, null, stateData)
- }
-
- logD("Wrote state to database.")
- }
-
/**
* Read the stored [SavedState] from the database, if there is one.
* @param musicStore Required to transform database songs/parents into actual instances
@@ -178,11 +150,69 @@ class PlaybackStateDatabase(context: Context) :
isShuffling = cursor.getInt(shuffleIndex) == 1,
loopMode = LoopMode.fromInt(cursor.getInt(loopModeIndex)) ?: LoopMode.NONE,
)
+
+ logD("Successfully read playback state: $state")
}
return state
}
+ /**
+ * Clear the previously written [SavedState] and write a new one.
+ */
+ fun writeState(state: SavedState) {
+ assertBackgroundThread()
+
+ writableDatabase.transaction {
+ delete(TABLE_NAME_STATE, null, null)
+
+ this@PlaybackStateDatabase.logD("Wiped state db")
+
+ val stateData = ContentValues(10).apply {
+ put(StateColumns.COLUMN_ID, 0)
+ put(StateColumns.COLUMN_SONG_HASH, state.song?.id)
+ put(StateColumns.COLUMN_POSITION, state.position)
+ put(StateColumns.COLUMN_PARENT_HASH, state.parent?.id)
+ put(StateColumns.COLUMN_QUEUE_INDEX, state.queueIndex)
+ put(StateColumns.COLUMN_PLAYBACK_MODE, state.playbackMode.toInt())
+ put(StateColumns.COLUMN_IS_SHUFFLING, state.isShuffling)
+ put(StateColumns.COLUMN_LOOP_MODE, state.loopMode.toInt())
+ }
+
+ insert(TABLE_NAME_STATE, null, stateData)
+ }
+
+ logD("Wrote state to database")
+ }
+
+ /**
+ * Read a list of queue items from this database.
+ * @param musicStore Required to transform database songs into actual song instances
+ */
+ fun readQueue(musicStore: MusicStore): MutableList {
+ assertBackgroundThread()
+
+ val queue = mutableListOf()
+
+ readableDatabase.queryAll(TABLE_NAME_QUEUE) { cursor ->
+ if (cursor.count == 0) return@queryAll
+
+ val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_HASH)
+ val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_HASH)
+
+ while (cursor.moveToNext()) {
+ musicStore.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))
+ ?.let { song ->
+ queue.add(song)
+ }
+ }
+ }
+
+ logD("Successfully read queue of ${queue.size} songs")
+
+ return queue
+ }
+
/**
* Write a queue to the database.
*/
@@ -190,12 +220,11 @@ class PlaybackStateDatabase(context: Context) :
assertBackgroundThread()
val database = writableDatabase
-
database.transaction {
delete(TABLE_NAME_QUEUE, null, null)
}
- logD("Wiped queue db.")
+ logD("Wiped queue db")
writeQueueBatch(queue, queue.size)
}
@@ -232,32 +261,6 @@ class PlaybackStateDatabase(context: Context) :
}
}
- /**
- * Read a list of queue items from this database.
- * @param musicStore Required to transform database songs into actual song instances
- */
- fun readQueue(musicStore: MusicStore): MutableList {
- assertBackgroundThread()
-
- val queue = mutableListOf()
-
- readableDatabase.queryAll(TABLE_NAME_QUEUE) { cursor ->
- if (cursor.count == 0) return@queryAll
-
- val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_HASH)
- val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_HASH)
-
- while (cursor.moveToNext()) {
- musicStore.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))
- ?.let { song ->
- queue.add(song)
- }
- }
- }
-
- return queue
- }
-
data class SavedState(
val song: Song?,
val position: Long,
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
index 0fe3b6348..0a8e1d7c9 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
@@ -40,6 +40,8 @@ import org.oxycblt.auxio.util.logE
*
* All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt
+ *
+ * TODO: Rework this to possibly handle gapless playback and more refined queue management.
*/
class PlaybackStateManager private constructor() {
// Playback
@@ -151,17 +153,8 @@ class PlaybackStateManager private constructor() {
}
PlaybackMode.IN_GENRE -> {
- val genre = song.genre
-
- // Don't do this if the genre is null
- if (genre != null) {
- mParent = genre
- mQueue = genre.songs.toMutableList()
- } else {
- playSong(song, PlaybackMode.ALL_SONGS)
-
- return
- }
+ mParent = song.genre
+ mQueue = song.genre.songs.toMutableList()
}
PlaybackMode.IN_ARTIST -> {
@@ -233,7 +226,6 @@ class PlaybackStateManager private constructor() {
private fun updatePlayback(song: Song, shouldPlay: Boolean = true) {
mSong = song
mPosition = 0
-
setPlaying(shouldPlay)
}
@@ -280,18 +272,14 @@ class PlaybackStateManager private constructor() {
* Remove a queue item at [index]. Will ignore invalid indexes.
*/
fun removeQueueItem(index: Int): Boolean {
- logD("Removing item ${mQueue[index].name}.")
-
if (index > mQueue.size || index < 0) {
- logE("Index is out of bounds, did not remove queue item.")
-
+ logE("Index is out of bounds, did not remove queue item")
return false
}
+ logD("Removing item ${mQueue[index].name}")
mQueue.removeAt(index)
-
pushQueueUpdate()
-
return true
}
@@ -301,15 +289,12 @@ class PlaybackStateManager private constructor() {
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")
-
return false
}
- val item = mQueue.removeAt(from)
- mQueue.add(to, item)
-
+ logD("Moving item $from to position $to")
+ mQueue.add(to, mQueue.removeAt(from))
pushQueueUpdate()
-
return true
}
@@ -463,7 +448,6 @@ class PlaybackStateManager private constructor() {
*/
fun seekTo(position: Long) {
mPosition = position
-
callbacks.forEach { it.onSeek(position) }
}
@@ -511,7 +495,7 @@ class PlaybackStateManager private constructor() {
* @param context [Context] required
*/
suspend fun saveStateToDatabase(context: Context) {
- logD("Saving state to DB.")
+ logD("Saving state to DB")
// Pack the entire state and save it to the database.
withContext(Dispatchers.IO) {
@@ -519,8 +503,6 @@ class PlaybackStateManager private constructor() {
val database = PlaybackStateDatabase.getInstance(context)
- logD("$mPlaybackMode")
-
database.writeState(
PlaybackStateDatabase.SavedState(
mSong, mPosition, mParent, mIndex,
@@ -531,7 +513,7 @@ class PlaybackStateManager private constructor() {
database.writeQueue(mQueue)
this@PlaybackStateManager.logD(
- "Save finished in ${System.currentTimeMillis() - start}ms"
+ "State save completed successfully in ${System.currentTimeMillis() - start}ms"
)
}
}
@@ -541,19 +523,16 @@ class PlaybackStateManager private constructor() {
* @param context [Context] required.
*/
suspend fun restoreFromDatabase(context: Context) {
- logD("Getting state from DB.")
+ logD("Getting state from DB")
val musicStore = MusicStore.maybeGetInstance() ?: return
-
val start: Long
val playbackState: PlaybackStateDatabase.SavedState?
val queue: MutableList
withContext(Dispatchers.IO) {
start = System.currentTimeMillis()
-
val database = PlaybackStateDatabase.getInstance(context)
-
playbackState = database.readState(musicStore)
queue = database.readQueue(musicStore)
}
@@ -561,15 +540,13 @@ class PlaybackStateManager private constructor() {
// Get off the IO coroutine since it will cause LiveData updates to throw an exception
if (playbackState != null) {
- logD("Found playback state $playbackState")
-
unpackFromPlaybackState(playbackState)
unpackQueue(queue)
doParentSanityCheck()
doIndexSanityCheck()
}
- logD("Restore finished in ${System.currentTimeMillis() - start}ms")
+ logD("State load completed successfully in ${System.currentTimeMillis() - start}ms")
markRestored()
}
@@ -595,14 +572,6 @@ class PlaybackStateManager private constructor() {
private fun unpackQueue(queue: MutableList) {
mQueue = queue
-
- // Sanity check: Ensure that the
- mSong?.let { song ->
- while (mQueue.getOrNull(mIndex) != song) {
- mIndex--
- }
- }
-
pushQueueUpdate()
}
@@ -612,7 +581,7 @@ class PlaybackStateManager private constructor() {
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.")
+ logD("Parent lost, attempting restore")
mParent = when (mPlaybackMode) {
PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album
@@ -627,12 +596,14 @@ class PlaybackStateManager private constructor() {
* Do a sanity check to make sure that the index lines up with the current song.
*/
private fun doIndexSanityCheck() {
- if (mSong != null && mSong != mQueue[mIndex]) {
+ // Be careful with how we handle the queue since a possible index de-sync
+ // could easily result in an OOB crash.
+ if (mSong != null && mSong != mQueue.getOrNull(mIndex)) {
val correctedIndex = mQueue.wobblyIndexOfFirst(mIndex, mSong)
-
if (correctedIndex > -1) {
logD("Correcting malformed index to $correctedIndex")
mIndex = correctedIndex
+ pushQueueUpdate()
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt
index ebb31f150..4380eaba8 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt
@@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.system
import android.content.Context
import android.media.AudioManager
+import android.os.Build
import androidx.core.math.MathUtils
import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat
@@ -32,6 +33,7 @@ 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
/**
@@ -84,16 +86,19 @@ class AudioReactor(
* 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
*/
fun applyReplayGain(metadata: Metadata?) {
if (metadata == null) {
- logD("No metadata.")
+ logW("No metadata could be extracted from this track")
volume = 1f
return
}
@@ -101,7 +106,7 @@ class AudioReactor(
// ReplayGain is configurable, so determine what to do based off of the mode.
val useAlbumGain: (Gain) -> Boolean = when (settingsManager.replayGainMode) {
ReplayGainMode.OFF -> {
- logD("ReplayGain is off.")
+ logD("ReplayGain is off")
volume = 1f
return
}
@@ -127,14 +132,15 @@ class AudioReactor(
playbackManager.song?.album == playbackManager.parent
}
}
+
val gain = parseReplayGain(metadata)
val adjust = if (gain != null) {
if (useAlbumGain(gain)) {
- logD("Using album gain.")
+ logD("Using album gain")
gain.album
} else {
- logD("Using track gain.")
+ logD("Using track gain")
gain.track
}
} else {
@@ -144,8 +150,6 @@ class AudioReactor(
// Final adjustment along the volume curve.
// Ensure this is clamped to 0 or 1 so that it can be used as a volume.
- // While positive ReplayGain values *could* be theoretically added, it's such
- // a niche use-case that to be worth the effort required. Maybe if someone requests it.
volume = MathUtils.clamp((10f.pow((adjust / 20f))), 0f, 1f)
}
@@ -177,7 +181,7 @@ class AudioReactor(
}
if (key in REPLAY_GAIN_TAGS) {
- tags.add(GainTag(key!!, parseReplayGainFloat(value)))
+ tags.add(GainTag(requireNotNull(key), parseReplayGainFloat(value)))
}
}
@@ -233,7 +237,7 @@ class AudioReactor(
// --- INTERNAL AUDIO FOCUS ---
override fun onAudioFocusChange(focusChange: Int) {
- if (!settingsManager.doAudioFocus) {
+ if (!settingsManager.doAudioFocus && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
// Don't do audio focus if its not enabled
return
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
index 0173fc7b5..8cde108b2 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
@@ -5,6 +5,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
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
@@ -20,6 +21,7 @@ import androidx.core.content.ContextCompat
class MediaButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
+ logD("Received external media button intent")
intent.component = ComponentName(context, PlaybackService::class.java)
ContextCompat.startForegroundService(context, intent)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt
index d56a2663d..d0fca446f 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt
@@ -51,6 +51,7 @@ class PlaybackNotification private constructor(
setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false)
setSilent(true)
+ setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
setContentIntent(context.newMainIntent())
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@@ -142,7 +143,7 @@ class PlaybackNotification private constructor(
loopMode: LoopMode
): NotificationCompat.Action {
val drawableRes = when (loopMode) {
- LoopMode.NONE -> R.drawable.ic_loop_off
+ LoopMode.NONE -> R.drawable.ic_remote_loop_off
LoopMode.ALL -> R.drawable.ic_loop
LoopMode.TRACK -> R.drawable.ic_loop_one
}
@@ -154,7 +155,7 @@ class PlaybackNotification private constructor(
context: Context,
isShuffled: Boolean
): NotificationCompat.Action {
- val drawableRes = if (isShuffled) R.drawable.ic_shuffle else R.drawable.ic_shuffle_off
+ val drawableRes = if (isShuffled) R.drawable.ic_shuffle else R.drawable.ic_remote_shuffle_off
return buildAction(context, PlaybackService.ACTION_SHUFFLE, drawableRes)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
index 15b76ed4f..03cac948b 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
@@ -180,7 +180,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
settingsManager.addCallback(this)
- logD("Service created.")
+ logD("Service created")
}
override fun onDestroy() {
@@ -207,7 +207,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
serviceJob.cancel()
}
- logD("Service destroyed.")
+ logD("Service destroyed")
}
// --- PLAYER EVENT LISTENER OVERRIDES ---
@@ -260,22 +260,21 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onSongUpdate(song: Song?) {
if (song != null) {
+ logD("Setting player to ${song.name}")
player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare()
-
notification.setMetadata(song, ::startForegroundOrNotify)
-
return
}
// Clear if there's nothing to play.
+ logD("Nothing playing, stopping playback")
player.stop()
stopForegroundAndNotification()
}
override fun onParentUpdate(parent: MusicParent?) {
notification.setParent(parent)
-
startForegroundOrNotify()
}
@@ -295,7 +294,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onLoopUpdate(loopMode: LoopMode) {
if (!settingsManager.useAltNotifAction) {
notification.setLoop(loopMode)
-
startForegroundOrNotify()
}
}
@@ -303,7 +301,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onShuffleUpdate(isShuffling: Boolean) {
if (settingsManager.useAltNotifAction) {
notification.setShuffle(isShuffling)
-
startForegroundOrNotify()
}
}
@@ -334,7 +331,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onShowCoverUpdate(showCovers: Boolean) {
playbackManager.song?.let { song ->
connector.onSongUpdate(song)
-
notification.setMetadata(song, ::startForegroundOrNotify)
}
}
@@ -443,7 +439,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
private fun stopForegroundAndNotification() {
stopForeground(true)
notificationManager.cancel(PlaybackNotification.NOTIFICATION_ID)
-
isForeground = false
}
@@ -451,25 +446,36 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
* A [BroadcastReceiver] for receiving general playback events from the system.
*/
private inner class PlaybackReceiver : BroadcastReceiver() {
+ private var initialHeadsetPlugEventHandled = false
+
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
// --- SYSTEM EVENTS ---
+
+ // Technically the MediaSession seems to handle bluetooth events on their
+ // own, but keep this around as a fallback in the case that the former fails
+ // for whatever reason.
AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
when (intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)) {
- AudioManager.SCO_AUDIO_STATE_CONNECTED -> resumeFromPlug()
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> pauseFromPlug()
+ AudioManager.SCO_AUDIO_STATE_CONNECTED -> maybeResumeFromPlug()
}
}
- AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
-
+ // MediaSession does not handle wired headsets for some reason, so also include
+ // this. Gotta love Android having two actions for more or less the same thing.
AudioManager.ACTION_HEADSET_PLUG -> {
when (intent.getIntExtra("state", -1)) {
- 0 -> resumeFromPlug()
- 1 -> pauseFromPlug()
+ 0 -> pauseFromPlug()
+ 1 -> maybeResumeFromPlug()
}
+
+ initialHeadsetPlugEventHandled = true
}
+ // I have never seen this ever happen but it might be useful
+ AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
+
// --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> playbackManager.setPlaying(
!playbackManager.isPlaying
@@ -494,25 +500,35 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update()
}
}
- }
- /**
- * Resume from a headset plug event, as long as its allowed.
- */
- private fun resumeFromPlug() {
- if (playbackManager.song != null && settingsManager.doPlugMgt) {
- logD("Device connected, resuming...")
- playbackManager.setPlaying(true)
+ /**
+ * 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.
+ */
+ private fun maybeResumeFromPlug() {
+ if (playbackManager.song != null &&
+ settingsManager.headsetAutoplay &&
+ initialHeadsetPlugEventHandled
+ ) {
+ logD("Device connected, resuming")
+ playbackManager.setPlaying(true)
+ }
}
- }
- /**
- * Pause from a headset plug, as long as its allowed.
- */
- private fun pauseFromPlug() {
- if (playbackManager.song != null && settingsManager.doPlugMgt) {
- logD("Device disconnected, pausing...")
- playbackManager.setPlaying(false)
+ /**
+ * Pause from a headset plug.
+ */
+ private fun pauseFromPlug() {
+ if (playbackManager.song != null) {
+ logD("Device disconnected, pausing")
+ playbackManager.setPlaying(false)
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt
index 336667cf3..0c211c683 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt
@@ -29,6 +29,7 @@ import org.oxycblt.auxio.coil.loadBitmap
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager
+import org.oxycblt.auxio.util.logD
/**
* Nightmarish class that coordinates communication between [MediaSessionCompat], [Player],
@@ -158,6 +159,8 @@ class PlaybackSessionConnector(
// --- MISC ---
private fun invalidateSessionState() {
+ logD("Updating media session state")
+
// Position updates arrive faster when you upload STATE_PAUSED for some insane reason.
val state = PlaybackStateCompat.Builder()
.setActions(ACTIONS)
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
index 8104c55a0..3a7059520 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
@@ -24,9 +24,9 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.AlbumViewHolder
@@ -43,7 +43,7 @@ import org.oxycblt.auxio.ui.SongViewHolder
class SearchAdapter(
private val doOnClick: (data: Music) -> Unit,
private val doOnLongClick: (view: View, data: Music) -> Unit
-) : ListAdapter(DiffCallback()) {
+) : ListAdapter
- (DiffCallback
- ()) {
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
@@ -52,7 +52,6 @@ class SearchAdapter(
is Album -> AlbumViewHolder.ITEM_TYPE
is Song -> SongViewHolder.ITEM_TYPE
is Header -> HeaderViewHolder.ITEM_TYPE
-
else -> -1
}
}
@@ -77,7 +76,7 @@ class SearchAdapter(
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
- else -> error("Invalid ViewHolder item type.")
+ else -> error("Invalid ViewHolder item type")
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
index b39647326..f83e3b566 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
@@ -114,7 +114,6 @@ class SearchFragment : Fragment() {
if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown
requestFocus()
-
postDelayed(200) {
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
@@ -162,7 +161,7 @@ class SearchFragment : Fragment() {
imm.hide()
}
- logD("Fragment created.")
+ logD("Fragment created")
return binding.root
}
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
index 4f9604edc..2f5445651 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
@@ -25,14 +25,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
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
/**
@@ -40,13 +41,13 @@ import java.text.Normalizer
* @author OxygenCobalt
*/
class SearchViewModel : ViewModel() {
- private val mSearchResults = MutableLiveData(listOf())
+ private val mSearchResults = MutableLiveData(listOf
- ())
private var mIsNavigating = false
private var mFilterMode: DisplayMode? = null
private var mLastQuery = ""
/** Current search results from the last [search] call. */
- val searchResults: LiveData
> get() = mSearchResults
+ val searchResults: LiveData> get() = mSearchResults
val isNavigating: Boolean get() = mIsNavigating
val filterMode: DisplayMode? get() = mFilterMode
@@ -70,14 +71,17 @@ class SearchViewModel : ViewModel() {
mLastQuery = query
if (query.isEmpty() || musicStore == null) {
+ logD("No music/query, ignoring search")
mSearchResults.value = listOf()
return
}
- // Searching can be quite expensive, so hop on a co-routine
+ logD("Performing search for $query")
+
+ // Searching can be quite expensive, so get on a co-routine
viewModelScope.launch {
val sort = Sort.ByName(true)
- val results = mutableListOf()
+ val results = mutableListOf- ()
// Note: a filter mode of null means to not filter at all.
@@ -127,6 +131,8 @@ class SearchViewModel : ViewModel() {
else -> null
}
+ logD("Updating filter mode to $mFilterMode")
+
settingsManager.searchFilterMode = mFilterMode
search(mLastQuery)
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
index 64cb1bf26..f24110e70 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
@@ -74,7 +74,7 @@ class AboutFragment : Fragment() {
)
}
- logD("Dialog created.")
+ logD("Dialog created")
return binding.root
}
@@ -83,6 +83,8 @@ class AboutFragment : Fragment() {
* 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
)
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt
index 7c678052e..8ee87c17e 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt
@@ -22,8 +22,7 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import org.oxycblt.auxio.accent.Accent
-// A couple of utils for migrating from old settings values to the new
-// formats used in 1.3.2 & 1.4.0
+// A couple of utils for migrating from old settings values to the new formats
fun handleAccentCompat(prefs: SharedPreferences): Accent {
if (prefs.contains(OldKeys.KEY_ACCENT2)) {
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
index e20a184fb..525d5654d 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
@@ -31,7 +31,7 @@ import androidx.preference.children
import androidx.recyclerview.widget.RecyclerView
import coil.Coil
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.accent.AccentDialog
+import org.oxycblt.auxio.accent.AccentCustomizeDialog
import org.oxycblt.auxio.excluded.ExcludedDialog
import org.oxycblt.auxio.home.tabs.TabCustomizeDialog
import org.oxycblt.auxio.playback.PlaybackViewModel
@@ -68,7 +68,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
}
}
- logD("Fragment created.")
+ logD("Fragment created")
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@@ -119,7 +119,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
SettingsManager.KEY_ACCENT -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
- AccentDialog().show(childFragmentManager, AccentDialog.TAG)
+ AccentCustomizeDialog().show(childFragmentManager, AccentCustomizeDialog.TAG)
true
}
@@ -182,7 +182,6 @@ class SettingsListFragment : PreferenceFragmentCompat() {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> R.drawable.ic_auto
AppCompatDelegate.MODE_NIGHT_NO -> R.drawable.ic_day
AppCompatDelegate.MODE_NIGHT_YES -> R.drawable.ic_night
-
else -> R.drawable.ic_auto
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt
index c2e3c311b..47678c1b6 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt
@@ -37,27 +37,27 @@ import org.oxycblt.auxio.ui.Sort
class SettingsManager private constructor(context: Context) :
SharedPreferences.OnSharedPreferenceChangeListener {
- private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
+ private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
init {
- sharedPrefs.registerOnSharedPreferenceChangeListener(this)
+ prefs.registerOnSharedPreferenceChangeListener(this)
}
// --- VALUES ---
/** The current theme */
val theme: Int
- get() = sharedPrefs.getInt(KEY_THEME, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ get() = prefs.getInt(KEY_THEME, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
/** Whether the dark theme should be black or not */
val useBlackTheme: Boolean
- get() = sharedPrefs.getBoolean(KEY_BLACK_THEME, false)
+ get() = prefs.getBoolean(KEY_BLACK_THEME, false)
/** The current accent. */
var accent: Accent
- get() = handleAccentCompat(sharedPrefs)
+ get() = handleAccentCompat(prefs)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_ACCENT, value.index)
apply()
}
@@ -68,14 +68,14 @@ class SettingsManager private constructor(context: Context) :
* False if loop, true if shuffle.
*/
val useAltNotifAction: Boolean
- get() = sharedPrefs.getBoolean(KEY_USE_ALT_NOTIFICATION_ACTION, false)
+ get() = prefs.getBoolean(KEY_USE_ALT_NOTIFICATION_ACTION, false)
/** The current library tabs preferred by the user. */
var libTabs: Array
- get() = Tab.fromSequence(sharedPrefs.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) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_LIB_TABS, Tab.toSequence(value))
apply()
}
@@ -83,51 +83,51 @@ class SettingsManager private constructor(context: Context) :
/** Whether to load embedded covers */
val showCovers: Boolean
- get() = sharedPrefs.getBoolean(KEY_SHOW_COVERS, true)
+ get() = prefs.getBoolean(KEY_SHOW_COVERS, true)
/** Whether to ignore MediaStore covers */
val useQualityCovers: Boolean
- get() = sharedPrefs.getBoolean(KEY_QUALITY_COVERS, false)
+ get() = prefs.getBoolean(KEY_QUALITY_COVERS, false)
/** Whether to round album covers */
val roundCovers: Boolean
- get() = sharedPrefs.getBoolean(KEY_ROUND_COVERS, false)
+ get() = prefs.getBoolean(KEY_ROUND_COVERS, false)
/** Whether to do Audio focus. */
val doAudioFocus: Boolean
- get() = sharedPrefs.getBoolean(KEY_AUDIO_FOCUS, true)
+ get() = prefs.getBoolean(KEY_AUDIO_FOCUS, true)
- /** Whether to resume/stop playback when a headset is connected/disconnected. */
- val doPlugMgt: Boolean
- get() = sharedPrefs.getBoolean(KEY_PLUG_MANAGEMENT, true)
+ /** Whether to resume playback when a headset is connected (may not work well in all cases) */
+ val headsetAutoplay: Boolean
+ get() = prefs.getBoolean(KEY_HEADSET_AUTOPLAY, false)
/** The current ReplayGain configuration */
val replayGainMode: ReplayGainMode
- get() = ReplayGainMode.fromInt(sharedPrefs.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(sharedPrefs.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. */
val keepShuffle: Boolean
- get() = sharedPrefs.getBoolean(KEY_KEEP_SHUFFLE, true)
+ get() = prefs.getBoolean(KEY_KEEP_SHUFFLE, true)
/** Whether to rewind when the back button is pressed. */
val rewindWithPrev: Boolean
- get() = sharedPrefs.getBoolean(KEY_PREV_REWIND, true)
+ get() = prefs.getBoolean(KEY_PREV_REWIND, true)
/** Whether [org.oxycblt.auxio.playback.state.LoopMode.TRACK] should pause when the track repeats */
val pauseOnLoop: Boolean
- get() = sharedPrefs.getBoolean(KEY_LOOP_PAUSE, false)
+ get() = prefs.getBoolean(KEY_LOOP_PAUSE, false)
/** The current filter mode of the search tab */
var searchFilterMode: DisplayMode?
- get() = DisplayMode.fromFilterInt(sharedPrefs.getInt(KEY_SEARCH_FILTER_MODE, Int.MIN_VALUE))
+ get() = DisplayMode.fromFilterInt(prefs.getInt(KEY_SEARCH_FILTER_MODE, Int.MIN_VALUE))
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_SEARCH_FILTER_MODE, DisplayMode.toFilterInt(value))
apply()
}
@@ -135,10 +135,10 @@ class SettingsManager private constructor(context: Context) :
/** The song sort mode on HomeFragment **/
var libSongSort: Sort
- get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE))
+ get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_LIB_SONGS_SORT, value.toInt())
apply()
}
@@ -146,10 +146,10 @@ class SettingsManager private constructor(context: Context) :
/** The album sort mode on HomeFragment **/
var libAlbumSort: Sort
- get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE))
+ get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_LIB_ALBUMS_SORT, value.toInt())
apply()
}
@@ -157,10 +157,10 @@ class SettingsManager private constructor(context: Context) :
/** The artist sort mode on HomeFragment **/
var libArtistSort: Sort
- get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE))
+ get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_LIB_ARTISTS_SORT, value.toInt())
apply()
}
@@ -168,10 +168,10 @@ class SettingsManager private constructor(context: Context) :
/** The genre sort mode on HomeFragment **/
var libGenreSort: Sort
- get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE))
+ get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_LIB_GENRES_SORT, value.toInt())
apply()
}
@@ -179,10 +179,10 @@ class SettingsManager private constructor(context: Context) :
/** The detail album sort mode **/
var detailAlbumSort: Sort
- get() = Sort.fromInt(sharedPrefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE))
+ get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_DETAIL_ALBUM_SORT, value.toInt())
apply()
}
@@ -190,10 +190,10 @@ class SettingsManager private constructor(context: Context) :
/** The detail artist sort mode **/
var detailArtistSort: Sort
- get() = Sort.fromInt(sharedPrefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE))
+ get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE))
?: Sort.ByYear(false)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_DETAIL_ARTIST_SORT, value.toInt())
apply()
}
@@ -201,10 +201,10 @@ class SettingsManager private constructor(context: Context) :
/** The detail genre sort mode **/
var detailGenreSort: Sort
- get() = Sort.fromInt(sharedPrefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE))
+ get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE))
?: Sort.ByName(true)
set(value) {
- sharedPrefs.edit {
+ prefs.edit {
putInt(KEY_DETAIL_GENRE_SORT, value.toInt())
apply()
}
@@ -281,7 +281,7 @@ class SettingsManager private constructor(context: Context) :
const val KEY_USE_ALT_NOTIFICATION_ACTION = "KEY_ALT_NOTIF_ACTION"
const val KEY_AUDIO_FOCUS = "KEY_AUDIO_FOCUS"
- const val KEY_PLUG_MANAGEMENT = "KEY_PLUG_MGT"
+ const val KEY_HEADSET_AUTOPLAY = "auxio_headset_autoplay"
const val KEY_REPLAY_GAIN = "auxio_replay_gain"
const val KEY_SONG_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2"
@@ -331,7 +331,7 @@ class SettingsManager private constructor(context: Context) :
return instance
}
- error("SettingsManager must be initialized with init() before getting its instance.")
+ error("SettingsManager must be initialized with init() before getting its instance")
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPreference.kt b/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPreference.kt
index 6b163b8c2..78bdc08b4 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPreference.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPreference.kt
@@ -18,25 +18,28 @@
package org.oxycblt.auxio.settings.pref
-import android.annotation.SuppressLint
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import androidx.preference.DialogPreference
+import androidx.preference.Preference
import org.oxycblt.auxio.R
-import androidx.preference.R as prefR
class IntListPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = prefR.attr.dialogPreferenceStyle,
+ 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
+ }
+
val entries: Array
val values: IntArray
-
private var currentValue: Int? = null
- private val defValue: Int
+ private val defValue: Int get() = defValueField.get(this) as Int
init {
val prefAttrs = context.obtainStyledAttributes(
@@ -49,8 +52,6 @@ class IntListPreference @JvmOverloads constructor(
prefAttrs.getResourceId(R.styleable.IntListPreference_entryValues, -1)
)
- defValue = prefAttrs.getInt(prefR.styleable.Preference_defaultValue, Int.MIN_VALUE)
-
prefAttrs.recycle()
summaryProvider = IntListSummaryProvider()
@@ -96,7 +97,6 @@ class IntListPreference @JvmOverloads constructor(
}
}
- @SuppressLint("PrivateResource")
private inner class IntListSummaryProvider : SummaryProvider {
override fun provideSummary(preference: IntListPreference): CharSequence {
val index = getValueIndex()
@@ -105,7 +105,8 @@ class IntListPreference @JvmOverloads constructor(
return entries[index]
}
- return context.getString(prefR.string.not_set)
+ // Usually an invalid state, don't bother translating
+ return ""
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt b/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt
index d38095891..7f8892f43 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt
@@ -30,8 +30,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.showToast
@@ -39,11 +39,11 @@ import org.oxycblt.auxio.util.showToast
/**
* Extension method for creating and showing a new [ActionMenu].
* @param anchor [View] This should be centered around
- * @param data [BaseModel] this menu corresponds to
+ * @param data [Item] this menu corresponds to
* @param flag (Optional, defaults to [ActionMenu.FLAG_NONE]) Any extra flags to accompany the data.
* @see ActionMenu
*/
-fun Fragment.newMenu(anchor: View, data: BaseModel, flag: Int = ActionMenu.FLAG_NONE) {
+fun Fragment.newMenu(anchor: View, data: Item, flag: Int = ActionMenu.FLAG_NONE) {
ActionMenu(requireActivity() as AppCompatActivity, anchor, data, flag).show()
}
@@ -51,15 +51,18 @@ fun Fragment.newMenu(anchor: View, data: BaseModel, flag: Int = ActionMenu.FLAG_
* A wrapper around [PopupMenu] that automates the menu creation for nearly every datatype in Auxio.
* @param activity [AppCompatActivity] required as both a context and ViewModelStore owner.
* @param anchor [View] This should be centered around
- * @param data [BaseModel] this menu corresponds to
+ * @param data [Item] this menu corresponds to
* @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM], [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
* @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?
*/
class ActionMenu(
activity: AppCompatActivity,
anchor: View,
- private val data: BaseModel,
+ private val data: Item,
private val flag: Int
) : PopupMenu(activity, anchor) {
private val context = activity.applicationContext
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt b/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt
index 768a3a63d..5ed786da4 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt
@@ -19,14 +19,14 @@
package org.oxycblt.auxio.ui
import androidx.recyclerview.widget.DiffUtil
-import org.oxycblt.auxio.music.BaseModel
+import org.oxycblt.auxio.music.Item
/**
- * A re-usable diff callback for all [BaseModel] implementations.
+ * A re-usable diff callback for all [Item] implementations.
* **Use this instead of creating a DiffCallback for each adapter.**
* @author OxygenCobalt
*/
-class DiffCallback : DiffUtil.ItemCallback() {
+class DiffCallback : DiffUtil.ItemCallback() {
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.hashCode() == newItem.hashCode()
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/EdgeAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/EdgeAppBarLayout.kt
index 60f6ff74c..f30302ca0 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/EdgeAppBarLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/EdgeAppBarLayout.kt
@@ -24,12 +24,12 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowInsets
-import androidx.annotation.StyleRes
+import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.updatePadding
import com.google.android.material.appbar.AppBarLayout
-import org.oxycblt.auxio.util.logE
+import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
@@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
open class EdgeAppBarLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- @StyleRes defStyleAttr: Int = -1
+ @AttrRes defStyleAttr: Int = 0
) : AppBarLayout(context, attrs, defStyleAttr) {
private var scrollingChild: View? = null
private val tConsumed = IntArray(2)
@@ -51,7 +51,6 @@ open class EdgeAppBarLayout @JvmOverloads constructor(
if (child != null) {
val coordinator = parent as CoordinatorLayout
-
(layoutParams as CoordinatorLayout.LayoutParams).behavior?.onNestedPreScroll(
coordinator, this, coordinator, 0, 0, tConsumed, 0
)
@@ -66,15 +65,12 @@ open class EdgeAppBarLayout @JvmOverloads constructor(
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
super.onApplyWindowInsets(insets)
-
updatePadding(top = insets.systemBarInsetsCompat.top)
-
return insets
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
-
viewTreeObserver.removeOnPreDrawListener(onPreDraw)
}
@@ -94,9 +90,10 @@ open class EdgeAppBarLayout @JvmOverloads constructor(
if (liftOnScrollTargetViewId != ResourcesCompat.ID_NULL) {
scrollingChild = (parent as ViewGroup).findViewById(liftOnScrollTargetViewId)
} else {
- logE("liftOnScrollTargetViewId was not specified. ignoring scroll events.")
+ logW("liftOnScrollTargetViewId was not specified. ignoring scroll events")
}
}
+
return scrollingChild
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/EdgeCoordinatorLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/EdgeCoordinatorLayout.kt
index 7e41af3fe..84341f7be 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/EdgeCoordinatorLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/EdgeCoordinatorLayout.kt
@@ -21,6 +21,7 @@ package org.oxycblt.auxio.ui
import android.content.Context
import android.util.AttributeSet
import android.view.WindowInsets
+import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.children
@@ -33,7 +34,7 @@ import androidx.core.view.children
class EdgeCoordinatorLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = -1
+ @AttrRes defStyleAttr: Int = 0
) : CoordinatorLayout(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
for (child in children) {
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt
index 8f8fc4b1d..3a273a55b 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt
@@ -21,6 +21,7 @@ package org.oxycblt.auxio.ui
import android.content.Context
import android.util.AttributeSet
import android.view.WindowInsets
+import androidx.annotation.AttrRes
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat
@@ -31,7 +32,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
class EdgeRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = -1
+ @AttrRes defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/MemberBinder.kt b/app/src/main/java/org/oxycblt/auxio/ui/MemberBinder.kt
deleted file mode 100644
index b460b7290..000000000
--- a/app/src/main/java/org/oxycblt/auxio/ui/MemberBinder.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright (c) 2021 Auxio Project
- * MemberBinder.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
- * 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 .
- */
-
-package org.oxycblt.auxio.ui
-
-import android.view.LayoutInflater
-import androidx.databinding.ViewDataBinding
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.LifecycleObserver
-import androidx.lifecycle.LifecycleOwner
-import org.oxycblt.auxio.util.assertMainThread
-import org.oxycblt.auxio.util.inflater
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-
-/**
- * A delegate that creates a binding that can be used as a member variable without nullability or
- * memory leaks.
- * @param inflate The ViewBinding inflation method that should be used
- * @param onDestroy What to do when the binding is destroyed
- */
-fun Fragment.memberBinding(
- inflate: (LayoutInflater) -> T,
- onDestroy: T.() -> Unit = {}
-) = MemberBinder(this, inflate, onDestroy)
-
-/**
- * The delegate for the [memberBinding] shortcut function.
- * Adapted from KAHelpers (https://github.com/FunkyMuse/KAHelpers/tree/master/viewbinding)
- * @author OxygenCobalt
- */
-class MemberBinder(
- private val fragment: Fragment,
- private val inflate: (LayoutInflater) -> T,
- private val onDestroy: T.() -> Unit
-) : ReadOnlyProperty, LifecycleObserver, LifecycleEventObserver {
- private var fragmentBinding: T? = null
-
- init {
- fragment.observeOwnerThroughCreation {
- lifecycle.addObserver(this@MemberBinder)
- }
- }
-
- override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
- assertMainThread()
-
- val binding = fragmentBinding
-
- // If the fragment is already initialized, then just return that.
- if (binding != null) {
- return binding
- }
-
- val lifecycle = fragment.viewLifecycleOwner.lifecycle
-
- check(lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
- "Fragment views are destroyed."
- }
-
- // Otherwise create the binding and return that.
- return inflate(thisRef.requireContext().inflater).also {
- fragmentBinding = it
- }
- }
-
- override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
- if (event == Lifecycle.Event.ON_DESTROY) {
- fragmentBinding?.onDestroy()
- fragmentBinding = null
- }
- }
-
- private inline fun Fragment.observeOwnerThroughCreation(
- crossinline viewOwner: LifecycleOwner.() -> Unit
- ) {
- lifecycle.addObserver(object : DefaultLifecycleObserver {
- override fun onCreate(owner: LifecycleOwner) {
- super.onCreate(owner)
-
- viewLifecycleOwnerLiveData.observe(this@observeOwnerThroughCreation) {
- it.viewOwner()
- }
- }
- })
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt
index a7c378ee9..d92075c10 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt
@@ -102,7 +102,7 @@ sealed class Sort(open val isAscending: Boolean) {
is ByName -> songs.stringSort { it.name }
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
- album.songs.intSort(true) { it.track }
+ album.songs.intSort(true) { it.track ?: 0 }
}
}
}
@@ -121,7 +121,7 @@ sealed class Sort(open val isAscending: Boolean) {
is ByArtist -> sortParents(albums.groupBy { it.artist }.keys)
.flatMap { ByYear(false).sortAlbums(it.albums) }
- is ByYear -> albums.intSort { it.year }
+ is ByYear -> albums.intSort { it.year ?: 0 }
}
}
@@ -139,7 +139,7 @@ sealed class Sort(open val isAscending: Boolean) {
* @see sortSongs
*/
fun sortAlbum(album: Album): List {
- return album.songs.intSort { it.track }
+ return album.songs.intSort { it.track ?: 0 }
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt
index 2b700a774..975c23ca1 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt
@@ -32,21 +32,21 @@ import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
+import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders.
- * @param T The datatype, inheriting [BaseModel] for this ViewHolder.
+ * @param T The datatype, inheriting [Item] for this ViewHolder.
* @param binding Basic [ViewDataBinding] required to set up click listeners & sizing.
* @param doOnClick (Optional) Function that calls on a click.
* @param doOnLongClick (Optional) Functions that calls on a long-click.
* @author OxygenCobalt
*/
-abstract class BaseViewHolder(
+abstract class BaseViewHolder(
private val binding: ViewDataBinding,
private val doOnClick: ((data: T) -> Unit)? = null,
private val doOnLongClick: ((view: View, data: T) -> Unit)? = null
@@ -59,7 +59,7 @@ abstract class BaseViewHolder(
}
/**
- * Bind the viewholder with whatever [BaseModel] instance that has been specified.
+ * 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
*/
diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt
index 722e23303..77ca4c7b2 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt
@@ -39,7 +39,6 @@ import androidx.annotation.PluralsRes
import androidx.annotation.Px
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
-import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainActivity
import kotlin.reflect.KClass
import kotlin.system.exitProcess
@@ -75,8 +74,7 @@ fun Context.getPluralSafe(@PluralsRes pluralsRes: Int, value: Int): String {
return try {
resources.getQuantityString(pluralsRes, value, value)
} catch (e: Exception) {
- logE("plural load failed")
- return ""
+ handleResourceFailure(e, "plural", "")
}
}
@@ -191,16 +189,9 @@ fun Context.pxOfDp(@Dimension dp: Float): Int {
}
private fun Context.handleResourceFailure(e: Exception, what: String, default: T): T {
- logE("$what load failed.")
-
- if (BuildConfig.DEBUG) {
- // I'd rather be aware of a sudden crash when debugging.
- throw e
- } else {
- // Not so much when the app is in production.
- logE(e.stackTraceToString())
- return default
- }
+ logE("$what load failed")
+ e.logTraceOrThrow()
+ return default
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt
index 8895960a6..f47c88d32 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt
@@ -34,15 +34,6 @@ fun SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
*/
fun assertBackgroundThread() {
check(Looper.myLooper() != Looper.getMainLooper()) {
- "This operation must be ran on a background thread."
- }
-}
-
-/**
- * Assert that we are on a foreground thread.
- */
-fun assertMainThread() {
- check(Looper.myLooper() == Looper.getMainLooper()) {
- "This operation must be ran on the main thread"
+ "This operation must be ran on a background thread"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt
index 1bc758b24..500b65df9 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt
@@ -41,6 +41,13 @@ fun Any.logD(msg: String) {
}
}
+/**
+ * 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
*/
@@ -49,18 +56,30 @@ fun Any.logE(msg: String) {
}
/**
- * Get a non-nullable name, used so that logs will always show up in the console.
- * This also applies a special "Auxio" prefix so that messages can be filtered to just from the main codebase.
+ * Logs an error in production while still throwing it in debug mode. This is useful for
+ * non-showstopper bugs that I would still prefer to be caught in debug mode.
+ */
+fun Throwable.logTraceOrThrow() {
+ if (BuildConfig.DEBUG) {
+ throw this
+ } else {
+ logE(stackTraceToString())
+ }
+}
+
+/**
+ * Get a non-nullable name, used so that logs will always show up by Auxio
* @return The name of the object, otherwise "Anonymous Object"
*/
private fun Any.getName(): String = "Auxio.${this::class.simpleName ?: "Anonymous Object"}"
/**
- * I know that this will not stop you, but consider what you are doing with your life, copiers.
+ * 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. Be better.
+ * 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" &&
diff --git a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt
index 1c8a22455..7f1c9773e 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt
@@ -22,6 +22,7 @@ import android.content.res.ColorStateList
import android.graphics.Insets
import android.graphics.Rect
import android.os.Build
+import android.view.View
import android.view.WindowInsets
import androidx.annotation.ColorRes
import androidx.recyclerview.widget.GridLayoutManager
@@ -63,7 +64,20 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height
/**
- * Resolve window insets in a version-aware manner. This can be used to apply padding to
+ * 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) {
+ logD("Disabling drop shadows")
+ val transparent = context.getColorSafe(android.R.color.transparent)
+ outlineAmbientShadowColor = transparent
+ outlineSpotShadowColor = transparent
+ }
+}
+
+/**
+ * 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() {
@@ -86,7 +100,11 @@ val WindowInsets.systemBarInsetsCompat: Rect get() {
}
}
-fun WindowInsets.replaceInsetsCompat(left: Int, top: Int, right: Int, bottom: Int): WindowInsets {
+/**
+ * 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 {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
WindowInsets.Builder(this)
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
index 9d73f4700..feb146d7a 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
@@ -53,7 +53,7 @@ fun createTinyWidget(context: Context, state: WidgetState): RemoteViews {
fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_small)
.applyCover(context, state)
- .applyControls(context, state)
+ .applyBasicControls(context, state)
}
/**
@@ -63,7 +63,7 @@ fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_medium)
.applyMeta(context, state)
- .applyControls(context, state)
+ .applyBasicControls(context, state)
}
/**
@@ -142,7 +142,7 @@ private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState):
return this
}
-private fun RemoteViews.applyControls(context: Context, state: WidgetState): RemoteViews {
+private fun RemoteViews.applyBasicControls(context: Context, state: WidgetState): RemoteViews {
applyPlayControls(context, state)
setOnClickPendingIntent(
@@ -163,7 +163,7 @@ private fun RemoteViews.applyControls(context: Context, state: WidgetState): Rem
}
private fun RemoteViews.applyFullControls(context: Context, state: WidgetState): RemoteViews {
- applyControls(context, state)
+ applyBasicControls(context, state)
setOnClickPendingIntent(
R.id.widget_loop,
@@ -179,17 +179,15 @@ private fun RemoteViews.applyFullControls(context: Context, state: WidgetState):
)
)
- // While it is technically possible to use the setColorFilter to tint these buttons, its
- // actually less efficient than using duplicate drawables.
- // And no, we can't control state drawables with RemoteViews. Because of course we can't.
-
+ // Like notifications, use the remote variants of icons since we really don't want to hack
+ // indicators.
val shuffleRes = when {
- state.isShuffled -> R.drawable.ic_shuffle_on
- else -> R.drawable.ic_shuffle
+ state.isShuffled -> R.drawable.ic_remote_shuffle_on
+ else -> R.drawable.ic_remote_shuffle_off
}
val loopRes = when (state.loopMode) {
- LoopMode.NONE -> R.drawable.ic_loop
+ LoopMode.NONE -> R.drawable.ic_remote_loop_off
LoopMode.ALL -> R.drawable.ic_loop_on
LoopMode.TRACK -> R.drawable.ic_loop_one
}
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt
index c28a5b560..10bd5026c 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt
@@ -23,6 +23,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.SettingsManager
+import org.oxycblt.auxio.util.logD
/**
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
@@ -53,6 +54,8 @@ class WidgetController(private val context: Context) :
* Release this instance, removing the callbacks and resetting all widgets
*/
fun release() {
+ logD("Releasing instance")
+
widget.reset(context)
playbackManager.removeCallback(this)
settingsManager.removeCallback(this)
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
index 6014f7072..96dbf54ed 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
@@ -34,11 +34,13 @@ import coil.imageLoader
import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation
import org.oxycblt.auxio.BuildConfig
+import org.oxycblt.auxio.coil.SquareFrameTransform
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
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
/**
@@ -86,32 +88,39 @@ class WidgetProvider : AppWidgetProvider() {
}
}
+ /**
+ * 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) {
- // Load our image so that it takes up the phone screen. This allows
- // us to get stable rounded corners for every single widget image. This probably
- // sacrifices quality in some way, but it's really the only good option.
- val metrics = context.resources.displayMetrics
- val imageSize = min(metrics.widthPixels, metrics.heightPixels)
-
val coverRequest = ImageRequest.Builder(context)
.data(song.album)
- .size(imageSize)
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
)
- // If we are on Android 12 or higher, round out the album cover.
- // This is simply to maintain stylistic cohesion with other widgets.
- // Here, we actually have to use RoundedCornersTransformation since the way
- // we get a 1:1 aspect ratio image results in clipToOutline not working well.
+ // 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
+ // art.
+ // - After Android 12, the widget has round edges, so we need to round out the album art.
+ // I dislike this, but it's mainly for stylistic cohesion.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // 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()
)
- coverRequest.transformations(transform)
+ // 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)
+ .size(min(metrics.widthPixels, metrics.heightPixels))
+ } else {
+ coverRequest.transformations(SquareFrameTransform())
}
context.imageLoader.enqueue(coverRequest.build())
@@ -148,6 +157,8 @@ class WidgetProvider : AppWidgetProvider() {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ logD("Requesting new view from PlaybackService")
+
// We can't resize the widget until we can generate the views, so request an update
// from PlaybackService.
requestUpdate(context)
@@ -214,7 +225,6 @@ class WidgetProvider : AppWidgetProvider() {
// Find the layout with the greatest area that fits entirely within
// the widget. This is what we will use.
-
val candidates = mutableListOf()
for (size in views.keys) {
@@ -231,7 +241,7 @@ class WidgetProvider : AppWidgetProvider() {
continue
} else {
// Default to the smallest view if no layout fits
- logD("No widget layout found")
+ logW("No good widget layout found")
val minimum = requireNotNull(
views.minByOrNull { it.key.width * it.key.height }?.value
diff --git a/app/src/main/res/color/sel_m3_switch_thumb.xml b/app/src/main/res/color/sel_m3_switch_thumb.xml
index 61c22f9ad..6dc73cbb1 100644
--- a/app/src/main/res/color/sel_m3_switch_thumb.xml
+++ b/app/src/main/res/color/sel_m3_switch_thumb.xml
@@ -1,5 +1,6 @@
+
\ No newline at end of file
diff --git a/app/src/main/res/color/sel_m3_switch_track.xml b/app/src/main/res/color/sel_m3_switch_track.xml
index 5d2e6df2f..c3abdac07 100644
--- a/app/src/main/res/color/sel_m3_switch_track.xml
+++ b/app/src/main/res/color/sel_m3_switch_track.xml
@@ -1,5 +1,6 @@
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_loop_off.xml b/app/src/main/res/drawable/ic_remote_loop_off.xml
similarity index 89%
rename from app/src/main/res/drawable/ic_loop_off.xml
rename to app/src/main/res/drawable/ic_remote_loop_off.xml
index fb09414e1..433c53d66 100644
--- a/app/src/main/res/drawable/ic_loop_off.xml
+++ b/app/src/main/res/drawable/ic_remote_loop_off.xml
@@ -2,6 +2,7 @@
diff --git a/app/src/main/res/drawable/ic_shuffle_on.xml b/app/src/main/res/drawable/ic_remote_shuffle_on.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_shuffle_on.xml
rename to app/src/main/res/drawable/ic_remote_shuffle_on.xml
diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml
index 4a14cbffa..a22ea2e59 100644
--- a/app/src/main/res/drawable/ic_shuffle.xml
+++ b/app/src/main/res/drawable/ic_shuffle.xml
@@ -2,7 +2,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ui_small_unbounded_ripple.xml b/app/src/main/res/drawable/ui_large_unbounded_ripple.xml
similarity index 74%
rename from app/src/main/res/drawable/ui_small_unbounded_ripple.xml
rename to app/src/main/res/drawable/ui_large_unbounded_ripple.xml
index b4367d84b..8daf5be74 100644
--- a/app/src/main/res/drawable/ui_small_unbounded_ripple.xml
+++ b/app/src/main/res/drawable/ui_large_unbounded_ripple.xml
@@ -1,4 +1,4 @@
+ android:radius="24dp" />
diff --git a/app/src/main/res/drawable/ui_widget_aspect_ratio.xml b/app/src/main/res/drawable/ui_remote_aspect_ratio.xml
similarity index 100%
rename from app/src/main/res/drawable/ui_widget_aspect_ratio.xml
rename to app/src/main/res/drawable/ui_remote_aspect_ratio.xml
diff --git a/app/src/main/res/drawable/ui_rounded_cutout.xml b/app/src/main/res/drawable/ui_rounded_cutout.xml
deleted file mode 100644
index 00d969fd3..000000000
--- a/app/src/main/res/drawable/ui_rounded_cutout.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ui_unbounded_ripple.xml b/app/src/main/res/drawable/ui_unbounded_ripple.xml
index 003c9a27b..0b98cc5db 100644
--- a/app/src/main/res/drawable/ui_unbounded_ripple.xml
+++ b/app/src/main/res/drawable/ui_unbounded_ripple.xml
@@ -1,4 +1,4 @@
+ android:radius="20dp" />
diff --git a/app/src/main/res/layout-h600dp/item_detail.xml b/app/src/main/res/layout-h600dp/item_detail.xml
index 21e2f021a..02a1eff41 100644
--- a/app/src/main/res/layout-h600dp/item_detail.xml
+++ b/app/src/main/res/layout-h600dp/item_detail.xml
@@ -10,7 +10,7 @@
android:layout_height="match_parent"
android:padding="@dimen/spacing_medium">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_margin="@dimen/spacing_medium">
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
index 285774204..2bab4386f 100644
--- a/app/src/main/res/layout/fragment_home.xml
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -11,9 +11,7 @@
-
-
+ android:src="@drawable/ic_shuffle" />
-
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_playback.xml b/app/src/main/res/layout/fragment_playback.xml
index 33b063fc1..d8f582ac6 100644
--- a/app/src/main/res/layout/fragment_playback.xml
+++ b/app/src/main/res/layout/fragment_playback.xml
@@ -34,7 +34,7 @@
tools:subtitle="@string/lbl_all_songs"
app:menu="@menu/menu_playback" />
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml
index 716fe376d..5108175dc 100644
--- a/app/src/main/res/layout/fragment_search.xml
+++ b/app/src/main/res/layout/fragment_search.xml
@@ -9,9 +9,7 @@
@@ -40,8 +38,7 @@
android:imeOptions="actionSearch|flagNoExtractUi"
android:inputType="textFilter"
android:paddingStart="0dp"
- android:paddingEnd="0dp"
- android:textAppearance="@style/TextAppearance.Auxio.TitleMedium" />
+ android:paddingEnd="0dp" />
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index 89c367e0b..f2110e80b 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -13,9 +13,7 @@
diff --git a/app/src/main/res/layout/item_action_header.xml b/app/src/main/res/layout/item_action_header.xml
index 42e3d1b2b..09d0d188e 100644
--- a/app/src/main/res/layout/item_action_header.xml
+++ b/app/src/main/res/layout/item_action_header.xml
@@ -31,10 +31,10 @@
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:background="@drawable/ui_small_unbounded_ripple"
android:contentDescription="@{context.getString(header.desc)}"
android:minWidth="@dimen/size_btn_small"
android:minHeight="@dimen/size_btn_small"
+ android:background="@drawable/ui_unbounded_ripple"
android:paddingStart="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium"
android:src="@{context.getDrawable(header.icon)}"
diff --git a/app/src/main/res/layout/item_album.xml b/app/src/main/res/layout/item_album.xml
index 898936cac..fe541cab6 100644
--- a/app/src/main/res/layout/item_album.xml
+++ b/app/src/main/res/layout/item_album.xml
@@ -13,7 +13,7 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_playback_bar.xml b/app/src/main/res/layout/view_playback_bar.xml
index bece8e10c..e7e3996de 100644
--- a/app/src/main/res/layout/view_playback_bar.xml
+++ b/app/src/main/res/layout/view_playback_bar.xml
@@ -17,7 +17,7 @@
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
-
-
+ android:text="@string/def_playback" />
diff --git a/app/src/main/res/layout/widget_large.xml b/app/src/main/res/layout/widget_large.xml
index fb14eb705..5a717fc7b 100644
--- a/app/src/main/res/layout/widget_large.xml
+++ b/app/src/main/res/layout/widget_large.xml
@@ -31,7 +31,7 @@
android:layout_marginEnd="@dimen/spacing_medium"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
- android:src="@drawable/ui_widget_aspect_ratio"
+ android:src="@drawable/ui_remote_aspect_ratio"
android:visibility="invisible"
tools:ignore="ContentDescription" />
@@ -76,7 +76,7 @@
@@ -77,7 +77,7 @@
@@ -63,7 +63,7 @@
@@ -63,7 +63,7 @@
صوتيات
تركيز الصوت
ايقاف مؤقت عند تشغيل صوت آخر (كالمكالمات)
- تركيز السماعة
- تشغيل/ايقاف مؤقت عند حدوث تغيير في اتصال السماعة
صخب الصوت (تجريبي)
- اطفاء
+ اطفاء
تفضيل المقطع
تفضيل الالبوم
ديناميكي
@@ -177,7 +175,7 @@
- - ألبومات d%
+ - %d ألبومات
- %d البوم
- %d ألبومات
- %d ألبومات
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 4d7a96d28..be8f00f18 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -67,8 +67,6 @@
"Zvuk"
"Zaměření zvuku"
Pozastavit při přehrávání jiného zvuku (např. hovor)
- "Zaměření sluchátek"
- "Přehrát/pozastavit při změně připojení sluchátek"
"Chování"
"Když je vybrána skladba"
"Zapamatovat si náhodné přehrávání"
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index b3e8db030..affb20ab7 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -71,10 +71,8 @@
Audio
Audiofokus
Pausieren wenn andere Töne abspielt wird [Bsp. Anrufe]
- Kopfhörerfokus
- Abspielen/Pausieren wenn sich die Kopfhörerverbindung ändert
ReplayGain (Experimentell)
- Aus
+ Aus
Titel bevorzugen
Album bevorzugen
@@ -151,7 +149,7 @@
- %d Alben
Ein einfacher, rationaler Musikplayer für Android.
- Spielende Musik anzeigen und kontrollieren
+ Musikwiedergabe anzeigen und kontrollieren
Künstler
Album
Jahr
@@ -173,5 +171,4 @@
Lied in der Warteschlange löschen
Tab versetzen
Unbekannter Künstler
-
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 7fcebbbaa..df4e05eb1 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1,8 +1,10 @@
+
- Reproductor simple y racional para Android.
- Reproductor de música
+ Un reproductor de música simple y racional para Android.
+ Reproducción musical
+ Ver y controlar la reproducción musical
Reintentar
@@ -10,136 +12,162 @@
Géneros
Artistas
- Álbum
+ Álbumes
Canciones
Todas las canciones
Buscar
- Filtro
+ Filtrar
Todo
- Ordenar
+ Organizar
+ Nombre
+ Artista
+ Álbum
+ Año
Ascendente
+ En reproducción
Reproducir
- Aleatorio
- Reproducir todas las canciones
+ Mezcla
+ Reproducir todo
Reproducir por álbum
Reproducir por artista
Reproducir por género
- Reproducción actual
Cola
- Siguiente
+ Reproducir siguiente
Agregar a la cola
- Agregada a la cola
+ Agregado a la cola
Ir al artista
Ir al álbum
Estado guardado
- Añadir
+ Agregar
Guardar
- No hay carpetas
+ Sin carpetas
Acerca de
Versión
- Ver en Github
- FAQ
+ Ver en GitHub
+ Preguntas frecuentes
Licencias
Desarrollado por OxygenCobalt
- Preferencias
-
+ Ajustes
Apariencia
Tema
Automático
Claro
Oscuro
- Acento
+ Esquema de color
Tema negro
- Usar tema negro puro
+ Usar un tema completamente negro
Pantalla
- Mostrar carátula de álbum
- Desactivar para ahorrar uso de memoria
+ Pestañas de biblioteca
+ Cambiar visibilidad y orden de las pestañas de la biblioteca
+ Mostrar carátulas de álbumes
+ Desactive para ahorrar memoria
Ignorar carátulas de MediaStore
- Mejora la calidad de las carátulas de álbum, pero resulta en tiempos de carga lentos y un mayor uso de memoria
- Usar acción de notificación alternativa
- Preferir acción modo repetir
- Preferir acción aleatoria
+ Incrementa la calidad de las carátulas, pero deriva en mayores tiempos de carga y uso de memoria
+ Carátulas redondeadas
+ Usar carátulas redondeadas para los álbumes
+ Usar acciones de notificación alternativas
+ Preferir acción de bucle
+ Preferir acción de mezcla
- Audio
- Enfoque de audio
- Pausar cuando se reproduce otro audio (ej. Llamadas)
- Conexión de auriculares
- Reproducir/Pausar cuando la conexión de los auriculares cambie
+ Sonido
+ Enfoque de sonido
+ Pausar cuando se reproduce otro sonido (Ej: llamadas)
+ ReplayGain (Experimental)
+ Desactivado
+ Por pista
+ Por álbum
+ Dinámico
- Funcionamiento
- Cuando una canción es seleccionada
- Recordar orden aleatorio
- Mantener la reproducción aleatoria cuando se reproduce una nueva canción
- Rebobinar antes de saltar al anterior
- Rebobinar antes de saltar a la canción anterior
+ Comportamiento
+ Cuando se selecciona una canción
+ Recordar mezcla
+ Mantener mezcla cuando se reproduce una nueva canción
+ Rebobinar atrás
+ Rebobinar al saltar a la canción anterior
+ Pausa en repetición
+ Pausa cuando se repite una canción
Contenido
Guardar estado de reproducción
- Guardar el estado actual de la reproducción ahora
- Carpetas excluidas
- El contenido de las carpetas excluidas se oculta de la biblioteca
+ Guardar el estado de reproduccion ahora
+ Recargar música
+ Se reiniciará la aplicación
+ Directorios excluidos
+ El contenido de los directorios excluidos no se mostrará
- No se encontró música
- Error al cargar música
- Auxio necesita permiso para leer tu biblioteca musical
- Ninguna aplicación puede abrir este enlace
- Este directorio no es compatible
+ Sin música
+ Falló la carga de música
+ Auxio necesita permiso para leer su biblioteca de música
+ Sin aplicación para abrir este enlace
+ Directorio no soportado
+ Auxio no soporta este tamaño de ventana
- Busca en tu biblioteca…
+ Buscar en la biblioteca…
Pista %d
- Reproducir o Pausar
+ Reproducir o pausar
Saltar a la siguiente canción
Saltar a la última canción
- Cambiar el modo de repetición
+ Cambiar modo de repetición
+ Act/des mezcla
+ Mezclar todo
+ Quitar canción de la cola
+ Mover canción en la cola
+ Mover pestaña
Borrar historial de búsqueda
- Eliminar directorio excluido
+ Quitar directorio excluido
- Auxio icon
+ Icono de Auxio
+ Carátula de álbum
Carátula de álbum para %s
Imagen de artista para %s
Imagen de género para %s
-
+
+ Artista desconocido
Género desconocido
Sin fecha
+ Sin número de pista
+ Sin música en reproducción
+ Song Name
+ Artist Name
Rojo
Rosa
Púrpura
- Púrpura Profundo
+ Púrpura intenso
Índigo
Azul
- Azul Profundo
+ Azul intenso
Cyan
- Teal
+ Verde azulino
Verde
- Verde Profundo
+ Verde intenso
Lima
Amarillo
Naranja
- Café
+ Marrón
Gris
- Canciones encontradas: %d
+ Canciones cargadas: %d
- %d Canción
@@ -150,5 +178,4 @@
- %d Álbum
- %d Álbumes
- Artista desconocido
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index bcd920982..de7e762e1 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -50,7 +50,6 @@
Audio
Audio Focus
- Branchement du casque
Comportement
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 288b00bc7..e3d0eec06 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -41,7 +41,6 @@
ऑडियो
ऑडियो फोकस
- हेडसेट प्लग
चाल चलन
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index ff105e669..2241b99c8 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -49,7 +49,6 @@
Hang
Hangfókusz
- Fejhallgató csatlakozó
Működés
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index ba13e7cb5..0f9ddec2d 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -50,7 +50,6 @@
Audio
Focus audio
- Inserimento cuffie
Comportamento
Ricorda casuale
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 928c6a0ff..5b504fc5e 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -48,7 +48,6 @@
오디오
오디오 포커스
- 헤드셋 연결
동작
diff --git a/app/src/main/res/values-night-v31/styles_core.xml b/app/src/main/res/values-night-v31/styles_core.xml
deleted file mode 100644
index c94c7b362..000000000
--- a/app/src/main/res/values-night-v31/styles_core.xml
+++ /dev/null
@@ -1,90 +0,0 @@
-
->
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 87590ca2a..ab00f5223 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -385,4 +385,8 @@
#C8C8C8
#fafafa
#191919
+
+ @color/material_dynamic_secondary20
+ @color/material_dynamic_neutral90
+ @color/material_dynamic_neutral20
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 52c0ca205..b52e19cbe 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -72,8 +72,6 @@
Audio
Audiofocus
Pauze wanneer andere audio speelt (ex. Gesprekken)
- Headset-pluggen
- Afspelen/Pauzeren wanneer de headsetaansluiting verandert
Gedrag
Wanneer een liedje is geselecteerd
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 851606073..0d49e2c45 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -49,7 +49,6 @@
Dźwięk
Wyciszanie otoczenia
- Podłączanie słuchawek
Zachowanie
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index a9f059dc7..51d7540fb 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -49,7 +49,6 @@
Áudio
Foco do áudio
- Entrada do fone de ouvido
Comportamento
Memorizar aleatorização
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index a9701d224..26c346d5e 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -50,7 +50,6 @@
Áudio
Foco de áudio
- Entrada do fone de ouvido
Comportamento
Memorizar aleatorização
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 700cff133..c9f02a1bc 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -50,7 +50,6 @@
Audio
Concentrare audio
- Conexiune cu cască
Comportament
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index ef49e0786..7f5680dec 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -83,10 +83,8 @@
Звук
Аудио-фокус
Ставить на паузу при звонках
- Гарнитурный фокус
- Ставить на паузу при отключении гарнитуры
ReplayGain (экспериментально)
- Выкл.
+ Выкл.
По треку
По альбому
Динамический
diff --git a/app/src/main/res/values-sw640dp/styles_ui.xml b/app/src/main/res/values-sw640dp/styles_ui.xml
new file mode 100644
index 000000000..f72e10d34
--- /dev/null
+++ b/app/src/main/res/values-sw640dp/styles_ui.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-v31/config.xml b/app/src/main/res/values-v31/config.xml
index 4bd8a8884..f229b667e 100644
--- a/app/src/main/res/values-v31/config.xml
+++ b/app/src/main/res/values-v31/config.xml
@@ -1,4 +1,5 @@
false
+ false
\ No newline at end of file
diff --git a/app/src/main/res/values-v31/styles_android.xml b/app/src/main/res/values-v31/styles_android.xml
index 8619e88be..82b614600 100644
--- a/app/src/main/res/values-v31/styles_android.xml
+++ b/app/src/main/res/values-v31/styles_android.xml
@@ -1,6 +1,6 @@
\ No newline at end of file
diff --git a/app/src/main/res/values-v31/styles_core.xml b/app/src/main/res/values-v31/styles_core.xml
index 977e3313c..19fb67673 100644
--- a/app/src/main/res/values-v31/styles_core.xml
+++ b/app/src/main/res/values-v31/styles_core.xml
@@ -1,90 +1,12 @@
-
-
-
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index a1eabcaa2..17b29e809 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -83,10 +83,8 @@
音频
音频焦点
有其它音频播放(比如电话)时暂停
- 设备焦点
- 设备连接状态改变时播放/暂停
回放增益
- 关
+ 关
偏好曲目
偏好专辑
动态
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 2b9a9f9e1..5aa7b7261 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -48,7 +48,6 @@
音訊
音頻焦點
- 耳機插頭
行為
記住隨機播放
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 000000000..616f08ecc
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 1f05f9332..de0f10d2f 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -386,4 +386,8 @@
#484848
#1f1f1f
#F0F0F0
+
+ @color/material_dynamic_primary95
+ @color/material_dynamic_neutral80
+ @color/material_dynamic_neutral95
\ No newline at end of file
diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml
index 09a31ac51..232746f50 100644
--- a/app/src/main/res/values/config.xml
+++ b/app/src/main/res/values/config.xml
@@ -1,5 +1,6 @@
true
+ true
1
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 68866574c..270cf530d 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -16,11 +16,11 @@
192dp
256dp
- 20dp
- 24dp
+ 8dp
+ 16dp
32dp
- 32dp
+ 32dp
16sp
18sp
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index 18505b690..4470ddbd5 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -6,6 +6,5 @@
%1$s • %2$s
%1$s • %2$s • %3$s
- %1$s, %2$s
%d
\ No newline at end of file
diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/integers.xml
similarity index 89%
rename from app/src/main/res/values/settings.xml
rename to app/src/main/res/values/integers.xml
index e027cb94f..e89cba798 100644
--- a/app/src/main/res/values/settings.xml
+++ b/app/src/main/res/values/integers.xml
@@ -1,11 +1,8 @@
-
-
-
-
-
+ 150
+
- @string/set_theme_auto
- @string/set_theme_day
@@ -33,7 +30,7 @@
- - @string/set_replay_gain_off
+ - @string/set_off
- @string/set_replay_gain_track
- @string/set_replay_gain_album
- @string/set_replay_gain_dynamic
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cb039b406..051c52fc9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,10 +1,9 @@
-
A simple, rational music player for android.
Music Playback
- View and control playing music
+ View and control music playback
Retry
@@ -83,10 +82,9 @@
Audio
Audio focus
Pause when other audio plays (ex. Calls)
- Headset focus
- Play/Pause when the headset connection changes
+ Headset autoplay
+ Always start playing when a headset is connected (may not work on all devices)
ReplayGain (Experimental)
- Off
Prefer track
Prefer album
Dynamic
@@ -108,6 +106,8 @@
Excluded folders
The content of excluded folders is hidden from your library
+ Off
+
No music found
Music loading failed
diff --git a/app/src/main/res/values/styles_android.xml b/app/src/main/res/values/styles_android.xml
index 3bd708c22..4b794a828 100644
--- a/app/src/main/res/values/styles_android.xml
+++ b/app/src/main/res/values/styles_android.xml
@@ -22,7 +22,7 @@
@@ -40,16 +40,14 @@
@@ -62,8 +60,8 @@
- ?android:attr/selectableItemBackgroundBorderless
-
-
+
+
+
@@ -95,7 +111,7 @@
- end
- 1
- ?android:attr/textColorSecondary
- - @style/TextAppearance.Auxio.TitleMedium
+ - @style/TextAppearance.Auxio.BodyLarge
-
@@ -158,19 +166,21 @@
-
\ No newline at end of file
diff --git a/app/src/main/res/values/typography.xml b/app/src/main/res/values/typography.xml
index 88447d7fa..8950b68c9 100644
--- a/app/src/main/res/values/typography.xml
+++ b/app/src/main/res/values/typography.xml
@@ -1,6 +1,14 @@
-
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/prefs_main.xml b/app/src/main/res/xml/prefs_main.xml
index 5bc7c2b81..afe8be5d4 100644
--- a/app/src/main/res/xml/prefs_main.xml
+++ b/app/src/main/res/xml/prefs_main.xml
@@ -82,15 +82,16 @@
app:defaultValue="true"
app:iconSpaceReserved="false"
app:key="KEY_AUDIO_FOCUS"
+ app:isPreferenceVisible="@bool/enable_audio_focus_setting"
app:summary="@string/set_focus_desc"
app:title="@string/set_focus" />
+ app:key="auxio_headset_autoplay"
+ app:summary="@string/set_headset_autoplay_desc"
+ app:title="@string/set_headset_autoplay" />
Exoplayer, Auxio ofrece una experiencia auditiva comparado con otras aplicaciones que usan la API nativa MediaPlayer. En resumen, reproduce música.
+
+Características
+
+- Reproducción basada en ExoPlayer
+- Interfaz y comportamiento personalizables
+- Indexador avanzado que prioriza los metadatos correctos
+- Estado de reproducción persistente confiable
+- Soporte para ReplayGain (en MP3, MP4, FLAC, OGG y OPUS)
+- Material You (sólo Android 12+)
+- Borde a borde
+- Soporte para carátulas insertadas
+- Función de búsqueda
+- Enfoque para Audio/Auriculares
+- Completamente privado y sin conexión
+- Sin carátulas redondeadas (Salvo que las quiera. En ese caso las tiene.)
\ No newline at end of file
diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt
new file mode 100644
index 000000000..defbc492e
--- /dev/null
+++ b/fastlane/metadata/android/es-ES/short_description.txt
@@ -0,0 +1 @@
+Un reproductor de música simple y racional
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 73a41e5ce..f3bf8f027 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -17,7 +17,7 @@ org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
+# Stop ExoPlayer from mangling AAR libraries with default abstract methods
+android.enableDexingArtifactTransform=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
-# Stop ExoPlayer from mangling AAR libraries with default abstract methods
-android.enableDexingArtifactTransform=false
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index ffed3a254..2e6e5897b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/info/ADDITIONS.md b/info/ADDITIONS.md
index 3681bdbcf..f0474d5d5 100644
--- a/info/ADDITIONS.md
+++ b/info/ADDITIONS.md
@@ -7,9 +7,8 @@ These will likely be accepted as long as they do not cause too much harm to the
## New Customizations/Options
While I do like adding new behavior/UI customizations, these will be looked at more closely as certain additions can cause harm to the apps UI/UX while not providing alot of benefit. These tend to be accepted however.
-## Feature Addtions and UI Changes
+## Feature Additions and UI Changes
These arent as likely to be accepted. As I said, I do not want Auxio to become overly bloated with features that are rarely used, therefore I only tend to accept features that:
-
- Benefit **my own** usage
- Are in line with Auxio's purpose as a music player
@@ -22,6 +21,7 @@ Feel free to fork Auxio to add your own feature set however.
- Recently added list [#18] (Out of scope)
- Lyrics [#19] (Out of scope)
- Tag editing [#33] (Out of scope)
-- Gapless Playback [#35] (Technical issues)
+- Gapless Playback [#35] (Technical issues, may change in the future)
- Reduce leading instrument [#45] (Technical issues, Out of scope)
- Opening music through a provider [#30] (Out of scope)
+- Cuesheet support [#83]
diff --git a/info/ARCHITECTURE.md b/info/ARCHITECTURE.md
index 9d6a759d3..6ee1a3b84 100644
--- a/info/ARCHITECTURE.md
+++ b/info/ARCHITECTURE.md
@@ -52,13 +52,11 @@ is separated into three phases:
- Set up the UI
- Set up ViewModel instances and LiveData observers
-`findViewById` is to **only** be used when interfacing with non-Auxio views. Otherwise, viewbinding should be
-used in all cases. If one needs to keep track of a viewbinding outside of `onCreateView`, then one can declare
-a binding `by memberBinding(BindingClass::inflate)` in order to have a binding that properly disposes itself
-on lifecycle events.
+`findViewById` is to **only** be used when interfacing with non-Auxio views. Otherwise, view-binding should be
+used in all cases. Avoid usages of databinding outside of the `onCreateView` step unless absolutely necessary.
At times it may be more appropriate to use a `View` instead of a full blown fragment. This is okay as long as
-viewbinding is still used.
+view-binding is still used.
When creating a ViewHolder for a `RecyclerView`, one should use `BaseViewHolder` to standardize the binding process
and automate some code shared across all ViewHolders. The only exceptions to this case are for ViewHolders that
@@ -98,14 +96,15 @@ to a name that can be used in UIs.
while `ActionHeader` corresponds to an action with a dedicated icon, such as with sorting.
Other data types represent a specific UI configuration or state:
-- Sealed classes like `Sort` and `HeaderString` contain data with them that can be modified.
+- Sealed classes like `Sort` contain data with them that can be modified.
- Enums like `DisplayMode` and `LoopMode` only contain static data, such as a string resource.
Things to keep in mind while working with music data:
- `id` is not derived from the `MediaStore` ID of the music data. It is actually a hash of the unique fields of the music data.
Attempting to use it as a `MediaStore` ID will result in errors.
-- Any field beginning with `_mediaStore` is off-limits. These fields are meant for use within `MusicLoader` and generally provide
-poor UX to the user.
+- Any field or method beginning with `internal` is off-limits. These fields are meant for use within `MusicLoader` and generally
+provide poor UX to the user. The only reason they are public is to make the loading process not have to rely on separate "Raw"
+objects.
- Generally, `name` is used when saving music data to storage, while `resolvedName` is used when displaying music data to the user.
- For `Song` instances in particular, prefer `resolvedAlbumName` and `resolvedArtistName` over `album.resolvedName` and `album.artist.resolvedName`
- For `Album` instances in particular, prefer `resolvedArtistName` over `artist.resolvedName`
@@ -289,7 +288,6 @@ Shared views and view configuration models. This contains:
- Customized views such as `EdgeAppBarLayout` and `EdgeRecyclerView`, which add some extra functionality not provided by default
- Configuration models like `DisplayMode` and `Sort`, which are used in many places but aren't tied to a specific feature.
- `newMenu` and `ActionMenu`, which automates menu creation for most data types
-- `memberBinding` and `MemberBinder`, which allows for ViewBindings to be used as a member variable without memory leaks or nullability issues.
#### `.util`
Shared utilities. This is primarily for QoL when developing Auxio. Documentation is provided on each method.
diff --git a/info/FAQ.md b/info/FAQ.md
index 0a209f75c..492be2061 100644
--- a/info/FAQ.md
+++ b/info/FAQ.md
@@ -32,8 +32,8 @@ This is for a couple reason:
- Auxio doesn't extract ReplayGain tags for your format.
- Auxio doesn't recognize your ReplayGain tags. This is usually because of a non-standard tag like ID3v2's `RVAD` or
an unrecognized name.
-- Your tags use a ReplayGain value higher than 0. Due to technical limitations, Auxio does not support this right now.
-I do plan to add it eventually.
+- Your tags use a ReplayGain value higher than 0. Due to technical limitations, Auxio does not support this right now,
+but I can work on it if the demand for this is sufficient.
#### What is dynamic ReplayGain?
Dynamic ReplayGain is a quirk setting based off the FooBar2000 plugin that dynamically switches from track gain to album
diff --git a/prebuild.py b/prebuild.py
index 48b3c5582..f95023c0e 100755
--- a/prebuild.py
+++ b/prebuild.py
@@ -1,13 +1,25 @@
#!/usr/bin/env python3
-# This script automatically installs exoplayer with the necessary components.
-# This is written in version-agnostic python 3, because I'd rather not have to
-# deal with the insanity of bash.
+
+# This script automatically assembles any required ExoPlayer extensions or components as
+# an AAR blob. This method is not only faster than depending on ExoPlayer outright as we
+# only need to build our components once, it's also easier to use with Android Studio, which
+# tends to get bogged down when we include a massive source repository as part of the gradle
+# project. This script may change from time to time depending on the components or extensions
+# that I leverage. It's recommended to re-run it after every release to ensure consistent
+# behavior.
+
+# As for why I wrote this in Python and not Bash, it's because Bash really does not have
+# the capabilities for a nice, seamless pre-build process.
+
import os
import platform
import sys
import subprocess
import re
+# WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION AND
+# THE GRADLE DEPENDENCY. IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
+EXO_VERSION = "2.17.0"
FLAC_VERSION = "1.3.2"
FATAL="\033[1;31m"
@@ -16,32 +28,38 @@ INFO="\033[1;94m"
OK="\033[1;92m"
NC="\033[0m"
-system = platform.system()
-
# We do some shell scripting later on, so we can't support windows.
+system = platform.system()
if system not in ["Linux", "Darwin"]:
print("fatal: unsupported platform " + system)
sys.exit(1)
def sh(cmd):
+ print(INFO + "execute: " + NC + cmd)
code = subprocess.call(["sh", "-c", "set -e; " + cmd])
-
if code != 0:
print(FATAL + "fatal:" + NC + " command failed with exit code " + str(code))
sys.exit(1)
start_path = os.path.join(os.path.abspath(os.curdir))
libs_path = os.path.join(start_path, "app", "libs")
-exoplayer_path = os.path.join(start_path, "app", "build", "srclibs", "exoplayer")
-
if os.path.exists(libs_path):
- reinstall = input(INFO + "info:" + NC + " exoplayer is already installed. would you like to reinstall it? [y/n] ")
-
+ reinstall = input(INFO + "info:" + NC + " exoplayer is already installed. " +
+ "would you like to reinstall it? [y/n] ")
if not re.match("[yY][eE][sS]|[yY]", reinstall):
sys.exit(0)
-ndk_path = os.getenv("NDK_PATH")
+exoplayer_path = os.path.join(start_path, "app", "build", "srclibs", "exoplayer")
+# Ensure that there is always an SDK environment variable.
+# Technically there is also an sdk.dir field in local.properties, but that does
+# not work when you clone a project without a local.properties.
+if os.getenv("ANDROID_HOME") is None and os.getenv("ANDROID_SDK_ROOT") is None:
+ print(FATAL + "fatal:" + NC + " sdk location not found. please define " +
+ "ANDROID_HOME/ANDROID_SDK_ROOT before continuing.")
+ sys.exit(1)
+
+ndk_path = os.getenv("NDK_PATH")
if ndk_path is None or not os.path.isfile(os.path.join(ndk_path, "ndk-build")):
# We don't have a proper path. Do some digging on the Android SDK directory
# to see if we can find it.
@@ -51,14 +69,13 @@ if ndk_path is None or not os.path.isfile(os.path.join(ndk_path, "ndk-build")):
ndk_root = os.path.join(os.getenv("HOME"), "Library", "Android", "sdk", "ndk")
candidates = []
-
for entry in os.scandir(ndk_root):
if entry.is_dir():
candidates.append(entry.path)
if len(candidates) > 0:
- print(WARN + "warn:" + NC + " NDK_PATH was not set or invalid. multiple candidates were found however:")
-
+ print(WARN + "warn:" + NC + " NDK_PATH was not set or invalid. multiple " +
+ "candidates were found however:")
for i, candidate in enumerate(candidates):
print("[" + str(i) + "] " + candidate)
@@ -67,43 +84,36 @@ if ndk_path is None or not os.path.isfile(os.path.join(ndk_path, "ndk-build")):
except:
ndk_path = candidates[0]
else:
- print(FATAL + "fatal:" + NC + " the android ndk was not installed at a recognized location.")
+ print(FATAL + "fatal:" + NC + " the android ndk was not installed at a " +
+ "recognized location.")
system.exit(1)
+ndk_build_path = os.path.join(ndk_path, "ndk-build")
+
# Now try to install ExoPlayer.
sh("rm -rf " + exoplayer_path)
sh("rm -rf " + libs_path)
print(INFO + "info:" + NC + " cloning exoplayer...")
-sh("git clone https://github.com/oxygencobalt/ExoPlayer.git " + exoplayer_path)
+sh("git clone https://github.com/google/ExoPlayer.git " + exoplayer_path)
os.chdir(exoplayer_path)
-sh("git checkout auxio")
+sh("git checkout r" + EXO_VERSION)
-print(INFO + "info:" + NC + " installing flac extension...")
+print(INFO + "info:" + NC + " assembling flac extension...")
+flac_ext_aar_path = os.path.join(exoplayer_path, "extensions", "flac",
+ "buildout", "outputs", "aar", "extension-flac-release.aar")
flac_ext_jni_path = os.path.join("extensions", "flac", "src", "main", "jni")
-ndk_build_path = os.path.join(ndk_path, "ndk-build")
+
os.chdir(flac_ext_jni_path)
-sh('curl "https://ftp.osuosl.org/pub/xiph/releases/flac/flac-' + FLAC_VERSION + '.tar.xz" | tar xJ && mv "flac-' + FLAC_VERSION + '" flac')
+sh('curl "https://ftp.osuosl.org/pub/xiph/releases/flac/flac-' + FLAC_VERSION +
+ '.tar.xz" | tar xJ && mv "flac-' + FLAC_VERSION + '" flac')
sh(ndk_build_path + " APP_ABI=all -j4")
-print(INFO + "info:" + NC + " assembling libraries")
-extractor_aar_path = os.path.join(
- exoplayer_path, "library", "extractor", "buildout",
- "outputs", "aar", "library-extractor-release.aar"
-)
-
-flac_ext_aar_path = os.path.join(
- exoplayer_path, "extensions", "flac", "buildout",
- "outputs", "aar", "extension-flac-release.aar"
-)
-
os.chdir(exoplayer_path)
-sh("./gradlew library-extractor:bundleReleaseAar")
sh("./gradlew extension-flac:bundleReleaseAar")
os.chdir(start_path)
sh("mkdir " + libs_path)
-sh("cp " + extractor_aar_path + " " + libs_path)
sh("cp " + flac_ext_aar_path + " " + libs_path)
-print(OK + "success:" + NC + " completed pre-build.")
+print(OK + "success:" + NC + " completed pre-build")