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.

- GitHub release + GitHub release Minimum SDK

-

Changelog | FAQ | Licenses | Contributing | Architecture +

Changelog | FAQ | Licenses | Contributing | Architecture

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")