Merge branch 'OxygenCobalt:dev' into dev

This commit is contained in:
Clyde 2022-03-12 08:47:34 +08:00 committed by GitHub
commit b662d2e59d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
187 changed files with 1692 additions and 1492 deletions

View file

@ -1,11 +1,54 @@
# Changelog # 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 ## v2.2.0
#### What's New: #### What's New:
- Added arabic translations [courtesy of hasanpasha] - Added Arabic translations [Courtesy of hasanpasha]
- Better russian translations [courtesy of lisiczka43] - Improved Russian translations [Courtesy of lisiczka43]
- Added option to reload the music library - Added option to reload the music library
#### What's Improved: #### What's Improved:
@ -18,9 +61,10 @@ artist they are grouped up in
#### What's Fixed: #### What's Fixed:
- Fixed crash on some devices configured to use French or Czech translations - 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 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 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: #### Dev/Meta:
- Removed 1.4.X compat - 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 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 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 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 #### Dev/Meta
- Migrated fully to material design - Migrated fully to material design

View file

@ -3,14 +3,14 @@
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="GitHub release" src="https://img.shields.io/static/v1?label=Tag&message=v2.2.0&color=0D5AF5"> <img alt="GitHub release" src="https://img.shields.io/static/v1?label=Tag&message=v2.2.2&color=0D5AF5">
</a> </a>
<a href="https://www.gnu.org/licenses/gpl-3.0"> <a href="https://www.gnu.org/licenses/gpl-3.0">
<img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"> <img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg">
</a> </a>
<img alt="Minimum SDK" src="https://img.shields.io/badge/API-21%2B-32B5ED"> <img alt="Minimum SDK" src="https://img.shields.io/badge/API-21%2B-32B5ED">
</p> </p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="/info/FAQ.md">FAQ</a> | <a href="/info/LICENSES.md">Licenses</a> | <a href="/.github/CONTRIBUTING.md">Contributing</a> | <a href="/info/ARCHITECTURE.md">Architecture</a> <h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="/info/FAQ.md">FAQ</a> | <a href="/info/LICENSES.md">Licenses</a> | <a href="/.github/CONTRIBUTING.md">Contributing</a> | <a href="/info/ARCHITECTURE.md">Architecture</a></h4>
<p align="center"> <p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a> <a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
</p> </p>

View file

@ -9,8 +9,8 @@ android {
defaultConfig { defaultConfig {
applicationId "org.oxycblt.auxio" applicationId "org.oxycblt.auxio"
versionName "2.2.0" versionName "2.2.2"
versionCode 12 versionCode 14
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
@ -73,7 +73,7 @@ dependencies {
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01" implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
// Lifecycle // 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:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -85,7 +85,7 @@ dependencies {
// Media // Media
// TODO: Dumpster this for Media3 // TODO: Dumpster this for Media3
implementation "androidx.media:media:1.4.3" implementation "androidx.media:media:1.5.0"
// Preferences // Preferences
implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.preference:preference-ktx:1.2.0"
@ -93,32 +93,28 @@ dependencies {
// --- THIRD PARTY --- // --- THIRD PARTY ---
// Exoplayer // 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. // IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
def exoplayerVersion = '2.16.1' def exoplayerVersion = '2.17.0'
implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion") { implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion")
exclude group: "com.google.android.exoplayer", module: "exoplayer-extractor"
}
implementation fileTree(dir: "libs", include: ["library-*.aar"])
implementation fileTree(dir: "libs", include: ["extension-*.aar"]) implementation fileTree(dir: "libs", include: ["extension-*.aar"])
// Image loading // Image loading
implementation 'io.coil-kt:coil:2.0.0-alpha06' implementation 'io.coil-kt:coil:2.0.0-alpha09'
// Material // Material
implementation 'com.google.android.material:material:1.6.0-alpha02' implementation 'com.google.android.material:material:1.6.0-alpha03'
// --- DEBUG --- // --- DEBUG ---
// Lint // Lint
ktlint 'com.pinterest:ktlint:0.43.2' ktlint 'com.pinterest:ktlint:0.44.0'
} }
task ktlint(type: JavaExec, group: "verification") { task ktlint(type: JavaExec, group: "verification") {
description = "Check Kotlin code style." description = "Check Kotlin code style."
mainClass.set("com.pinterest.ktlint.Main") mainClass.set("com.pinterest.ktlint.Main")
classpath = configurations.ktlint classpath = configurations.ktlint
args "src/**/*.kt" args "src/**/*.kt"
} }
check.dependsOn ktlint check.dependsOn ktlint
@ -127,6 +123,5 @@ task ktlintFormat(type: JavaExec, group: "formatting") {
description = "Fix Kotlin code style deviations." description = "Fix Kotlin code style deviations."
mainClass.set("com.pinterest.ktlint.Main") mainClass.set("com.pinterest.ktlint.Main")
classpath = configurations.ktlint classpath = configurations.ktlint
args "-F", "src/**/*.kt" args "-F", "src/**/*.kt"
} }

View file

@ -51,9 +51,12 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" /> <data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="audio/*" /> <data android:mimeType="audio/*" />
</intent-filter> </intent-filter>
</activity> </activity>
@ -66,7 +69,7 @@
android:roundIcon="@mipmap/ic_launcher" /> android:roundIcon="@mipmap/ic_launcher" />
<!-- <!--
Workaround to get apps that blindly query for ACTION_MEDIA_BUTTON working. Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info. See the class for more info.
--> -->
<receiver <receiver

View file

@ -24,11 +24,18 @@ import coil.ImageLoaderFactory
import coil.request.CachePolicy import coil.request.CachePolicy
import org.oxycblt.auxio.coil.AlbumArtFetcher import org.oxycblt.auxio.coil.AlbumArtFetcher
import org.oxycblt.auxio.coil.ArtistImageFetcher import org.oxycblt.auxio.coil.ArtistImageFetcher
import org.oxycblt.auxio.coil.ErrorCrossfadeFactory import org.oxycblt.auxio.coil.CrossfadeFactory
import org.oxycblt.auxio.coil.GenreImageFetcher import org.oxycblt.auxio.coil.GenreImageFetcher
import org.oxycblt.auxio.coil.MusicKeyer import org.oxycblt.auxio.coil.MusicKeyer
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
/**
* TODO: Plan for a general UI rework
* - Refactor fragment class
* - Remove databinding and dedup layouts
* - Rework RecyclerView management and item dragging
* - Rework sealed classes to minimize whens and maximize overrides
*/
@Suppress("UNUSED") @Suppress("UNUSED")
class AuxioApp : Application(), ImageLoaderFactory { class AuxioApp : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
@ -48,7 +55,7 @@ class AuxioApp : Application(), ImageLoaderFactory {
add(GenreImageFetcher.Factory()) add(GenreImageFetcher.Factory())
add(MusicKeyer()) add(MusicKeyer())
} }
.transitionFactory(ErrorCrossfadeFactory()) .transitionFactory(CrossfadeFactory())
.diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching .diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching
.build() .build()
} }

View file

@ -29,18 +29,20 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.accent.Accent
import org.oxycblt.auxio.databinding.ActivityMainBinding import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.system.PlaybackService import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.replaceInsetsCompat import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* The single [AppCompatActivity] for Auxio. * The single [AppCompatActivity] for Auxio.
* TODO: Add a new view for crashes with a stack trace
* TODO: Custom language support
* TODO: Rework menus [perhaps add multi-select]
*/ */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by viewModels() private val playbackModel: PlaybackViewModel by viewModels()
@ -56,7 +58,7 @@ class MainActivity : AppCompatActivity() {
applyEdgeToEdgeWindow(binding) applyEdgeToEdgeWindow(binding)
logD("Activity created.") logD("Activity created")
} }
override fun onStart() { override fun onStart() {
@ -80,7 +82,6 @@ class MainActivity : AppCompatActivity() {
if (action == Intent.ACTION_VIEW && !isConsumed) { if (action == Intent.ACTION_VIEW && !isConsumed) {
// Mark the intent as used so this does not fire again // Mark the intent as used so this does not fire again
intent.putExtra(KEY_INTENT_USED, true) intent.putExtra(KEY_INTENT_USED, true)
intent.data?.let { fileUri -> intent.data?.let { fileUri ->
playbackModel.playWithUri(fileUri, this) playbackModel.playWithUri(fileUri, this)
} }
@ -94,26 +95,29 @@ class MainActivity : AppCompatActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12, let dynamic colors be our accent and only enable the black theme option // Android 12, let dynamic colors be our accent and only enable the black theme option
if (isNight && settingsManager.useBlackTheme) { if (isNight && settingsManager.useBlackTheme) {
logD("Applying black theme [dynamic colors]")
setTheme(R.style.Theme_Auxio_Black) setTheme(R.style.Theme_Auxio_Black)
} }
} else { } else {
// Below android 12, load the accent and enable theme customization // Below android 12, load the accent and enable theme customization
AppCompatDelegate.setDefaultNightMode(settingsManager.theme) 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 // The black theme has a completely separate set of styles since style attributes cannot
// be modified at runtime. // be modified at runtime.
if (isNight && settingsManager.useBlackTheme) { if (isNight && settingsManager.useBlackTheme) {
setTheme(newAccent.blackTheme) logD("Applying black theme [accent $accent]")
setTheme(accent.blackTheme)
} else { } else {
setTheme(newAccent.theme) logD("Applying normal theme [accent $accent]")
setTheme(accent.theme)
} }
} }
} }
private fun applyEdgeToEdgeWindow(binding: ViewBinding) { private fun applyEdgeToEdgeWindow(binding: ViewBinding) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
logD("Doing R+ edge-to-edge.") logD("Doing R+ edge-to-edge")
window?.setDecorFitsSystemWindows(false) window?.setDecorFitsSystemWindows(false)
@ -136,7 +140,7 @@ class MainActivity : AppCompatActivity() {
} }
} else { } else {
// Do old edge-to-edge otherwise. // Do old edge-to-edge otherwise.
logD("Doing legacy edge-to-edge.") logD("Doing legacy edge-to-edge")
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
binding.root.apply { binding.root.apply {
@ -158,7 +162,7 @@ class MainActivity : AppCompatActivity() {
right = bars.right right = bars.right
) )
return replaceInsetsCompat(0, bars.top, 0, bars.bottom) return replaceSystemBarInsetsCompat(0, bars.top, 0, bars.bottom)
} }
companion object { companion object {

View file

@ -36,11 +36,13 @@ import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.logD 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 * A wrapper around the home fragment that shows the playback fragment and controls
* the more high-level navigation features. * the more high-level navigation features.
* @author OxygenCobalt * @author OxygenCobalt
* TODO: Add a new view with a stack trace whenever the music loading process fails.
*/ */
class MainFragment : Fragment() { class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() 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 // 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 // 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. // 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) { if (requireActivity().isInMultiWindowMode) {
val config = resources.configuration val config = resources.configuration
@ -110,7 +108,7 @@ class MainFragment : Fragment() {
// Error, show the error to the user // Error, show the error to the user
is MusicStore.Response.Err -> { is MusicStore.Response.Err -> {
logD("Received Error") logW("Received Error")
val errorRes = when (response.kind) { val errorRes = when (response.kind) {
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music
@ -142,7 +140,7 @@ class MainFragment : Fragment() {
} }
} }
logD("Fragment Created.") logD("Fragment Created")
return binding.root return binding.root
} }

View file

@ -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." * 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 name The name of this accent
* @property theme The theme resource for this accent * @property theme The theme resource for this accent
* @property blackTheme The black 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 theme: Int get() = ACCENT_THEMES[index]
val blackTheme: Int get() = ACCENT_BLACK_THEMES[index] val blackTheme: Int get() = ACCENT_BLACK_THEMES[index]
val primary: Int get() = ACCENT_PRIMARY_COLORS[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
}
}
} }

View file

@ -77,12 +77,10 @@ class AccentAdapter(
val context = binding.accent.context val context = binding.accent.context
binding.accent.isEnabled = !isSelected binding.accent.isEnabled = !isSelected
binding.accent.imageTintList = if (isSelected) { binding.accent.imageTintList = if (isSelected) {
// Switch out the currently selected ViewHolder with this one. // Switch out the currently selected ViewHolder with this one.
selectedViewHolder?.setSelected(false) selectedViewHolder?.setSelected(false)
selectedViewHolder = this selectedViewHolder = this
context.getAttrColorSafe(R.attr.colorSurface).stateList context.getAttrColorSafe(R.attr.colorSurface).stateList
} else { } else {
context.getColorSafe(android.R.color.transparent).stateList context.getColorSafe(android.R.color.transparent).stateList

View file

@ -34,9 +34,9 @@ import org.oxycblt.auxio.util.logD
* Dialog responsible for showing the list of accents to select. * Dialog responsible for showing the list of accents to select.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class AccentDialog : LifecycleDialog() { class AccentCustomizeDialog : LifecycleDialog() {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private var pendingAccent = Accent.get() private var pendingAccent = settingsManager.accent
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -53,18 +53,18 @@ class AccentDialog : LifecycleDialog() {
binding.accentRecycler.apply { binding.accentRecycler.apply {
adapter = AccentAdapter(pendingAccent) { accent -> adapter = AccentAdapter(pendingAccent) { accent ->
logD("Switching selected accent to $accent")
pendingAccent = accent pendingAccent = accent
} }
} }
logD("Dialog created.") logD("Dialog created")
return binding.root return binding.root
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index) outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index)
} }
@ -72,9 +72,9 @@ class AccentDialog : LifecycleDialog() {
builder.setTitle(R.string.set_accent) builder.setTitle(R.string.set_accent)
builder.setPositiveButton(android.R.string.ok) { _, _ -> builder.setPositiveButton(android.R.string.ok) { _, _ ->
if (pendingAccent != Accent.get()) { if (pendingAccent != settingsManager.accent) {
logD("Applying new accent")
settingsManager.accent = pendingAccent settingsManager.accent = pendingAccent
requireActivity().recreate() requireActivity().recreate()
} }

View file

@ -30,7 +30,7 @@ import kotlin.math.max
* of the RecyclerView. * of the RecyclerView.
* Adapted from this StackOverflow answer: https://stackoverflow.com/a/30256880/14143986 * Adapted from this StackOverflow answer: https://stackoverflow.com/a/30256880/14143986
*/ */
class AutoGridLayoutManager( class AccentGridLayoutManager(
context: Context, context: Context,
attrs: AttributeSet, attrs: AttributeSet,
defStyleAttr: Int, defStyleAttr: Int,

View file

@ -18,8 +18,8 @@ import coil.size.pxOrElse
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.flac.PictureFrame
import com.google.android.exoplayer2.metadata.id3.ApicFrame import com.google.android.exoplayer2.metadata.id3.ApicFrame
import com.google.android.exoplayer2.metadata.vorbis.PictureFrame
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.buffer import okio.buffer
@ -27,6 +27,7 @@ import okio.source
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import android.util.Size as AndroidSize 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. * The base implementation for all image fetchers in Auxio.
* @author OxygenCobalt * @author OxygenCobalt
* TODO: Artist images
*/ */
abstract class AuxioFetcher : Fetcher { abstract class BaseFetcher : Fetcher {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
/** /**
@ -55,6 +57,7 @@ abstract class AuxioFetcher : Fetcher {
fetchMediaStoreCovers(context, album) fetchMediaStoreCovers(context, album)
} }
} catch (e: Exception) { } catch (e: Exception) {
logW("Unable to extract album art due to an error")
null null
} }
} }
@ -80,7 +83,6 @@ abstract class AuxioFetcher : Fetcher {
// music app which relies on proprietary OneUI extensions instead of AOSP. That means // 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. // we have to have another layer of redundancy to retain quality. Thanks samsung. Prick.
val result = fetchAospMetadataCovers(context, album) val result = fetchAospMetadataCovers(context, album)
if (result != null) { if (result != null) {
return result return result
} }
@ -88,7 +90,6 @@ abstract class AuxioFetcher : Fetcher {
// Our next fallback is to rely on ExoPlayer's largely half-baked and undocumented // Our next fallback is to rely on ExoPlayer's largely half-baked and undocumented
// metadata system. // metadata system.
val exoResult = fetchExoplayerCover(context, album) val exoResult = fetchExoplayerCover(context, album)
if (exoResult != null) { if (exoResult != null) {
return exoResult 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 // 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. // and we can't do any filesystem traversing due to scoped storage.
val mediaStoreResult = fetchMediaStoreCovers(context, album) val mediaStoreResult = fetchMediaStoreCovers(context, album)
if (mediaStoreResult != null) { if (mediaStoreResult != null) {
return mediaStoreResult return mediaStoreResult
} }
@ -107,16 +107,14 @@ abstract class AuxioFetcher : Fetcher {
} }
private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? { private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
val extractor = MediaMetadataRetriever() MediaMetadataRetriever().use { ext ->
extractor.use { ext ->
// This call is time-consuming but it also doesn't seem to hold up the main thread, // 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. // so it's probably fine not to wrap it.
ext.setDataSource(context, album.songs[0].uri) ext.setDataSource(context, album.songs[0].uri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full // Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts. // ByteArray of the cover without any compression artifacts.
// If its null [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 -> return ext.embeddedPicture?.let { coverBytes ->
ByteArrayInputStream(coverBytes) ByteArrayInputStream(coverBytes)
} }
@ -125,7 +123,6 @@ abstract class AuxioFetcher : Fetcher {
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? { private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri val uri = album.songs[0].uri
val future = MetadataRetriever.retrieveMetadata( val future = MetadataRetriever.retrieveMetadata(
context, MediaItem.fromUri(uri) context, MediaItem.fromUri(uri)
) )
@ -192,8 +189,7 @@ abstract class AuxioFetcher : Fetcher {
} else if (stream != null) { } else if (stream != null) {
// In the case a front cover is not found, use the first image in the tag instead. // 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. // 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) 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 * Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph * https://github.com/kabouzeid/Phonograph
*/ */
protected fun createMosaic(context: Context, streams: List<InputStream>, size: Size): FetchResult? { protected suspend fun createMosaic(context: Context, streams: List<InputStream>, size: Size): FetchResult? {
if (streams.size < 4) { if (streams.size < 4) {
return streams.firstOrNull()?.let { stream -> return streams.firstOrNull()?.let { stream ->
return SourceResult( return SourceResult(
@ -220,12 +216,15 @@ abstract class AuxioFetcher : Fetcher {
// get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a // get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a
// 512x512 mosaic. // 512x512 mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize()) val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val increment = AndroidSize(mosaicSize.width / 2, mosaicSize.height / 2) val mosaicFrameSize = Size(
Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2)
val mosaicBitmap = Bitmap.createBitmap(
mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888
) )
val mosaicBitmap = Bitmap.createBitmap(
mosaicSize.width,
mosaicSize.height,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(mosaicBitmap) val canvas = Canvas(mosaicBitmap)
var x = 0 var x = 0
@ -238,20 +237,21 @@ abstract class AuxioFetcher : Fetcher {
break break
} }
val bitmap = Bitmap.createScaledBitmap( // Run the bitmap through a transform to make sure it's a square of the desired
BitmapFactory.decodeStream(stream), // resolution.
increment.width, val bitmap = SquareFrameTransform.INSTANCE
increment.height, .transform(
true BitmapFactory.decodeStream(stream),
) mosaicFrameSize
)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += increment.width x += bitmap.width
if (x == mosaicSize.width) { if (x == mosaicSize.width) {
x = 0 x = 0
y += increment.height y += bitmap.height
} }
} }

View file

@ -35,7 +35,6 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager
// --- BINDING ADAPTERS --- // --- BINDING ADAPTERS ---
@ -65,24 +64,9 @@ fun ImageView.bindGenreImage(genre: Genre?) = load(genre, R.drawable.ic_genre)
fun <T : Music> ImageView.load(music: T?, @DrawableRes error: Int) { fun <T : Music> ImageView.load(music: T?, @DrawableRes error: Int) {
dispose() 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) { load(music) {
error(error) error(error)
transformations(SquareFrameTransform.INSTANCE)
} }
} }
@ -102,6 +86,7 @@ fun loadBitmap(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(song.album) .data(song.album)
.size(Size.ORIGINAL) .size(Size.ORIGINAL)
.transformations(SquareFrameTransform())
.target( .target(
onError = { onDone(null) }, onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) } onSuccess = { onDone(it.toBitmap()) }

View file

@ -13,7 +13,7 @@ import coil.transition.TransitionTarget
* You know. Like they used to. * You know. Like they used to.
* @author Coil Team * @author Coil Team
*/ */
class ErrorCrossfadeFactory : Transition.Factory { class CrossfadeFactory : Transition.Factory {
override fun create(target: TransitionTarget, result: ImageResult): Transition { override fun create(target: TransitionTarget, result: ImageResult): Transition {
// Don't animate if the request was fulfilled by the memory cache. // Don't animate if the request was fulfilled by the memory cache.
if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) { if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) {

View file

@ -43,7 +43,7 @@ import kotlin.math.min
class AlbumArtFetcher private constructor( class AlbumArtFetcher private constructor(
private val context: Context, private val context: Context,
private val album: Album private val album: Album
) : AuxioFetcher() { ) : BaseFetcher() {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
return fetchArt(context, album)?.let { stream -> return fetchArt(context, album)?.let { stream ->
SourceResult( SourceResult(
@ -75,11 +75,10 @@ class ArtistImageFetcher private constructor(
private val context: Context, private val context: Context,
private val size: Size, private val size: Size,
private val artist: Artist, private val artist: Artist,
) : AuxioFetcher() { ) : BaseFetcher() {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
val albums = Sort.ByName(true) val albums = Sort.ByName(true)
.sortAlbums(artist.albums) .sortAlbums(artist.albums)
val results = albums.mapAtMost(4) { album -> val results = albums.mapAtMost(4) { album ->
fetchArt(context, album) fetchArt(context, album)
} }
@ -102,7 +101,7 @@ class GenreImageFetcher private constructor(
private val context: Context, private val context: Context,
private val size: Size, private val size: Size,
private val genre: Genre, private val genre: Genre,
) : AuxioFetcher() { ) : BaseFetcher() {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
// We don't need to sort here, as the way we // We don't need to sort here, as the way we
val albums = genre.songs.groupBy { it.album }.keys val albums = genre.songs.groupBy { it.album }.keys

View file

@ -11,6 +11,7 @@ import org.oxycblt.auxio.music.Song
class MusicKeyer : Keyer<Music> { class MusicKeyer : Keyer<Music> {
override fun key(data: Music, options: Options): String { override fun key(data: Music, options: Options): String {
return if (data is Song) { return if (data is Song) {
// Group up song covers with album covers for better caching
key(data.album, options) key(data.album, options)
} else { } else {
"${data::class.simpleName}: ${data.id}" "${data::class.simpleName}: ${data.id}"

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -40,6 +40,7 @@ import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
/** /**
@ -56,6 +57,7 @@ class AlbumDetailFragment : DetailFragment() {
): View { ): View {
detailModel.setAlbum(args.albumId) detailModel.setAlbum(args.albumId)
val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = AlbumDetailAdapter( val detailAdapter = AlbumDetailAdapter(
playbackModel, detailModel, playbackModel, detailModel,
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) }, doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) },
@ -66,7 +68,7 @@ class AlbumDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner 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) { when (itemId) {
R.id.action_play_next -> { R.id.action_play_next -> {
playbackModel.playNext(detailModel.curAlbum.value!!) playbackModel.playNext(detailModel.curAlbum.value!!)
@ -84,7 +86,7 @@ class AlbumDetailFragment : DetailFragment() {
} }
} }
setupRecycler(detailAdapter) { pos -> setupRecycler(binding, detailAdapter) { pos ->
val item = detailAdapter.currentList[pos] val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Album item is Header || item is ActionHeader || item is Album
} }
@ -111,10 +113,11 @@ class AlbumDetailFragment : DetailFragment() {
// fragment should be launched otherwise. // fragment should be launched otherwise.
is Song -> { is Song -> {
if (detailModel.curAlbum.value!!.id == item.album.id) { 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() detailModel.finishNavToItem()
} else { } else {
logD("Navigating to another album")
findNavController().navigate( findNavController().navigate(
AlbumDetailFragmentDirections.actionShowAlbum(item.album.id) AlbumDetailFragmentDirections.actionShowAlbum(item.album.id)
) )
@ -125,9 +128,11 @@ class AlbumDetailFragment : DetailFragment() {
// detail fragment. // detail fragment.
is Album -> { is Album -> {
if (detailModel.curAlbum.value!!.id == item.id) { if (detailModel.curAlbum.value!!.id == item.id) {
logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0) binding.detailRecycler.scrollToPosition(0)
detailModel.finishNavToItem() detailModel.finishNavToItem()
} else { } else {
logD("Navigating to another album")
findNavController().navigate( findNavController().navigate(
AlbumDetailFragmentDirections.actionShowAlbum(item.id) AlbumDetailFragmentDirections.actionShowAlbum(item.id)
) )
@ -136,13 +141,14 @@ class AlbumDetailFragment : DetailFragment() {
// Always launch a new ArtistDetailFragment. // Always launch a new ArtistDetailFragment.
is Artist -> { is Artist -> {
logD("Navigating to another artist")
findNavController().navigate( findNavController().navigate(
AlbumDetailFragmentDirections.actionShowArtist(item.id) 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 return binding.root
} }
@ -180,7 +186,11 @@ class AlbumDetailFragment : DetailFragment() {
/** /**
* Scroll to an song using its [id]. * 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 // Calculate where the item for the currently played song is
val pos = adapter.currentList.indexOfFirst { it.id == id && it is Song } val pos = adapter.currentList.indexOfFirst { it.id == id && it is Song }

View file

@ -25,6 +25,7 @@ import android.view.ViewGroup
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album 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.ActionMenu
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/** /**
* The [DetailFragment] for an artist. * The [DetailFragment] for an artist.
@ -50,6 +52,7 @@ class ArtistDetailFragment : DetailFragment() {
): View { ): View {
detailModel.setArtist(args.artistId) detailModel.setArtist(args.artistId)
val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = ArtistDetailAdapter( val detailAdapter = ArtistDetailAdapter(
playbackModel, playbackModel,
doOnClick = { data -> doOnClick = { data ->
@ -73,8 +76,8 @@ class ArtistDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curArtist.value!!) setupToolbar(detailModel.curArtist.value!!, binding)
setupRecycler(detailAdapter) { pos -> setupRecycler(binding, detailAdapter) { pos ->
// If the item is an ActionHeader we need to also make the item full-width // If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos] val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Artist item is Header || item is ActionHeader || item is Artist
@ -98,25 +101,33 @@ class ArtistDetailFragment : DetailFragment() {
when (item) { when (item) {
is Artist -> { is Artist -> {
if (item.id == detailModel.curArtist.value?.id) { if (item.id == detailModel.curArtist.value?.id) {
logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0) binding.detailRecycler.scrollToPosition(0)
detailModel.finishNavToItem() detailModel.finishNavToItem()
} else { } else {
logD("Navigating to another artist")
findNavController().navigate( findNavController().navigate(
ArtistDetailFragmentDirections.actionShowArtist(item.id) ArtistDetailFragmentDirections.actionShowArtist(item.id)
) )
} }
} }
is Album -> findNavController().navigate( is Album -> {
ArtistDetailFragmentDirections.actionShowAlbum(item.id) logD("Navigating to another album")
) findNavController().navigate(
ArtistDetailFragmentDirections.actionShowAlbum(item.id)
is Song -> findNavController().navigate( )
ArtistDetailFragmentDirections.actionShowAlbum(item.album.id)
)
else -> {
} }
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 return binding.root
} }

View file

@ -5,7 +5,7 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StyleRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
@ -14,6 +14,9 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.EdgeAppBarLayout import org.oxycblt.auxio.ui.EdgeAppBarLayout
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow
import java.lang.Exception
/** /**
* An [EdgeAppBarLayout] variant that also shows the name of the toolbar whenever the detail * An [EdgeAppBarLayout] variant that also shows the name of the toolbar whenever the detail
@ -25,7 +28,7 @@ import org.oxycblt.auxio.ui.EdgeAppBarLayout
class DetailAppBarLayout @JvmOverloads constructor( class DetailAppBarLayout @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@StyleRes defStyleAttr: Int = -1 @AttrRes defStyleAttr: Int = 0
) : EdgeAppBarLayout(context, attrs, defStyleAttr) { ) : EdgeAppBarLayout(context, attrs, defStyleAttr) {
private var mTitleView: AppCompatTextView? = null private var mTitleView: AppCompatTextView? = null
private var mRecycler: RecyclerView? = null private var mRecycler: RecyclerView? = null
@ -35,13 +38,11 @@ class DetailAppBarLayout @JvmOverloads constructor(
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context) (layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
} }
private fun findTitleView(): AppCompatTextView { private fun findTitleView(): AppCompatTextView? {
val titleView = mTitleView val titleView = mTitleView
if (titleView != null) { if (titleView != null) {
return titleView return titleView
} }
@ -49,13 +50,18 @@ class DetailAppBarLayout @JvmOverloads constructor(
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar) val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
// Reflect to get the actual title view to do transformations on // Reflect to get the actual title view to do transformations on
val newTitleView = Toolbar::class.java.getDeclaredField("mTitleTextView").run { val newTitleView = try {
isAccessible = true Toolbar::class.java.getDeclaredField("mTitleTextView").run {
get(toolbar) as AppCompatTextView 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 newTitleView.alpha = 0f
mTitleView = newTitleView mTitleView = newTitleView
return newTitleView return newTitleView
} }
@ -95,14 +101,14 @@ class DetailAppBarLayout @JvmOverloads constructor(
to = 0f to = 0f
} }
if (titleView.alpha == to) return if (titleView?.alpha == to) return
mTitleAnimator = ValueAnimator.ofFloat(from, to).apply { mTitleAnimator = ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { 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() start()
} }

View file

@ -29,8 +29,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD
/** /**
* A Base [Fragment] implementing the base features shared across all detail fragments. * 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() { abstract class DetailFragment : Fragment() {
protected val detailModel: DetailViewModel by activityViewModels() protected val detailModel: DetailViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels() protected val playbackModel: PlaybackViewModel by activityViewModels()
protected val binding by memberBinding(FragmentDetailBinding::inflate)
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
detailModel.setNavigating(false) detailModel.setNavigating(false)
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
// Cancel all pending menus when this fragment stops to prevent bugs/crashes // Cancel all pending menus when this fragment stops to prevent bugs/crashes
detailModel.finishShowMenu(null) detailModel.finishShowMenu(null)
} }
@ -62,6 +59,7 @@ abstract class DetailFragment : Fragment() {
*/ */
protected fun setupToolbar( protected fun setupToolbar(
data: MusicParent, data: MusicParent,
binding: FragmentDetailBinding,
@MenuRes menuId: Int = -1, @MenuRes menuId: Int = -1,
onMenuClick: ((itemId: Int) -> Boolean)? = null onMenuClick: ((itemId: Int) -> Boolean)? = null
) { ) {
@ -88,13 +86,13 @@ abstract class DetailFragment : Fragment() {
* Shortcut method for recyclerview setup * Shortcut method for recyclerview setup
*/ */
protected fun setupRecycler( protected fun setupRecycler(
binding: FragmentDetailBinding,
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>, detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
gridLookup: (Int) -> Boolean gridLookup: (Int) -> Boolean
) { ) {
binding.detailRecycler.apply { binding.detailRecycler.apply {
adapter = detailAdapter adapter = detailAdapter
setHasFixedSize(true) setHasFixedSize(true)
applySpans(gridLookup) applySpans(gridLookup)
} }
} }
@ -105,6 +103,8 @@ abstract class DetailFragment : Fragment() {
* @param showItem Which menu items to keep * @param showItem Which menu items to keep
*/ */
protected fun showMenu(config: DetailViewModel.MenuConfig, showItem: ((Int) -> Boolean)? = null) { protected fun showMenu(config: DetailViewModel.MenuConfig, showItem: ((Int) -> Boolean)? = null) {
logD("Launching menu [$config]")
PopupMenu(config.anchor.context, config.anchor).apply { PopupMenu(config.anchor.context, config.anchor).apply {
inflate(R.menu.menu_detail_sort) inflate(R.menu.menu_detail_sort)

View file

@ -26,13 +26,14 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
/** /**
* ViewModel that stores data for the [DetailFragment]s. This includes: * ViewModel that stores data for the [DetailFragment]s. This includes:
@ -48,41 +49,39 @@ class DetailViewModel : ViewModel() {
private val mCurGenre = MutableLiveData<Genre?>() private val mCurGenre = MutableLiveData<Genre?>()
val curGenre: LiveData<Genre?> get() = mCurGenre val curGenre: LiveData<Genre?> get() = mCurGenre
private val mGenreData = MutableLiveData(listOf<BaseModel>()) private val mGenreData = MutableLiveData(listOf<Item>())
val genreData: LiveData<List<BaseModel>> = mGenreData val genreData: LiveData<List<Item>> = mGenreData
private val mCurArtist = MutableLiveData<Artist?>() private val mCurArtist = MutableLiveData<Artist?>()
val curArtist: LiveData<Artist?> get() = mCurArtist val curArtist: LiveData<Artist?> get() = mCurArtist
private val mArtistData = MutableLiveData(listOf<BaseModel>()) private val mArtistData = MutableLiveData(listOf<Item>())
val artistData: LiveData<List<BaseModel>> = mArtistData val artistData: LiveData<List<Item>> = mArtistData
private val mCurAlbum = MutableLiveData<Album?>() private val mCurAlbum = MutableLiveData<Album?>()
val curAlbum: LiveData<Album?> get() = mCurAlbum val curAlbum: LiveData<Album?> get() = mCurAlbum
private val mAlbumData = MutableLiveData(listOf<BaseModel>()) private val mAlbumData = MutableLiveData(listOf<Item>())
val albumData: LiveData<List<BaseModel>> get() = mAlbumData val albumData: LiveData<List<Item>> get() = mAlbumData
data class MenuConfig(val anchor: View, val sortMode: Sort) data class MenuConfig(val anchor: View, val sortMode: Sort)
private val mShowMenu = MutableLiveData<MenuConfig?>(null) private val mShowMenu = MutableLiveData<MenuConfig?>(null)
val showMenu: LiveData<MenuConfig?> = mShowMenu val showMenu: LiveData<MenuConfig?> = mShowMenu
private val mNavToItem = MutableLiveData<BaseModel?>() private val mNavToItem = MutableLiveData<Item?>()
/** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */ /** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */
val navToItem: LiveData<BaseModel?> get() = mNavToItem val navToItem: LiveData<Item?> get() = mNavToItem
var isNavigating = false var isNavigating = false
private set private set
private var currentMenuContext: DisplayMode? = null private var currentMenuContext: DisplayMode? = null
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
fun setGenre(id: Long) { fun setGenre(id: Long) {
if (mCurGenre.value?.id == id) return if (mCurGenre.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurGenre.value = musicStore.genres.find { it.id == id } mCurGenre.value = musicStore.genres.find { it.id == id }
refreshGenreData() refreshGenreData()
@ -90,7 +89,6 @@ class DetailViewModel : ViewModel() {
fun setArtist(id: Long) { fun setArtist(id: Long) {
if (mCurArtist.value?.id == id) return if (mCurArtist.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurArtist.value = musicStore.artists.find { it.id == id } mCurArtist.value = musicStore.artists.find { it.id == id }
refreshArtistData() refreshArtistData()
@ -98,7 +96,6 @@ class DetailViewModel : ViewModel() {
fun setAlbum(id: Long) { fun setAlbum(id: Long) {
if (mCurAlbum.value?.id == id) return if (mCurAlbum.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurAlbum.value = musicStore.albums.find { it.id == id } mCurAlbum.value = musicStore.albums.find { it.id == id }
refreshAlbumData() refreshAlbumData()
@ -112,6 +109,7 @@ class DetailViewModel : ViewModel() {
mShowMenu.value = null mShowMenu.value = null
if (newMode != null) { if (newMode != null) {
logD("Applying new sort mode")
when (currentMenuContext) { when (currentMenuContext) {
DisplayMode.SHOW_ALBUMS -> { DisplayMode.SHOW_ALBUMS -> {
settingsManager.detailAlbumSort = newMode settingsManager.detailAlbumSort = newMode
@ -135,7 +133,7 @@ class DetailViewModel : ViewModel() {
/** /**
* Navigate to an item, whether a song/album/artist * Navigate to an item, whether a song/album/artist
*/ */
fun navToItem(item: BaseModel) { fun navToItem(item: Item) {
mNavToItem.value = item mNavToItem.value = item
} }
@ -154,7 +152,9 @@ class DetailViewModel : ViewModel() {
} }
private fun refreshGenreData() { private fun refreshGenreData() {
val data = mutableListOf<BaseModel>(curGenre.value!!) logD("Refreshing genre data")
val genre = requireNotNull(curGenre.value)
val data = mutableListOf<Item>(genre)
data.add( data.add(
ActionHeader( ActionHeader(
@ -175,8 +175,9 @@ class DetailViewModel : ViewModel() {
} }
private fun refreshArtistData() { private fun refreshArtistData() {
val artist = curArtist.value!! logD("Refreshing artist data")
val data = mutableListOf<BaseModel>(artist) val artist = requireNotNull(curArtist.value)
val data = mutableListOf<Item>(artist)
data.add( data.add(
Header( Header(
@ -206,7 +207,9 @@ class DetailViewModel : ViewModel() {
} }
private fun refreshAlbumData() { private fun refreshAlbumData() {
val data = mutableListOf<BaseModel>(curAlbum.value!!) logD("Refreshing album data")
val album = requireNotNull(curAlbum.value)
val data = mutableListOf<Item>(album)
data.add( data.add(
ActionHeader( ActionHeader(

View file

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album 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.ActionMenu
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/** /**
* The [DetailFragment] for a genre. * The [DetailFragment] for a genre.
@ -50,6 +52,7 @@ class GenreDetailFragment : DetailFragment() {
): View { ): View {
detailModel.setGenre(args.genreId) detailModel.setGenre(args.genreId)
val binding = FragmentDetailBinding.inflate(inflater)
val detailAdapter = GenreDetailAdapter( val detailAdapter = GenreDetailAdapter(
playbackModel, playbackModel,
doOnClick = { song -> doOnClick = { song ->
@ -64,8 +67,8 @@ class GenreDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curGenre.value!!) setupToolbar(detailModel.curGenre.value!!, binding)
setupRecycler(detailAdapter) { pos -> setupRecycler(binding, detailAdapter) { pos ->
val item = detailAdapter.currentList[pos] val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Genre item is Header || item is ActionHeader || item is Genre
} }
@ -79,20 +82,29 @@ class GenreDetailFragment : DetailFragment() {
detailModel.navToItem.observe(viewLifecycleOwner) { item -> detailModel.navToItem.observe(viewLifecycleOwner) { item ->
when (item) { when (item) {
// All items will launch new detail fragments. // All items will launch new detail fragments.
is Artist -> findNavController().navigate( is Artist -> {
GenreDetailFragmentDirections.actionShowArtist(item.id) logD("Navigating to another 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 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 return binding.root
} }

View file

@ -30,9 +30,8 @@ import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album 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.Song
import org.oxycblt.auxio.music.toDate
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.BaseViewHolder
@ -49,7 +48,7 @@ class AlbumDetailAdapter(
private val detailModel: DetailViewModel, private val detailModel: DetailViewModel,
private val doOnClick: (data: Song) -> Unit, private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Song) -> Unit private val doOnLongClick: (view: View, data: Song) -> Unit
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback()) { ) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback()) {
private var currentSong: Song? = null private var currentSong: Song? = null
private var currentHolder: Highlightable? = null private var currentHolder: Highlightable? = null
@ -58,7 +57,6 @@ class AlbumDetailAdapter(
is Album -> ALBUM_DETAIL_ITEM_TYPE is Album -> ALBUM_DETAIL_ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
is Song -> ALBUM_SONG_ITEM_TYPE is Song -> ALBUM_SONG_ITEM_TYPE
else -> -1 else -> -1
} }
} }
@ -86,7 +84,6 @@ class AlbumDetailAdapter(
is Album -> (holder as AlbumDetailViewHolder).bind(item) is Album -> (holder as AlbumDetailViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item) is Song -> (holder as AlbumSongViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
else -> { else -> {
} }
} }
@ -127,7 +124,6 @@ class AlbumDetailAdapter(
recycler.layoutManager?.findViewByPosition(pos)?.let { child -> recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let { recycler.getChildViewHolder(child)?.let {
currentHolder = it as Highlightable currentHolder = it as Highlightable
currentHolder?.setHighlighted(true) currentHolder?.setHighlighted(true)
} }
} }
@ -148,21 +144,19 @@ class AlbumDetailAdapter(
binding.detailSubhead.apply { binding.detailSubhead.apply {
text = data.artist.resolvedName text = data.artist.resolvedName
setOnClickListener { setOnClickListener {
detailModel.navToItem(data.artist) detailModel.navToItem(data.artist)
} }
} }
binding.detailInfo.text = binding.detailInfo.context.getString( binding.detailInfo.apply {
R.string.fmt_three, text = context.getString(
data.year.toDate(binding.detailInfo.context), R.string.fmt_three,
binding.detailInfo.context.getPluralSafe( data.year?.toString() ?: context.getString(R.string.def_date),
R.plurals.fmt_song_count, context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size),
data.songs.size data.totalDuration
), )
data.totalDuration }
)
binding.detailPlayButton.setOnClickListener { binding.detailPlayButton.setOnClickListener {
playbackModel.playAlbum(data, false) 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 // 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. // 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.songTrack.isInvisible = usePlaceholder
binding.songTrackPlaceholder.isInvisible = !usePlaceholder binding.songTrackPlaceholder.isInvisible = !usePlaceholder
} }

View file

@ -30,15 +30,15 @@ import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.bindArtistInfo
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
@ -49,8 +49,8 @@ class ArtistDetailAdapter(
private val playbackModel: PlaybackViewModel, private val playbackModel: PlaybackViewModel,
private val doOnClick: (data: Album) -> Unit, private val doOnClick: (data: Album) -> Unit,
private val doOnSongClick: (data: Song) -> Unit, private val doOnSongClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: BaseModel) -> Unit, private val doOnLongClick: (view: View, data: Item) -> Unit,
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback()) { ) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback()) {
private var currentAlbum: Album? = null private var currentAlbum: Album? = null
private var currentAlbumHolder: Highlightable? = null private var currentAlbumHolder: Highlightable? = null
@ -64,7 +64,6 @@ class ArtistDetailAdapter(
is Song -> ARTIST_SONG_ITEM_TYPE is Song -> ARTIST_SONG_ITEM_TYPE
is Header -> HeaderViewHolder.ITEM_TYPE is Header -> HeaderViewHolder.ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
else -> -1 else -> -1
} }
} }
@ -174,7 +173,6 @@ class ArtistDetailAdapter(
recycler.layoutManager?.findViewByPosition(pos)?.let { child -> recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let { recycler.getChildViewHolder(child)?.let {
currentSongHolder = it as Highlightable currentSongHolder = it as Highlightable
currentSongHolder?.setHighlighted(true) currentSongHolder?.setHighlighted(true)
} }
} }
@ -201,15 +199,11 @@ class ArtistDetailAdapter(
// Get the genre that corresponds to the most songs in this artist, which would be // Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre. // the most "Prominent" genre.
binding.detailSubhead.text = data.songs binding.detailSubhead.text = data.songs
.groupBy { it.genre?.resolvedName } .groupBy { it.genre.resolvedName }
.entries.maxByOrNull { it.value.size } .entries.maxByOrNull { it.value.size }
?.key ?: context.getString(R.string.def_genre) ?.key ?: context.getString(R.string.def_genre)
binding.detailInfo.text = context.getString( binding.detailInfo.bindArtistInfo(data)
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.detailPlayButton.setOnClickListener { binding.detailPlayButton.setOnClickListener {
playbackModel.playArtist(data, false) playbackModel.playArtist(data, false)

View file

@ -27,14 +27,14 @@ import org.oxycblt.auxio.coil.bindGenreImage
import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemGenreSongBinding import org.oxycblt.auxio.databinding.ItemGenreSongBinding
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.bindGenreInfo
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
@ -45,7 +45,7 @@ class GenreDetailAdapter(
private val playbackModel: PlaybackViewModel, private val playbackModel: PlaybackViewModel,
private val doOnClick: (data: Song) -> Unit, private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Song) -> Unit private val doOnLongClick: (view: View, data: Song) -> Unit
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback()) { ) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback()) {
private var currentSong: Song? = null private var currentSong: Song? = null
private var currentHolder: Highlightable? = null private var currentHolder: Highlightable? = null
@ -54,7 +54,6 @@ class GenreDetailAdapter(
is Genre -> GENRE_DETAIL_ITEM_TYPE is Genre -> GENRE_DETAIL_ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
is Song -> GENRE_SONG_ITEM_TYPE is Song -> GENRE_SONG_ITEM_TYPE
else -> -1 else -> -1
} }
} }
@ -121,7 +120,6 @@ class GenreDetailAdapter(
recycler.layoutManager?.findViewByPosition(pos)?.let { child -> recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let { recycler.getChildViewHolder(child)?.let {
currentHolder = it as Highlightable currentHolder = it as Highlightable
currentHolder?.setHighlighted(true) currentHolder?.setHighlighted(true)
} }
} }
@ -143,11 +141,7 @@ class GenreDetailAdapter(
} }
binding.detailName.text = data.resolvedName binding.detailName.text = data.resolvedName
binding.detailSubhead.bindGenreInfo(data)
binding.detailSubhead.apply {
text = context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)
}
binding.detailInfo.text = data.totalDuration binding.detailInfo.text = data.totalDuration
binding.detailPlayButton.setOnClickListener { binding.detailPlayButton.setOnClickListener {

View file

@ -55,7 +55,6 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
writableDatabase.transaction { writableDatabase.transaction {
delete(TABLE_NAME, null, null) delete(TABLE_NAME, null, null)
logD("Deleted paths db") logD("Deleted paths db")
for (path in paths) { 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() assertBackgroundThread()
val paths = mutableListOf<String>() val paths = mutableListOf<String>()
readableDatabase.queryAll(TABLE_NAME) { cursor -> readableDatabase.queryAll(TABLE_NAME) { cursor ->
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
paths.add(cursor.getString(0)) paths.add(cursor.getString(0))
} }
} }
logD("Successfully read ${paths.size} paths from db")
return paths return paths
} }
companion object { 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_VERSION = 1
const val DB_NAME = "auxio_blacklist_database.db" const val DB_NAME = "auxio_blacklist_database.db"

View file

@ -77,13 +77,16 @@ class ExcludedDialog : LifecycleDialog() {
dialog.setOnShowListener { dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
logD("Opening launcher")
launcher.launch(null) launcher.launch(null)
} }
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
if (excludedModel.isModified) { if (excludedModel.isModified) {
logD("Committing changes")
saveAndRestart() saveAndRestart()
} else { } else {
logD("Dropping changes")
dismiss() dismiss()
} }
} }
@ -93,11 +96,10 @@ class ExcludedDialog : LifecycleDialog() {
excludedModel.paths.observe(viewLifecycleOwner) { paths -> excludedModel.paths.observe(viewLifecycleOwner) { paths ->
adapter.submitList(paths) adapter.submitList(paths)
binding.excludedEmpty.isVisible = paths.isEmpty() binding.excludedEmpty.isVisible = paths.isEmpty()
} }
logD("Dialog created.") logD("Dialog created")
return binding.root return binding.root
} }
@ -114,6 +116,7 @@ class ExcludedDialog : LifecycleDialog() {
private fun addDocTreePath(uri: Uri?) { private fun addDocTreePath(uri: Uri?) {
// A null URI means that the user left the file picker without picking a directory // A null URI means that the user left the file picker without picking a directory
if (uri == null) { if (uri == null) {
logD("No URI given (user closed the dialog)")
return return
} }
@ -142,6 +145,7 @@ class ExcludedDialog : LifecycleDialog() {
return getRootPath() + "/" + typeAndPath.last() return getRootPath() + "/" + typeAndPath.last()
} }
logD("Unsupported volume ${typeAndPath[0]}")
return null return null
} }
@ -156,7 +160,6 @@ class ExcludedDialog : LifecycleDialog() {
/** /**
* Get *just* the root path, nothing else is really needed. * Get *just* the root path, nothing else is really needed.
*/ */
@Suppress("DEPRECATION")
private fun getRootPath(): String { private fun getRootPath(): String {
return Environment.getExternalStorageDirectory().absolutePath return Environment.getExternalStorageDirectory().absolutePath
} }

View file

@ -27,6 +27,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.util.logD
/** /**
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal * ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal
@ -73,10 +74,13 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
*/ */
fun save(onDone: () -> Unit) { fun save(onDone: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis()
excludedDatabase.writePaths(mPaths.value!!) excludedDatabase.writePaths(mPaths.value!!)
dbPaths = mPaths.value!! dbPaths = mPaths.value!!
onDone() 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() { private fun loadDatabasePaths() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis()
dbPaths = excludedDatabase.readPaths() dbPaths = excludedDatabase.readPaths()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
mPaths.value = dbPaths.toMutableList() mPaths.value = dbPaths.toMutableList()
} }
this@ExcludedViewModel.logD(
"Path load completed successfully in ${System.currentTimeMillis() - start}ms"
)
} }
} }

View file

@ -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)
}
}
}

View file

@ -3,6 +3,7 @@ package org.oxycblt.auxio.home
import android.content.Context import android.content.Context
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator 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. * 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] val tabMode = homeModel.tabs[position]
when { when {
width < 370 -> width < 370 -> {
logD("Using icon-only configuration")
tab.setIcon(tabMode.icon) tab.setIcon(tabMode.icon)
.setContentDescription(tabMode.string) .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) tab.setIcon(tabMode.icon)
.setText(tabMode.string) .setText(tabMode.string)
}
} }
} }
} }

View file

@ -22,6 +22,7 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import org.oxycblt.auxio.util.systemBarInsetsCompat 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. * A container for a FloatingActionButton that enables edge-to-edge support.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class FloatingActionButtonContainer @JvmOverloads constructor( class EdgeFabContainer @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = -1 @AttrRes defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
init { init {
clipToPadding = false clipToPadding = false
@ -44,7 +45,6 @@ class FloatingActionButtonContainer @JvmOverloads constructor(
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarInsetsCompat.bottom) updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets return insets
} }
} }

View file

@ -49,11 +49,14 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow
/** /**
* The main "Launching Point" fragment of Auxio, allowing navigation to the detail * The main "Launching Point" fragment of Auxio, allowing navigation to the detail
* views for each respective item. * views for each respective item.
* @author OxygenCobalt * @author OxygenCobalt
* TODO: Make tabs invisible when there is only one
* TODO: Add duration and song count sorts
*/ */
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
@ -77,16 +80,19 @@ class HomeFragment : Fragment() {
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.action_search -> { R.id.action_search -> {
logD("Navigating to search")
findNavController().navigate(HomeFragmentDirections.actionShowSearch()) findNavController().navigate(HomeFragmentDirections.actionShowSearch())
} }
R.id.action_settings -> { R.id.action_settings -> {
logD("Navigating to settings")
parentFragment?.parentFragment?.findNavController()?.navigate( parentFragment?.parentFragment?.findNavController()?.navigate(
MainFragmentDirections.actionShowSettings() MainFragmentDirections.actionShowSettings()
) )
} }
R.id.action_about -> { R.id.action_about -> {
logD("Navigating to about")
parentFragment?.parentFragment?.findNavController()?.navigate( parentFragment?.parentFragment?.findNavController()?.navigate(
MainFragmentDirections.actionShowAbout() MainFragmentDirections.actionShowAbout()
) )
@ -96,20 +102,16 @@ class HomeFragment : Fragment() {
R.id.option_sort_asc -> { R.id.option_sort_asc -> {
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!) val new = homeModel.getSortForDisplay(homeModel.curTab.value!!)
.ascending(item.isChecked) .ascending(item.isChecked)
homeModel.updateCurrentSort(new) homeModel.updateCurrentSort(new)
} }
// Sorting option was selected, mark it as selected and update the mode // Sorting option was selected, mark it as selected and update the mode
else -> { else -> {
item.isChecked = true item.isChecked = true
val new = homeModel.getSortForDisplay(homeModel.curTab.value!!) val new = homeModel.getSortForDisplay(homeModel.curTab.value!!)
.assignId(item.itemId) .assignId(item.itemId)
homeModel.updateCurrentSort(requireNotNull(new)) homeModel.updateCurrentSort(requireNotNull(new))
} }
} }
@ -141,8 +143,8 @@ class HomeFragment : Fragment() {
set(recycler, slop * 3) // 3x seems to be the best fit here set(recycler, slop * 3) // 3x seems to be the best fit here
} }
} catch (e: Exception) { } catch (e: Exception) {
logE("Unable to reduce ViewPager sensitivity") logE("Unable to reduce ViewPager sensitivity (likely an internal code change)")
logE(e.stackTraceToString()) e.logTraceOrThrow()
} }
// We know that there will only be a fixed amount of tabs, so we manually set this // 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() is MusicStore.Response.Ok -> binding.homeFab.show()
// While loading or during an error, make sure we keep the shuffle fab hidden so // 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. // invariant, so please don't change it.
else -> binding.homeFab.hide() else -> binding.homeFab.hide()
} }
@ -207,7 +209,7 @@ class HomeFragment : Fragment() {
homeModel.curTab.observe(viewLifecycleOwner) { t -> homeModel.curTab.observe(viewLifecycleOwner) { t ->
val tab = requireNotNull(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. // the tab changes.
when (tab) { when (tab) {
DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab) DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab)
@ -229,8 +231,9 @@ class HomeFragment : Fragment() {
} }
detailModel.navToItem.observe(viewLifecycleOwner) { item -> detailModel.navToItem.observe(viewLifecycleOwner) { item ->
// The AppBarLayout gets confused and collapses when we navigate too fast, wait for it // The AppBarLayout gets confused when we navigate too fast, wait for it to draw
// to draw before we continue. // before we navigate.
// This is only here just in case a collapsing toolbar is re-added.
binding.homeAppbar.post { binding.homeAppbar.post {
when (item) { when (item) {
is Song -> findNavController().navigate( is Song -> findNavController().navigate(
@ -255,7 +258,7 @@ class HomeFragment : Fragment() {
} }
} }
logD("Fragment Created.") logD("Fragment Created")
return binding.root return binding.root
} }

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
/** /**
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state. * The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
@ -78,7 +79,6 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
viewModelScope.launch { viewModelScope.launch {
val musicStore = MusicStore.awaitInstance() val musicStore = MusicStore.awaitInstance()
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs) mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums) mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.sortParents(musicStore.artists) 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. * Update the current tab based off of the new ViewPager position.
*/ */
fun updateCurrentTab(pos: Int) { fun updateCurrentTab(pos: Int) {
logD("Updating current tab to ${tabs[pos]}")
mCurTab.value = tabs[pos] mCurTab.value = tabs[pos]
} }
@ -110,6 +111,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
* Update the currently displayed item's [Sort]. * Update the currently displayed item's [Sort].
*/ */
fun updateCurrentSort(sort: Sort) { fun updateCurrentSort(sort: Sort) {
logD("Updating ${mCurTab.value} sort to $sort")
when (mCurTab.value) { when (mCurTab.value) {
DisplayMode.SHOW_SONGS -> { DisplayMode.SHOW_SONGS -> {
settingsManager.libSongSort = sort settingsManager.libSongSort = sort

View file

@ -32,6 +32,7 @@ import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.math.MathUtils import androidx.core.math.MathUtils
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
@ -77,7 +78,7 @@ import kotlin.math.abs
class FastScrollRecyclerView @JvmOverloads constructor( class FastScrollRecyclerView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = -1 @AttrRes defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) { ) : RecyclerView(context, attrs, defStyleAttr) {
/** Callback to provide a string to be shown on the popup when an item is passed */ /** Callback to provide a string to be shown on the popup when an item is passed */
var popupProvider: ((Int) -> String)? = null var popupProvider: ((Int) -> String)? = null

View file

@ -24,9 +24,9 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.toDate
import org.oxycblt.auxio.ui.AlbumViewHolder import org.oxycblt.auxio.ui.AlbumViewHolder
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
@ -43,6 +43,10 @@ class AlbumListFragment : HomeListFragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = AlbumAdapter( val adapter = AlbumAdapter(
@ -54,7 +58,7 @@ class AlbumListFragment : HomeListFragment() {
::newMenu ::newMenu
) )
setupRecycler(R.id.home_album_list, adapter, homeModel.albums) setupRecycler(R.id.home_album_list, binding, adapter, homeModel.albums)
return binding.root return binding.root
} }
@ -74,7 +78,8 @@ class AlbumListFragment : HomeListFragment() {
.first().uppercase() .first().uppercase()
// Year -> Use Full Year // 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 // Unsupported sort, error gracefully
else -> "" else -> ""

View file

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ArtistViewHolder import org.oxycblt.auxio.ui.ArtistViewHolder
@ -40,6 +41,10 @@ class ArtistListFragment : HomeListFragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = ArtistAdapter( val adapter = ArtistAdapter(
@ -51,7 +56,7 @@ class ArtistListFragment : HomeListFragment() {
::newMenu ::newMenu
) )
setupRecycler(R.id.home_artist_list, adapter, homeModel.artists) setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists)
return binding.root return binding.root
} }

View file

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.ui.GenreViewHolder import org.oxycblt.auxio.ui.GenreViewHolder
@ -40,6 +41,10 @@ class GenreListFragment : HomeListFragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = GenreAdapter( val adapter = GenreAdapter(
@ -51,7 +56,7 @@ class GenreListFragment : HomeListFragment() {
::newMenu ::newMenu
) )
setupRecycler(R.id.home_genre_list, adapter, homeModel.genres) setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres)
return binding.root return binding.root
} }

View file

@ -26,9 +26,8 @@ import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel 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.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.applySpans
/** /**
@ -36,10 +35,6 @@ import org.oxycblt.auxio.util.applySpans
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class HomeListFragment : Fragment() { abstract class HomeListFragment : Fragment() {
protected val binding: FragmentHomeListBinding by memberBinding(
FragmentHomeListBinding::inflate
)
protected val homeModel: HomeViewModel by activityViewModels() protected val homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels() protected val playbackModel: PlaybackViewModel by activityViewModels()
@ -48,8 +43,9 @@ abstract class HomeListFragment : Fragment() {
*/ */
abstract val listPopupProvider: (Int) -> String abstract val listPopupProvider: (Int) -> String
protected fun <T : BaseModel, VH : RecyclerView.ViewHolder> setupRecycler( protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler(
@IdRes uniqueId: Int, @IdRes uniqueId: Int,
binding: FragmentHomeListBinding,
homeAdapter: HomeAdapter<T, VH>, homeAdapter: HomeAdapter<T, VH>,
homeData: LiveData<List<T>>, homeData: LiveData<List<T>>,
) { ) {
@ -71,7 +67,7 @@ abstract class HomeListFragment : Fragment() {
} }
} }
abstract class HomeAdapter<T : BaseModel, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() { abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
protected var data = listOf<T>() protected var data = listOf<T>()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")

View file

@ -23,8 +23,8 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toDate
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
@ -41,6 +41,10 @@ class SongListFragment : HomeListFragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
val adapter = SongsAdapter( val adapter = SongsAdapter(
@ -50,7 +54,7 @@ class SongListFragment : HomeListFragment() {
::newMenu ::newMenu
) )
setupRecycler(R.id.home_song_list, adapter, homeModel.songs) setupRecycler(R.id.home_song_list, binding, adapter, homeModel.songs)
return binding.root return binding.root
} }
@ -77,7 +81,8 @@ class SongListFragment : HomeListFragment() {
.first().uppercase() .first().uppercase()
// Year -> Use Full Year // Year -> Use Full Year
is Sort.ByYear -> song.album.year.toDate(requireContext()) is Sort.ByYear -> song.album.year?.toString()
?: getString(R.string.def_date)
} }
} }

View file

@ -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. // For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < SEQUENCE_LEN) { 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 return null
} }

View file

@ -70,14 +70,19 @@ class TabAdapter(
isChecked = tab is Tab.Visible isChecked = tab is Tab.Visible
} }
// Roll our own drag handlers as the default ones suck
binding.tabDragHandle.setOnTouchListener { _, motionEvent -> binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
binding.tabDragHandle.performClick() binding.tabDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) { if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(this) touchHelper.startDrag(this)
true true
} else false } else false
} }
binding.root.setOnLongClickListener {
touchHelper.startDrag(this)
true
}
} }
} }
} }

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.LifecycleDialog 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 * 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) { if (savedInstanceState != null) {
// Restore any pending tab configurations // Restore any pending tab configurations
val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS)) val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
if (tabs != null) { if (tabs != null) {
pendingTabs = tabs pendingTabs = tabs
} }
@ -66,10 +66,9 @@ class TabCustomizeDialog : LifecycleDialog() {
// of how ViewHolders are bound], but instead simply look for the mode in // of how ViewHolders are bound], but instead simply look for the mode in
// the list of pending tabs and update that instead. // the list of pending tabs and update that instead.
val index = pendingTabs.indexOfFirst { it.mode == tab.mode } val index = pendingTabs.indexOfFirst { it.mode == tab.mode }
if (index != -1) { if (index != -1) {
val curTab = pendingTabs[index] val curTab = pendingTabs[index]
logD("Updating tab $curTab to $tab")
pendingTabs[index] = when (curTab) { pendingTabs[index] = when (curTab) {
is Tab.Visible -> Tab.Invisible(curTab.mode) is Tab.Visible -> Tab.Invisible(curTab.mode)
is Tab.Invisible -> Tab.Visible(curTab.mode) is Tab.Invisible -> Tab.Visible(curTab.mode)
@ -93,7 +92,6 @@ class TabCustomizeDialog : LifecycleDialog() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs)) outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs))
} }
@ -101,6 +99,7 @@ class TabCustomizeDialog : LifecycleDialog() {
builder.setTitle(R.string.set_lib_tabs) builder.setTitle(R.string.set_lib_tabs)
builder.setPositiveButton(android.R.string.ok) { _, _ -> builder.setPositiveButton(android.R.string.ok) { _, _ ->
logD("Committing tab changes")
settingsManager.libTabs = pendingTabs settingsManager.libTabs = pendingTabs
} }

View file

@ -25,6 +25,8 @@ import androidx.recyclerview.widget.RecyclerView
/** /**
* A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu. * A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu.
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. * Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple.
* TODO: Consider unifying the shared behavior between this and QueueDragCallback into a single
* class.
*/ */
class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.Callback() { class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.Callback() {
private val tabs: Array<Tab> get() = getTabs() private val tabs: Array<Tab> get() = getTabs()
@ -70,6 +72,9 @@ class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.C
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} 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. * Add the tab adapter to this callback.
* Done because there's a circular dependency between the two objects * Done because there's a circular dependency between the two objects

View file

@ -28,33 +28,37 @@ import androidx.annotation.StringRes
// --- MUSIC MODELS --- // --- MUSIC MODELS ---
/** /**
* The base data object for all music. * The base for all items in Auxio.
* @property id A unique ID for this object. ***THIS IS NOT A MEDIASTORE ID!**
*/ */
sealed class BaseModel { sealed class Item {
/** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
abstract val id: Long abstract val id: Long
} }
/** /**
* A [BaseModel] variant that represents a music item. * [Item] variant that represents a music item.
* @property name The raw name of this track * @property name
*/ */
sealed class Music : BaseModel() { sealed class Music : Item() {
/** The raw name of this item. */
abstract val name: String abstract val name: String
} }
/** /**
* [Music] variant that denotes that this object is a parent of other data objects, such * [Music] variant that denotes that this object is a parent of other data objects, such
* as an [Album] or [Artist] * as an [Album] or [Artist]
* @property resolvedName A name resolved from it's raw form to a form suitable to be shown in * @property resolvedName
* a ui. Ex. "unknown" would become Unknown Artist, (124) would become its proper genre name, etc.
*/ */
sealed class MusicParent : Music() { sealed class MusicParent : Music() {
/**
* A name resolved from it's raw form to a form suitable to be shown in a ui.
* Ex. "unknown" would become Unknown Artist, (124) would become its proper genre name, etc.
*/
abstract val resolvedName: String abstract val resolvedName: String
} }
/** /**
* The data object for a song. Inherits [BaseModel]. * The data object for a song.
*/ */
data class Song( data class Song(
override val name: String, override val name: String,
@ -62,33 +66,33 @@ data class Song(
val fileName: String, val fileName: String,
/** The total duration of this song, in millis. */ /** The total duration of this song, in millis. */
val duration: Long, val duration: Long,
/** The track number of this song. */ /** The track number of this song, null if there isn't any. */
val track: Int, val track: Int?,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _mediaStoreId: Long, val internalMediaStoreId: Long,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _mediaStoreArtistName: String?, val internalMediaStoreYear: Int?,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _mediaStoreAlbumArtistName: String?, val internalMediaStoreAlbumName: String,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _mediaStoreAlbumId: Long, val internalMediaStoreAlbumId: Long,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _mediaStoreAlbumName: String, val internalMediaStoreArtistName: String?,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _mediaStoreYear: Int val internalMediaStoreAlbumArtistName: String?,
) : Music() { ) : Music() {
override val id: Long get() { override val id: Long get() {
var result = name.hashCode().toLong() var result = name.hashCode().toLong()
result = 31 * result + album.name.hashCode() result = 31 * result + album.name.hashCode()
result = 31 * result + album.artist.name.hashCode() result = 31 * result + album.artist.name.hashCode()
result = 31 * result + track result = 31 * result + (track ?: 0)
result = 31 * result + duration.hashCode() result = 31 * result + duration.hashCode()
return result return result
} }
/** The URI for this song. */ /** The URI for this song. */
val uri: Uri get() = ContentUris.withAppendedId( 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) */ /** The duration of this song, in seconds (rounded down) */
val seconds: Long get() = duration / 1000 val seconds: Long get() = duration / 1000
@ -99,9 +103,9 @@ data class Song(
/** The album of this song. */ /** The album of this song. */
val album: Album get() = requireNotNull(mAlbum) val album: Album get() = requireNotNull(mAlbum)
var mGenre: Genre? = null private var mGenre: Genre? = null
/** The genre of this song. May be null due to MediaStore insanity. */ /** The genre of this song. Will be an "unknown genre" if the song does not have any. */
val genre: Genre? get() = mGenre val genre: Genre get() = requireNotNull(mGenre)
/** An album name resolved to this song in particular. */ /** An album name resolved to this song in particular. */
val resolvedAlbumName: String get() = val resolvedAlbumName: String get() =
@ -109,43 +113,61 @@ data class Song(
/** An artist name resolved to this song in particular. */ /** An artist name resolved to this song in particular. */
val resolvedArtistName: String get() = val resolvedArtistName: String 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. */ /** Internal method. Do not use. */
fun mediaStoreLinkAlbum(album: Album) { fun internalLinkAlbum(album: Album) {
mAlbum = album mAlbum = album
} }
/** Internal method. Do not use. */ /** Internal method. Do not use. */
fun mediaStoreLinkGenre(genre: Genre) { fun internalLinkGenre(genre: Genre) {
mGenre = genre mGenre = genre
} }
} }
/** /**
* The data object for an album. Inherits [MusicParent]. * The data object for an album.
*/ */
data class Album( data class Album(
override val name: String, override val name: String,
/** The latest year of the songs in this album. */ /** The latest year of the songs in this album. Null if none of the songs had metadata. */
val year: Int, val year: Int?,
/** The URI for the cover art corresponding to this album. */ /** The URI for the cover art corresponding to this album. */
val albumCoverUri: Uri, val albumCoverUri: Uri,
/** The songs of this album. */ /** The songs of this album. */
val songs: List<Song>, val songs: List<Song>,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _mediaStoreArtistName: String, val internalGroupingArtistName: String,
) : MusicParent() { ) : MusicParent() {
init { init {
for (song in songs) { for (song in songs) {
song.mediaStoreLinkAlbum(this) song.internalLinkAlbum(this)
} }
} }
override val id: Long get() { override val id: Long get() {
var result = name.hashCode().toLong() var result = name.hashCode().toLong()
result = 31 * result + artist.name.hashCode() result = 31 * result + artist.name.hashCode()
result = 31 * result + year result = 31 * result + (year ?: 0)
return result return result
} }
@ -164,8 +186,11 @@ data class Album(
val resolvedArtistName: String get() = val resolvedArtistName: String get() =
artist.resolvedName artist.resolvedName
/** Internal field. Do not use. */
val internalIsMissingArtist: Boolean = mArtist != null
/** Internal method. Do not use. */ /** Internal method. Do not use. */
fun mediaStoreLinkArtist(artist: Artist) { fun internalLinkArtist(artist: Artist) {
mArtist = artist mArtist = artist
} }
} }
@ -182,7 +207,7 @@ data class Artist(
) : MusicParent() { ) : MusicParent() {
init { init {
for (album in albums) { 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( data class Genre(
override val name: String, override val name: String,
@ -202,7 +227,7 @@ data class Genre(
) : MusicParent() { ) : MusicParent() {
init { init {
for (song in songs) { for (song in songs) {
song.mediaStoreLinkGenre(this) song.internalLinkGenre(this)
} }
} }
@ -220,7 +245,7 @@ data class Header(
override val id: Long, override val id: Long,
/** The string resource used for the header. */ /** The string resource used for the header. */
@StringRes val string: Int @StringRes val string: Int
) : BaseModel() ) : Item()
/** /**
* A data object used for an action header. Like [Header], but with a button. * 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, @StringRes val desc: Int,
/** A callback for when this item is clicked. */ /** A callback for when this item is clicked. */
val onClick: (View) -> Unit, val onClick: (View) -> Unit,
) : BaseModel() { ) : Item() {
// All lambdas are not equal to each-other, so we override equals/hashCode and exclude them. // All lambdas are not equal to each-other, so we override equals/hashCode and exclude them.
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {

View file

@ -4,11 +4,12 @@ import android.content.ContentUris
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.excluded.ExcludedDatabase import org.oxycblt.auxio.excluded.ExcludedDatabase
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logD
import java.lang.Exception
/** /**
* This class acts as the base for most the black magic required to get a remotely sensible music * This class acts as the base for most the black magic required to get a remotely sensible music
@ -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 * 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 * 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 * 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 * 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 * own Google Play Music, and of course every Google Play Music user knew how great that turned
* out! * 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 * It's not even ergonomics that makes this API bad. It's base implementation is completely borked
* as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? * as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files?
* I sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see * 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 * 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 * 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 * 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 * 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) * 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 * 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 * 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 * 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 * 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 * 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. * 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 * 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. * I wish I was born in the neolithic.
* *
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@Suppress("InlinedApi")
class MusicLoader { class MusicLoader {
data class Library( data class Library(
val genres: List<Genre>, val genres: List<Genre>,
@ -89,13 +89,18 @@ class MusicLoader {
val artists = buildArtists(context, albums) val artists = buildArtists(context, albums)
val genres = readGenres(context, songs) 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) { for (song in songs) {
try { if (song.internalIsMissingAlbum ||
song.album.artist song.internalIsMissingArtist ||
} catch (e: Exception) { song.internalIsMissingGenre
logE("Found malformed song: ${song.name}") ) {
throw e 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. // 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 // 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. // 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) { for (path in paths) {
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?" selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children 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( context.applicationContext.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
arrayOf( arrayOf(
MediaStore.Audio.AudioColumns._ID, MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.TITLE, MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.DISPLAY_NAME, 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,
MediaStore.Audio.AudioColumns.ALBUM_ID, MediaStore.Audio.AudioColumns.ALBUM_ID,
MediaStore.Audio.AudioColumns.ARTIST, MediaStore.Audio.AudioColumns.ARTIST,
MediaStore.Audio.AudioColumns.ALBUM_ARTIST, AUDIO_COLUMN_ALBUM_ARTIST
MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.TRACK,
MediaStore.Audio.AudioColumns.DURATION,
), ),
selector, args.toTypedArray(), null selector, args.toTypedArray(), null
)?.use { cursor -> )?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID) val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST) val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ARTIST) val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_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)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val title = cursor.getString(titleIndex)
val fileName = cursor.getString(fileIndex) 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 album = cursor.getString(albumIndex)
val albumId = cursor.getLong(albumIdIndex) val albumId = cursor.getLong(albumIdIndex)
// If the artist field is <unknown>, make it null. This makes handling the // If the artist field is <unknown>, make it null. This makes handling the
// insanity of the artist field easier later on. // insanity of the artist field easier later on.
val artist = cursor.getString(artistIndex).let { val artist = cursor.getStringOrNull(artistIndex)?.run {
if (it != MediaStore.UNKNOWN_STRING) it else null if (this == MediaStore.UNKNOWN_STRING) {
null
} else {
this
}
} }
val albumArtist = cursor.getStringOrNull(albumArtistIndex) val albumArtist = cursor.getStringOrNull(albumArtistIndex)
val year = cursor.getInt(yearIndex) // Note: Directory parsing is currently disabled until artist images are added.
val track = cursor.getInt(trackIndex) // val dirs = cursor.getStringOrNull(dataIndex)?.run {
val duration = cursor.getLong(durationIndex) // substringBeforeLast("/", "").ifEmpty { null }
// }
songs.add( songs.add(
Song( Song(
@ -176,29 +201,28 @@ class MusicLoader {
duration, duration,
track, track,
id, id,
year,
album,
albumId,
artist, artist,
albumArtist, albumArtist,
albumId,
album,
year,
) )
) )
} }
} }
// Deduplicate songs to prevent (most) deformed music clones
songs = songs.distinctBy { 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() }.toMutableList()
logD("Successfully loaded ${songs.size} songs")
return songs return songs
} }
private fun buildAlbums(songs: List<Song>): List<Album> { private fun buildAlbums(songs: List<Song>): List<Album> {
// 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: // 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". // 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" // 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. // the template, but it seems to work pretty well.
val albums = mutableListOf<Album>() val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { song -> val songsByAlbum = songs.groupBy { song ->
val albumName = song._mediaStoreAlbumName song.internalGroupingId
val artistName = song.resolveAlbumArtistName()
Pair(albumName.lowercase(), artistName.lowercase())
} }
for (entry in songsByAlbum) { for (entry in songsByAlbum) {
@ -220,14 +242,17 @@ class MusicLoader {
// Use the song with the latest year as our metadata song. // 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 // This allows us to replicate the LAST_YEAR field, which is useful as it means that
// weird years like "0" wont show up if there are alternatives. // weird years like "0" wont show up if there are alternatives.
val templateSong = requireNotNull(albumSongs.maxByOrNull { it._mediaStoreYear }) // TODO: Weigh songs with null years lower than songs with zero years
val albumName = templateSong._mediaStoreAlbumName val templateSong = requireNotNull(
val albumYear = templateSong._mediaStoreYear albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 }
)
val albumName = templateSong.internalMediaStoreAlbumName
val albumYear = templateSong.internalMediaStoreYear
val albumCoverUri = ContentUris.withAppendedId( val albumCoverUri = ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"), Uri.parse("content://media/external/audio/albumart"),
templateSong._mediaStoreAlbumId templateSong.internalMediaStoreAlbumId
) )
val artistName = templateSong.resolveAlbumArtistName() val artistName = templateSong.internalGroupingArtistName
albums.add( albums.add(
Album( Album(
@ -240,12 +265,14 @@ class MusicLoader {
) )
} }
logD("Successfully built ${albums.size} albums")
return albums return albums
} }
private fun buildArtists(context: Context, albums: List<Album>): List<Artist> { private fun buildArtists(context: Context, albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>() val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it._mediaStoreArtistName } val albumsByArtist = albums.groupBy { it.internalGroupingArtistName }
for (entry in albumsByArtist) { for (entry in albumsByArtist) {
val artistName = entry.key val artistName = entry.key
@ -255,14 +282,17 @@ class MusicLoader {
} }
val artistAlbums = entry.value val artistAlbums = entry.value
// Due to the black magic we do to get a good artist field, the ID is unreliable. // Album deduplication does not eliminate every case of fragmented artists, do
// Take a hash of the artist name instead. // 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 -> val previousArtistIndex = artists.indexOfFirst { artist ->
artist.name.lowercase() == artistName.lowercase() artist.name.lowercase() == artistName.lowercase()
} }
if (previousArtistIndex > -1) { if (previousArtistIndex > -1) {
val previousArtist = artists[previousArtistIndex] val previousArtist = artists[previousArtistIndex]
logD("Merging duplicate artist into pre-existing artist ${previousArtist.name}")
artists[previousArtistIndex] = Artist( artists[previousArtistIndex] = Artist(
previousArtist.name, previousArtist.name,
previousArtist.resolvedName, previousArtist.resolvedName,
@ -279,13 +309,15 @@ class MusicLoader {
} }
} }
logD("Successfully built ${artists.size} artists")
return artists return artists
} }
private fun readGenres(context: Context, songs: List<Song>): List<Genre> { private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>() val genres = mutableListOf<Genre>()
val genreCursor = context.contentResolver.query( val genreCursor = context.applicationContext.contentResolver.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf( arrayOf(
MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres._ID,
@ -305,7 +337,7 @@ class MusicLoader {
// so we skip genres that have them. // so we skip genres that have them.
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue val name = cursor.getStringOrNull(nameIndex) ?: continue
val resolvedName = name.getGenreNameCompat() ?: name val resolvedName = name.genreNameCompat ?: name
val genreSongs = queryGenreSongs(context, id, songs) ?: continue val genreSongs = queryGenreSongs(context, id, songs) ?: continue
genres.add( genres.add(
@ -318,7 +350,7 @@ class MusicLoader {
} }
} }
val songsWithoutGenres = songs.filter { it.genre == null } val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
if (songsWithoutGenres.isNotEmpty()) { if (songsWithoutGenres.isNotEmpty()) {
// Songs that don't have a genre will be thrown into an unknown genre. // Songs that don't have a genre will be thrown into an unknown genre.
@ -331,6 +363,8 @@ class MusicLoader {
genres.add(unknownGenre) genres.add(unknownGenre)
} }
logD("Successfully loaded ${genres.size} genres")
return genres return genres
} }
@ -338,7 +372,7 @@ class MusicLoader {
val genreSongs = mutableListOf<Song>() val genreSongs = mutableListOf<Song>()
// Don't even bother blacklisting here as useless iterations are less expensive than IO // 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), MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID), arrayOf(MediaStore.Audio.Genres.Members._ID),
null, null, null null, null, null
@ -349,15 +383,87 @@ class MusicLoader {
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
songs.find { it.internalMediaStoreId == id }?.let { song ->
songs.find { it._mediaStoreId == id }?.let { song ->
genreSongs.add(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. // If that is the case, we drop them.
return genreSongs.ifEmpty { null } 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"
)
}
} }

View file

@ -19,7 +19,6 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -36,6 +35,7 @@ import java.lang.Exception
* The main storage for music items. * The main storage for music items.
* Getting an instance of this object is more complicated as it loads asynchronously. * Getting an instance of this object is more complicated as it loads asynchronously.
* See the companion object for more. * See the companion object for more.
* TODO: Add automatic rescanning [major change]
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MusicStore private constructor() { 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. * Load/Sort the entire music library. Should always be ran on a coroutine.
*/ */
private fun load(context: Context): Response { private fun load(context: Context): Response {
logD("Starting initial music load...") logD("Starting initial music load")
val notGranted = ContextCompat.checkSelfPermission( val notGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_EXTERNAL_STORAGE context, Manifest.permission.READ_EXTERNAL_STORAGE
@ -69,18 +69,18 @@ class MusicStore private constructor() {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val loader = MusicLoader() 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 mSongs = library.songs
mAlbums = library.albums mAlbums = library.albums
mArtists = library.artists mArtists = library.artists
mGenres = library.genres 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) { } catch (e: Exception) {
logE("Something went horribly wrong.") logE("Music loading failed.")
logE(e.stackTraceToString()) logE(e.stackTraceToString())
return Response.Err(ErrorKind.FAILED) 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. * @return The corresponding [Song] for this [uri], null if there isn't one.
*/ */
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? { fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
val cur = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) resolver.query(
uri,
cur?.use { cursor -> arrayOf(OpenableColumns.DISPLAY_NAME),
null, null, null
)?.use { cursor ->
cursor.moveToFirst() cursor.moveToFirst()
val fileName = cursor.getString(
// Make studio shut up about "invalid ranges" that don't exist cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
@SuppressLint("Range") )
val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
return songs.find { it.fileName == fileName } return songs.find { it.fileName == fileName }
} }
@ -117,6 +118,7 @@ class MusicStore private constructor() {
/** /**
* A response that [MusicStore] returns when loading music. * A response that [MusicStore] returns when loading music.
* And before you ask, yes, I do like rust. * And before you ask, yes, I do like rust.
* TODO: Replace this with the kotlin builtin
*/ */
sealed class Response { sealed class Response {
class Ok(val musicStore: MusicStore) : Response() class Ok(val musicStore: MusicStore) : Response()
@ -145,11 +147,9 @@ class MusicStore private constructor() {
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
val response = MusicStore().load(context) val response = MusicStore().load(context)
synchronized(this) { synchronized(this) {
RESPONSE = response RESPONSE = response
} }
response response
} }
@ -201,7 +201,7 @@ class MusicStore private constructor() {
*/ */
fun requireInstance(): MusicStore { fun requireInstance(): MusicStore {
return requireNotNull(maybeGetInstance()) { return requireNotNull(maybeGetInstance()) {
"Required MusicStore instance was not available." "Required MusicStore instance was not available"
} }
} }

View file

@ -18,79 +18,16 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context
import android.text.format.DateUtils import android.text.format.DateUtils
import android.widget.TextView import android.widget.TextView
import androidx.core.text.isDigitsOnly
import androidx.databinding.BindingAdapter import androidx.databinding.BindingAdapter
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.logD
/** import org.oxycblt.auxio.util.logW
* 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"
)
// --- EXTENSION FUNCTIONS --- // --- 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. * Convert a [Long] of seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then * @param isElapsed Whether this duration is represents elapsed time. If this is false, then
@ -98,6 +35,7 @@ fun String.getGenreNameCompat(): String? {
*/ */
fun Long.toDuration(isElapsed: Boolean): String { fun Long.toDuration(isElapsed: Boolean): String {
if (!isElapsed && this == 0L) { if (!isElapsed && this == 0L) {
logD("Non-elapsed duration is zero, using --:--")
return "--:--" return "--:--"
} }
@ -111,24 +49,56 @@ fun Long.toDuration(isElapsed: Boolean): String {
return durationString return durationString
} }
fun Int.toDate(context: Context): String {
return if (this == 0) {
context.getString(R.string.def_date)
} else {
toString()
}
}
// --- BINDING ADAPTERS --- // --- BINDING ADAPTERS ---
/** @BindingAdapter("songInfo")
* Bind the album + song counts for an artist fun TextView.bindSongInfo(song: Song?) {
*/ if (song == null) {
@BindingAdapter("artistCounts") logW("Song was null, not applying info")
fun TextView.bindArtistCounts(artist: Artist) { return
}
text = context.getString( 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_album_count, artist.albums.size),
context.getPluralSafe(R.plurals.fmt_song_count, artist.songs.size) context.getPluralSafe(R.plurals.fmt_song_count, artist.songs.size)
) )
} }
@BindingAdapter("genreInfo")
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)
}

View file

@ -24,6 +24,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.logD
class MusicViewModel : ViewModel() { class MusicViewModel : ViewModel() {
private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null) private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null)
@ -37,6 +38,7 @@ class MusicViewModel : ViewModel() {
*/ */
fun loadMusic(context: Context) { fun loadMusic(context: Context) {
if (mLoaderResponse.value != null || isBusy) { if (mLoaderResponse.value != null || isBusy) {
logD("Loader is busy/already completed, not reloading")
return return
} }
@ -45,15 +47,14 @@ class MusicViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
val result = MusicStore.initInstance(context) val result = MusicStore.initInstance(context)
isBusy = false
mLoaderResponse.value = result mLoaderResponse.value = result
isBusy = false
} }
} }
fun reloadMusic(context: Context) { fun reloadMusic(context: Context) {
logD("Reloading music library")
mLoaderResponse.value = null mLoaderResponse.value = null
loadMusic(context) loadMusic(context)
} }
} }

View file

@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackBarView @JvmOverloads constructor( class PlaybackBarView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = -1 defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) { ) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewPlaybackBarBinding.inflate(context.inflater, this, true) private val binding = ViewPlaybackBarBinding.inflate(context.inflater, this, true)

View file

@ -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)
}
}
}

View file

@ -32,7 +32,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBinding import org.oxycblt.auxio.databinding.FragmentPlaybackBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat 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. * A [Fragment] that displays more information about the song, along with more media controls.
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.** * Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
* @author OxygenCobalt * @author OxygenCobalt
* TODO: Handle RTL correctly in the playback buttons
*/ */
class PlaybackFragment : Fragment() { class PlaybackFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val binding by memberBinding(FragmentPlaybackBinding::inflate) { private var mLastBinding: FragmentPlaybackBinding? = null
playbackSong.isSelected = false // Clear marquee to prevent a memory leak
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = FragmentPlaybackBinding.inflate(layoutInflater)
val queueItem: MenuItem val queueItem: MenuItem
// See onDestroyView for why we do this
mLastBinding = binding
// --- UI SETUP --- // --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
@ -93,6 +95,7 @@ class PlaybackFragment : Fragment() {
binding.playbackSong.isSelected = true binding.playbackSong.isSelected = true
binding.playbackSeekBar.onConfirmListener = playbackModel::setPosition binding.playbackSeekBar.onConfirmListener = playbackModel::setPosition
// Abuse the play/pause FAB (see style definition for more info)
binding.playbackPlayPause.post { binding.playbackPlayPause.post {
binding.playbackPlayPause.stateListAnimator = null binding.playbackPlayPause.stateListAnimator = null
} }
@ -101,11 +104,11 @@ class PlaybackFragment : Fragment() {
playbackModel.song.observe(viewLifecycleOwner) { song -> playbackModel.song.observe(viewLifecycleOwner) { song ->
if (song != null) { if (song != null) {
logD("Updating song display to ${song.name}.") logD("Updating song display to ${song.name}")
binding.song = song binding.song = song
binding.playbackSeekBar.setDuration(song.seconds) binding.playbackSeekBar.setDuration(song.seconds)
} else { } else {
logD("No song is being played, leaving.") logD("No song is being played, leaving")
findNavController().navigateUp() findNavController().navigateUp()
} }
} }
@ -126,7 +129,10 @@ class PlaybackFragment : Fragment() {
LoopMode.TRACK -> R.drawable.ic_loop_one 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 -> playbackModel.position.observe(viewLifecycleOwner) { pos ->
@ -149,11 +155,20 @@ class PlaybackFragment : Fragment() {
} }
} }
logD("Fragment Created.") logD("Fragment Created")
return binding.root 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() { private fun navigateUp() {
// This is a dumb and fragile hack but this fragment isn't part of the navigation stack // This is a dumb and fragile hack but this fragment isn't part of the navigation stack
// so we can't really do much // so we can't really do much

View file

@ -23,11 +23,13 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenSafe import org.oxycblt.auxio.util.getDimenSafe
import org.oxycblt.auxio.util.getDrawableSafe import org.oxycblt.auxio.util.getDrawableSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.pxOfDp 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.stateList
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
import kotlin.math.abs import kotlin.math.abs
@ -46,6 +48,7 @@ import kotlin.math.min
* or extendable. You have been warned. * or extendable. You have been warned.
* *
* @author OxygenCobalt (With help from Umano and Hai Zhang) * @author OxygenCobalt (With help from Umano and Hai Zhang)
* TODO: Find a better way to handle PlaybackFragment in general (navigation, creation)
*/ */
class PlaybackLayout @JvmOverloads constructor( class PlaybackLayout @JvmOverloads constructor(
context: Context, context: Context,
@ -98,6 +101,7 @@ class PlaybackLayout @JvmOverloads constructor(
private var initMotionX = 0f private var initMotionX = 0f
private var initMotionY = 0f private var initMotionY = 0f
private val tRect = Rect() private val tRect = Rect()
private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal) private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
/** See [isDragging] */ /** See [isDragging] */
@ -129,6 +133,8 @@ class PlaybackLayout @JvmOverloads constructor(
background = (context.getDrawableSafe(R.drawable.ui_panel_bg) as LayerDrawable).apply { background = (context.getDrawableSafe(R.drawable.ui_panel_bg) as LayerDrawable).apply {
setDrawableByLayerId(R.id.panel_overlay, playbackContainerBg) setDrawableByLayerId(R.id.panel_overlay, playbackContainerBg)
} }
disableDropShadowCompat()
} }
playbackBarView = PlaybackBarView(context).apply { playbackBarView = PlaybackBarView(context).apply {
@ -223,6 +229,8 @@ class PlaybackLayout @JvmOverloads constructor(
} }
private fun applyState(state: PanelState) { 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 // Dragging events are really complex and we don't want to mess up the state
// while we are in one. // while we are in one.
if (state == panelState || panelState == PanelState.DRAGGING) { 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 // 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] // to reflect the presence of the panel [at least in it's collapsed state]
playbackContainerView.dispatchApplyWindowInsets(insets) playbackContainerView.dispatchApplyWindowInsets(insets)
lastInsets = insets lastInsets = insets
applyContentWindowInsets() applyContentWindowInsets()
return insets return insets
} }
@ -368,7 +374,6 @@ class PlaybackLayout @JvmOverloads constructor(
*/ */
private fun applyContentWindowInsets() { private fun applyContentWindowInsets() {
val insets = lastInsets val insets = lastInsets
if (insets != null) { if (insets != null) {
contentView.dispatchApplyWindowInsets(adjustInsets(insets)) contentView.dispatchApplyWindowInsets(adjustInsets(insets))
} }
@ -384,8 +389,9 @@ class PlaybackLayout @JvmOverloads constructor(
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0) val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)
return insets.replaceSystemBarInsetsCompat(
return insets.replaceInsetsCompat(bars.left, bars.top, bars.right, adjustedBottomInset) bars.left, bars.top, bars.right, adjustedBottomInset
)
} }
override fun onSaveInstanceState(): Parcelable = Bundle().apply { override fun onSaveInstanceState(): Parcelable = Bundle().apply {
@ -584,6 +590,8 @@ class PlaybackLayout @JvmOverloads constructor(
(computePanelTopPosition(0f) - topPosition).toFloat() / panelRange (computePanelTopPosition(0f) - topPosition).toFloat() / panelRange
private fun smoothSlideTo(offset: Float) { private fun smoothSlideTo(offset: Float) {
logD("Smooth sliding to $offset")
val okay = dragHelper.smoothSlideViewTo( val okay = dragHelper.smoothSlideViewTo(
playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset) playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset)
) )

View file

@ -29,19 +29,21 @@ import org.oxycblt.auxio.databinding.ViewSeekBarBinding
import org.oxycblt.auxio.music.toDuration import org.oxycblt.auxio.music.toDuration
import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.stateList
/** /**
* A custom view that bundles together a seekbar with a current duration and a total duration. * A custom view that bundles together a seekbar with a current duration and a total duration.
* The sub-views are specifically laid out so that the seekbar has an adequate touch height while * The sub-views are specifically laid out so that the seekbar has an adequate touch height while
* still not having gobs of whitespace everywhere. * still not having gobs of whitespace everywhere.
* TODO: Add smooth seeking [i.e seeking in sub-second values]
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class PlaybackSeekBar @JvmOverloads constructor( class PlaybackSeekBar @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleRes: Int = -1 defStyleRes: Int = 0
) : ConstraintLayout(context, attrs, defStyleRes), Slider.OnChangeListener, Slider.OnSliderTouchListener { ) : ConstraintLayout(context, attrs, defStyleRes), Slider.OnChangeListener, Slider.OnSliderTouchListener {
private val binding = ViewSeekBarBinding.inflate(context.inflater, this, true) private val binding = ViewSeekBarBinding.inflate(context.inflater, this, true)
private val isSeeking: Boolean get() = binding.playbackDurationCurrent.isActivated private val isSeeking: Boolean get() = binding.playbackDurationCurrent.isActivated
@ -73,6 +75,7 @@ class PlaybackSeekBar @JvmOverloads constructor(
// - The duration of the song was so low as to be rounded to zero when converted // - The duration of the song was so low as to be rounded to zero when converted
// to seconds. // to seconds.
// In either of these cases, the seekbar is more or less useless. Disable it. // In either of these cases, the seekbar is more or less useless. Disable it.
logD("Duration is 0, entering disabled state")
binding.seekBar.apply { binding.seekBar.apply {
valueTo = 1f valueTo = 1f
isEnabled = false isEnabled = false

View file

@ -111,7 +111,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/ */
fun playAlbum(album: Album, shuffled: Boolean) { fun playAlbum(album: Album, shuffled: Boolean) {
if (album.songs.isEmpty()) { if (album.songs.isEmpty()) {
logE("Album is empty, Not playing.") logE("Album is empty, Not playing")
return return
} }
@ -125,7 +125,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/ */
fun playArtist(artist: Artist, shuffled: Boolean) { fun playArtist(artist: Artist, shuffled: Boolean) {
if (artist.songs.isEmpty()) { if (artist.songs.isEmpty()) {
logE("Artist is empty, Not playing.") logE("Artist is empty, Not playing")
return return
} }
@ -139,7 +139,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/ */
fun playGenre(genre: Genre, shuffled: Boolean) { fun playGenre(genre: Genre, shuffled: Boolean) {
if (genre.songs.isEmpty()) { if (genre.songs.isEmpty()) {
logE("Genre is empty, Not playing.") logE("Genre is empty, Not playing")
return return
} }
@ -156,7 +156,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
if (playbackManager.isRestored && MusicStore.loaded()) { if (playbackManager.isRestored && MusicStore.loaded()) {
playWithUriInternal(uri, context) playWithUriInternal(uri, context)
} else { } else {
logD("Cant play this URI right now, waiting...") logD("Cant play this URI right now, waiting")
mIntentUri = uri 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. * [apply] is called just before the change is committed so that the adapter can be updated.
*/ */
fun removeQueueDataItem(adapterIndex: Int, apply: () -> Unit) { fun removeQueueDataItem(adapterIndex: Int, apply: () -> Unit) {
val adjusted = adapterIndex + (playbackManager.queue.size - mNextUp.value!!.size) val index = adapterIndex + (playbackManager.queue.size - mNextUp.value!!.size)
logD("$adjusted") if (index in playbackManager.queue.indices) {
if (adjusted in playbackManager.queue.indices) {
apply() 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 { fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int, apply: () -> Unit): Boolean {
val delta = (playbackManager.queue.size - mNextUp.value!!.size) val delta = (playbackManager.queue.size - mNextUp.value!!.size)
val from = adapterFrom + delta val from = adapterFrom + delta
val to = adapterTo + delta val to = adapterTo + delta
if (from in playbackManager.queue.indices && to in playbackManager.queue.indices) { if (from in playbackManager.queue.indices && to in playbackManager.queue.indices) {
apply() apply()
playbackManager.moveQueueItems(from, to) playbackManager.moveQueueItems(from, to)
@ -332,7 +328,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* [PlaybackStateManager] instance. * [PlaybackStateManager] instance.
*/ */
private fun restorePlaybackState() { private fun restorePlaybackState() {
logD("Attempting to restore playback state.") logD("Attempting to restore playback state")
onSongUpdate(playbackManager.song) onSongUpdate(playbackManager.song)
onPositionUpdate(playbackManager.position) onPositionUpdate(playbackManager.position)

View file

@ -30,13 +30,14 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.databinding.ItemQueueSongBinding import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.stateList
@ -49,7 +50,7 @@ import org.oxycblt.auxio.util.stateList
class QueueAdapter( class QueueAdapter(
private val touchHelper: ItemTouchHelper private val touchHelper: ItemTouchHelper
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var data = mutableListOf<BaseModel>() private var data = mutableListOf<Item>()
private var listDiffer = AsyncListDiffer(this, DiffCallback()) private var listDiffer = AsyncListDiffer(this, DiffCallback())
override fun getItemCount(): Int = data.size override fun getItemCount(): Int = data.size
@ -69,11 +70,9 @@ class QueueAdapter(
QUEUE_SONG_ITEM_TYPE -> QueueSongViewHolder( QUEUE_SONG_ITEM_TYPE -> QueueSongViewHolder(
ItemQueueSongBinding.inflate(parent.context.inflater) ItemQueueSongBinding.inflate(parent.context.inflater)
) )
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context) HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context) ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type $viewType")
else -> error("Invalid ViewHolder item type $viewType.")
} }
} }
@ -82,8 +81,7 @@ class QueueAdapter(
is Song -> (holder as QueueSongViewHolder).bind(item) is Song -> (holder as QueueSongViewHolder).bind(item)
is Header -> (holder as HeaderViewHolder).bind(item) is Header -> (holder as HeaderViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
else -> logE("Bad data given to QueueAdapter")
else -> logE("Bad data given to QueueAdapter.")
} }
} }
@ -91,10 +89,9 @@ class QueueAdapter(
* Submit data using [AsyncListDiffer]. * Submit data using [AsyncListDiffer].
* **Only use this if you have no idea what changes occurred to the data** * **Only use this if you have no idea what changes occurred to the data**
*/ */
fun submitList(newData: MutableList<BaseModel>) { fun submitList(newData: MutableList<Item>) {
if (data != newData) { if (data != newData) {
data = newData data = newData
listDiffer.submitList(newData) listDiffer.submitList(newData)
} }
} }
@ -132,6 +129,8 @@ class QueueAdapter(
).apply { ).apply {
fillColor = (binding.body.background as ColorDrawable).color.stateList fillColor = (binding.body.background as ColorDrawable).color.stateList
} }
binding.root.disableDropShadowCompat()
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
@ -143,14 +142,19 @@ class QueueAdapter(
binding.songName.requestLayout() binding.songName.requestLayout()
binding.songInfo.requestLayout() binding.songInfo.requestLayout()
// Roll our own drag handlers as the default ones suck
binding.songDragHandle.setOnTouchListener { _, motionEvent -> binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick() binding.songDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) { if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(this) touchHelper.startDrag(this)
true true
} else false } else false
} }
binding.body.setOnLongClickListener {
touchHelper.startDrag(this)
true
}
} }
} }

View file

@ -27,6 +27,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.getDimenSafe import org.oxycblt.auxio.util.getDimenSafe
import org.oxycblt.auxio.util.logD
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -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 // 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 // work! To emulate it on my own, I check if this child is in a drag state and then animate
// an elevation change. // an elevation change.
val holder = viewHolder as QueueAdapter.QueueSongViewHolder val holder = viewHolder as QueueAdapter.QueueSongViewHolder
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
logD("Lifting queue item")
val bg = holder.bodyView.background as MaterialShapeDrawable val bg = holder.bodyView.background as MaterialShapeDrawable
val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_small) val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_small)
holder.itemView.animate() holder.itemView.animate()
.translationZ(elevation) .translationZ(elevation)
.setDuration(100) .setDuration(100)
@ -127,9 +128,10 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
// When an elevated item is cleared, we reset the elevation using another animation. // When an elevated item is cleared, we reset the elevation using another animation.
val holder = viewHolder as QueueAdapter.QueueSongViewHolder val holder = viewHolder as QueueAdapter.QueueSongViewHolder
if (holder.itemView.translationZ != 0.0f) { if (holder.itemView.translationZ != 0f) {
val bg = holder.bodyView.background as MaterialShapeDrawable logD("Dropping queue item")
val bg = holder.bodyView.background as MaterialShapeDrawable
holder.itemView.animate() holder.itemView.animate()
.translationZ(0.0f) .translationZ(0.0f)
.setDuration(100) .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. * Add the queue adapter to this callback.
* Done because there's a circular dependency between the two objects * Done because there's a circular dependency between the two objects

View file

@ -28,6 +28,7 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.logD
/** /**
* A [Fragment] that shows the queue and enables editing as well. * A [Fragment] that shows the queue and enables editing as well.
@ -42,15 +43,13 @@ class QueueFragment : Fragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = FragmentQueueBinding.inflate(inflater) val binding = FragmentQueueBinding.inflate(inflater)
val callback = QueueDragCallback(playbackModel) val callback = QueueDragCallback(playbackModel)
val helper = ItemTouchHelper(callback) val helper = ItemTouchHelper(callback)
val queueAdapter = QueueAdapter(helper) val queueAdapter = QueueAdapter(helper)
var lastShuffle = playbackModel.isShuffling.value
callback.addQueueAdapter(queueAdapter) callback.addQueueAdapter(queueAdapter)
var lastShuffle = playbackModel.isShuffling.value
// --- UI SETUP --- // --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
@ -77,9 +76,11 @@ class QueueFragment : Fragment() {
} }
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling -> 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) { if (isShuffling != lastShuffle) {
logD("Reshuffle event, scrolling to top")
lastShuffle = isShuffling lastShuffle = isShuffling
binding.queueRecycler.scrollToPosition(0) binding.queueRecycler.scrollToPosition(0)
} }
} }

View file

@ -48,10 +48,10 @@ class PlaybackStateDatabase(context: Context) :
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db) override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
private fun nuke(db: SQLiteDatabase) { private fun nuke(db: SQLiteDatabase) {
logD("Nuking database")
db.apply { db.apply {
execSQL("DROP TABLE IF EXISTS $TABLE_NAME_STATE") execSQL("DROP TABLE IF EXISTS $TABLE_NAME_STATE")
execSQL("DROP TABLE IF EXISTS $TABLE_NAME_QUEUE") execSQL("DROP TABLE IF EXISTS $TABLE_NAME_QUEUE")
onCreate(this) onCreate(this)
} }
} }
@ -103,34 +103,6 @@ class PlaybackStateDatabase(context: Context) :
// --- INTERFACE FUNCTIONS --- // --- 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. * Read the stored [SavedState] from the database, if there is one.
* @param musicStore Required to transform database songs/parents into actual instances * @param musicStore Required to transform database songs/parents into actual instances
@ -178,11 +150,69 @@ class PlaybackStateDatabase(context: Context) :
isShuffling = cursor.getInt(shuffleIndex) == 1, isShuffling = cursor.getInt(shuffleIndex) == 1,
loopMode = LoopMode.fromInt(cursor.getInt(loopModeIndex)) ?: LoopMode.NONE, loopMode = LoopMode.fromInt(cursor.getInt(loopModeIndex)) ?: LoopMode.NONE,
) )
logD("Successfully read playback state: $state")
} }
return 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<Song> {
assertBackgroundThread()
val queue = mutableListOf<Song>()
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. * Write a queue to the database.
*/ */
@ -190,12 +220,11 @@ class PlaybackStateDatabase(context: Context) :
assertBackgroundThread() assertBackgroundThread()
val database = writableDatabase val database = writableDatabase
database.transaction { database.transaction {
delete(TABLE_NAME_QUEUE, null, null) delete(TABLE_NAME_QUEUE, null, null)
} }
logD("Wiped queue db.") logD("Wiped queue db")
writeQueueBatch(queue, queue.size) 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<Song> {
assertBackgroundThread()
val queue = mutableListOf<Song>()
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( data class SavedState(
val song: Song?, val song: Song?,
val position: Long, val position: Long,

View file

@ -40,6 +40,8 @@ import org.oxycblt.auxio.util.logE
* *
* All access should be done with [PlaybackStateManager.getInstance]. * All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Rework this to possibly handle gapless playback and more refined queue management.
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
// Playback // Playback
@ -151,17 +153,8 @@ class PlaybackStateManager private constructor() {
} }
PlaybackMode.IN_GENRE -> { PlaybackMode.IN_GENRE -> {
val genre = song.genre mParent = song.genre
mQueue = song.genre.songs.toMutableList()
// 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
}
} }
PlaybackMode.IN_ARTIST -> { PlaybackMode.IN_ARTIST -> {
@ -233,7 +226,6 @@ class PlaybackStateManager private constructor() {
private fun updatePlayback(song: Song, shouldPlay: Boolean = true) { private fun updatePlayback(song: Song, shouldPlay: Boolean = true) {
mSong = song mSong = song
mPosition = 0 mPosition = 0
setPlaying(shouldPlay) setPlaying(shouldPlay)
} }
@ -280,18 +272,14 @@ class PlaybackStateManager private constructor() {
* Remove a queue item at [index]. Will ignore invalid indexes. * Remove a queue item at [index]. Will ignore invalid indexes.
*/ */
fun removeQueueItem(index: Int): Boolean { fun removeQueueItem(index: Int): Boolean {
logD("Removing item ${mQueue[index].name}.")
if (index > mQueue.size || index < 0) { if (index > mQueue.size || index < 0) {
logE("Index is out of bounds, did not remove queue item.") logE("Index is out of bounds, did not remove queue item")
return false return false
} }
logD("Removing item ${mQueue[index].name}")
mQueue.removeAt(index) mQueue.removeAt(index)
pushQueueUpdate() pushQueueUpdate()
return true return true
} }
@ -301,15 +289,12 @@ class PlaybackStateManager private constructor() {
fun moveQueueItems(from: Int, to: Int): Boolean { fun moveQueueItems(from: Int, to: Int): Boolean {
if (from > mQueue.size || from < 0 || to > mQueue.size || to < 0) { if (from > mQueue.size || from < 0 || to > mQueue.size || to < 0) {
logE("Indices were out of bounds, did not move queue item") logE("Indices were out of bounds, did not move queue item")
return false return false
} }
val item = mQueue.removeAt(from) logD("Moving item $from to position $to")
mQueue.add(to, item) mQueue.add(to, mQueue.removeAt(from))
pushQueueUpdate() pushQueueUpdate()
return true return true
} }
@ -463,7 +448,6 @@ class PlaybackStateManager private constructor() {
*/ */
fun seekTo(position: Long) { fun seekTo(position: Long) {
mPosition = position mPosition = position
callbacks.forEach { it.onSeek(position) } callbacks.forEach { it.onSeek(position) }
} }
@ -511,7 +495,7 @@ class PlaybackStateManager private constructor() {
* @param context [Context] required * @param context [Context] required
*/ */
suspend fun saveStateToDatabase(context: Context) { 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. // Pack the entire state and save it to the database.
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -519,8 +503,6 @@ class PlaybackStateManager private constructor() {
val database = PlaybackStateDatabase.getInstance(context) val database = PlaybackStateDatabase.getInstance(context)
logD("$mPlaybackMode")
database.writeState( database.writeState(
PlaybackStateDatabase.SavedState( PlaybackStateDatabase.SavedState(
mSong, mPosition, mParent, mIndex, mSong, mPosition, mParent, mIndex,
@ -531,7 +513,7 @@ class PlaybackStateManager private constructor() {
database.writeQueue(mQueue) database.writeQueue(mQueue)
this@PlaybackStateManager.logD( 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. * @param context [Context] required.
*/ */
suspend fun restoreFromDatabase(context: Context) { suspend fun restoreFromDatabase(context: Context) {
logD("Getting state from DB.") logD("Getting state from DB")
val musicStore = MusicStore.maybeGetInstance() ?: return val musicStore = MusicStore.maybeGetInstance() ?: return
val start: Long val start: Long
val playbackState: PlaybackStateDatabase.SavedState? val playbackState: PlaybackStateDatabase.SavedState?
val queue: MutableList<Song> val queue: MutableList<Song>
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
start = System.currentTimeMillis() start = System.currentTimeMillis()
val database = PlaybackStateDatabase.getInstance(context) val database = PlaybackStateDatabase.getInstance(context)
playbackState = database.readState(musicStore) playbackState = database.readState(musicStore)
queue = database.readQueue(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 // Get off the IO coroutine since it will cause LiveData updates to throw an exception
if (playbackState != null) { if (playbackState != null) {
logD("Found playback state $playbackState")
unpackFromPlaybackState(playbackState) unpackFromPlaybackState(playbackState)
unpackQueue(queue) unpackQueue(queue)
doParentSanityCheck() doParentSanityCheck()
doIndexSanityCheck() doIndexSanityCheck()
} }
logD("Restore finished in ${System.currentTimeMillis() - start}ms") logD("State load completed successfully in ${System.currentTimeMillis() - start}ms")
markRestored() markRestored()
} }
@ -595,14 +572,6 @@ class PlaybackStateManager private constructor() {
private fun unpackQueue(queue: MutableList<Song>) { private fun unpackQueue(queue: MutableList<Song>) {
mQueue = queue mQueue = queue
// Sanity check: Ensure that the
mSong?.let { song ->
while (mQueue.getOrNull(mIndex) != song) {
mIndex--
}
}
pushQueueUpdate() pushQueueUpdate()
} }
@ -612,7 +581,7 @@ class PlaybackStateManager private constructor() {
private fun doParentSanityCheck() { private fun doParentSanityCheck() {
// Check if the parent was lost while in the DB. // Check if the parent was lost while in the DB.
if (mSong != null && mParent == null && mPlaybackMode != PlaybackMode.ALL_SONGS) { if (mSong != null && mParent == null && mPlaybackMode != PlaybackMode.ALL_SONGS) {
logD("Parent lost, attempting restore.") logD("Parent lost, attempting restore")
mParent = when (mPlaybackMode) { mParent = when (mPlaybackMode) {
PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album
@ -627,12 +596,14 @@ class PlaybackStateManager private constructor() {
* Do a sanity check to make sure that the index lines up with the current song. * Do a sanity check to make sure that the index lines up with the current song.
*/ */
private fun doIndexSanityCheck() { private fun doIndexSanityCheck() {
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) val correctedIndex = mQueue.wobblyIndexOfFirst(mIndex, mSong)
if (correctedIndex > -1) { if (correctedIndex > -1) {
logD("Correcting malformed index to $correctedIndex") logD("Correcting malformed index to $correctedIndex")
mIndex = correctedIndex mIndex = correctedIndex
pushQueueUpdate()
} }
} }
} }

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.system
import android.content.Context import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.os.Build
import androidx.core.math.MathUtils import androidx.core.math.MathUtils
import androidx.media.AudioAttributesCompat import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat 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.settings.SettingsManager
import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import kotlin.math.pow import kotlin.math.pow
/** /**
@ -84,16 +86,19 @@ class AudioReactor(
* Request the android system for audio focus * Request the android system for audio focus
*/ */
fun requestFocus() { fun requestFocus() {
logD("Requesting audio focus")
AudioManagerCompat.requestAudioFocus(audioManager, request) AudioManagerCompat.requestAudioFocus(audioManager, request)
} }
/** /**
* Updates the rough volume adjustment for [Metadata] with ReplayGain tags. * Updates the rough volume adjustment for [Metadata] with ReplayGain tags.
* This is based off Vanilla Music's implementation. * This is based off Vanilla Music's implementation.
* TODO: Add ReplayGain pre-amp
* TODO: Add positive ReplayGain values
*/ */
fun applyReplayGain(metadata: Metadata?) { fun applyReplayGain(metadata: Metadata?) {
if (metadata == null) { if (metadata == null) {
logD("No metadata.") logW("No metadata could be extracted from this track")
volume = 1f volume = 1f
return return
} }
@ -101,7 +106,7 @@ class AudioReactor(
// ReplayGain is configurable, so determine what to do based off of the mode. // ReplayGain is configurable, so determine what to do based off of the mode.
val useAlbumGain: (Gain) -> Boolean = when (settingsManager.replayGainMode) { val useAlbumGain: (Gain) -> Boolean = when (settingsManager.replayGainMode) {
ReplayGainMode.OFF -> { ReplayGainMode.OFF -> {
logD("ReplayGain is off.") logD("ReplayGain is off")
volume = 1f volume = 1f
return return
} }
@ -127,14 +132,15 @@ class AudioReactor(
playbackManager.song?.album == playbackManager.parent playbackManager.song?.album == playbackManager.parent
} }
} }
val gain = parseReplayGain(metadata) val gain = parseReplayGain(metadata)
val adjust = if (gain != null) { val adjust = if (gain != null) {
if (useAlbumGain(gain)) { if (useAlbumGain(gain)) {
logD("Using album gain.") logD("Using album gain")
gain.album gain.album
} else { } else {
logD("Using track gain.") logD("Using track gain")
gain.track gain.track
} }
} else { } else {
@ -144,8 +150,6 @@ class AudioReactor(
// Final adjustment along the volume curve. // Final adjustment along the volume curve.
// Ensure this is clamped to 0 or 1 so that it can be used as a volume. // 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) volume = MathUtils.clamp((10f.pow((adjust / 20f))), 0f, 1f)
} }
@ -177,7 +181,7 @@ class AudioReactor(
} }
if (key in REPLAY_GAIN_TAGS) { 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 --- // --- INTERNAL AUDIO FOCUS ---
override fun onAudioFocusChange(focusChange: Int) { 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 // Don't do audio focus if its not enabled
return return
} }

View file

@ -5,6 +5,7 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.ContextCompat 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 * 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() { class MediaButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_MEDIA_BUTTON) { if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
logD("Received external media button intent")
intent.component = ComponentName(context, PlaybackService::class.java) intent.component = ComponentName(context, PlaybackService::class.java)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }

View file

@ -51,6 +51,7 @@ class PlaybackNotification private constructor(
setCategory(NotificationCompat.CATEGORY_SERVICE) setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false) setShowWhen(false)
setSilent(true) setSilent(true)
setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
setContentIntent(context.newMainIntent()) setContentIntent(context.newMainIntent())
setVisibility(NotificationCompat.VISIBILITY_PUBLIC) setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@ -142,7 +143,7 @@ class PlaybackNotification private constructor(
loopMode: LoopMode loopMode: LoopMode
): NotificationCompat.Action { ): NotificationCompat.Action {
val drawableRes = when (loopMode) { 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.ALL -> R.drawable.ic_loop
LoopMode.TRACK -> R.drawable.ic_loop_one LoopMode.TRACK -> R.drawable.ic_loop_one
} }
@ -154,7 +155,7 @@ class PlaybackNotification private constructor(
context: Context, context: Context,
isShuffled: Boolean isShuffled: Boolean
): NotificationCompat.Action { ): NotificationCompat.Action {
val drawableRes = if (isShuffled) R.drawable.ic_shuffle else R.drawable.ic_shuffle_off val drawableRes = if (isShuffled) R.drawable.ic_shuffle else R.drawable.ic_remote_shuffle_off
return buildAction(context, PlaybackService.ACTION_SHUFFLE, drawableRes) return buildAction(context, PlaybackService.ACTION_SHUFFLE, drawableRes)
} }

View file

@ -180,7 +180,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
settingsManager.addCallback(this) settingsManager.addCallback(this)
logD("Service created.") logD("Service created")
} }
override fun onDestroy() { override fun onDestroy() {
@ -207,7 +207,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
serviceJob.cancel() serviceJob.cancel()
} }
logD("Service destroyed.") logD("Service destroyed")
} }
// --- PLAYER EVENT LISTENER OVERRIDES --- // --- PLAYER EVENT LISTENER OVERRIDES ---
@ -260,22 +260,21 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onSongUpdate(song: Song?) { override fun onSongUpdate(song: Song?) {
if (song != null) { if (song != null) {
logD("Setting player to ${song.name}")
player.setMediaItem(MediaItem.fromUri(song.uri)) player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare() player.prepare()
notification.setMetadata(song, ::startForegroundOrNotify) notification.setMetadata(song, ::startForegroundOrNotify)
return return
} }
// Clear if there's nothing to play. // Clear if there's nothing to play.
logD("Nothing playing, stopping playback")
player.stop() player.stop()
stopForegroundAndNotification() stopForegroundAndNotification()
} }
override fun onParentUpdate(parent: MusicParent?) { override fun onParentUpdate(parent: MusicParent?) {
notification.setParent(parent) notification.setParent(parent)
startForegroundOrNotify() startForegroundOrNotify()
} }
@ -295,7 +294,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onLoopUpdate(loopMode: LoopMode) { override fun onLoopUpdate(loopMode: LoopMode) {
if (!settingsManager.useAltNotifAction) { if (!settingsManager.useAltNotifAction) {
notification.setLoop(loopMode) notification.setLoop(loopMode)
startForegroundOrNotify() startForegroundOrNotify()
} }
} }
@ -303,7 +301,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onShuffleUpdate(isShuffling: Boolean) { override fun onShuffleUpdate(isShuffling: Boolean) {
if (settingsManager.useAltNotifAction) { if (settingsManager.useAltNotifAction) {
notification.setShuffle(isShuffling) notification.setShuffle(isShuffling)
startForegroundOrNotify() startForegroundOrNotify()
} }
} }
@ -334,7 +331,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onShowCoverUpdate(showCovers: Boolean) { override fun onShowCoverUpdate(showCovers: Boolean) {
playbackManager.song?.let { song -> playbackManager.song?.let { song ->
connector.onSongUpdate(song) connector.onSongUpdate(song)
notification.setMetadata(song, ::startForegroundOrNotify) notification.setMetadata(song, ::startForegroundOrNotify)
} }
} }
@ -443,7 +439,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
private fun stopForegroundAndNotification() { private fun stopForegroundAndNotification() {
stopForeground(true) stopForeground(true)
notificationManager.cancel(PlaybackNotification.NOTIFICATION_ID) notificationManager.cancel(PlaybackNotification.NOTIFICATION_ID)
isForeground = false isForeground = false
} }
@ -451,25 +446,36 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
* A [BroadcastReceiver] for receiving general playback events from the system. * A [BroadcastReceiver] for receiving general playback events from the system.
*/ */
private inner class PlaybackReceiver : BroadcastReceiver() { private inner class PlaybackReceiver : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
when (intent.action) { when (intent.action) {
// --- SYSTEM EVENTS --- // --- 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 -> { AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
when (intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)) { 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_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 -> { AudioManager.ACTION_HEADSET_PLUG -> {
when (intent.getIntExtra("state", -1)) { when (intent.getIntExtra("state", -1)) {
0 -> resumeFromPlug() 0 -> pauseFromPlug()
1 -> 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 --- // --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> playbackManager.setPlaying( ACTION_PLAY_PAUSE -> playbackManager.setPlaying(
!playbackManager.isPlaying !playbackManager.isPlaying
@ -494,25 +500,35 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update() WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update()
} }
} }
}
/** /**
* Resume from a headset plug event, as long as its allowed. * Resume from a headset plug event in the case that the quirk is enabled.
*/ * This functionality remains a quirk for two reasons:
private fun resumeFromPlug() { * 1. Automatically resuming more or less overrides all other audio streams, which
if (playbackManager.song != null && settingsManager.doPlugMgt) { * is not that friendly
logD("Device connected, resuming...") * 2. There is a bug where playback will always start when this service starts, mostly
playbackManager.setPlaying(true) * 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. * Pause from a headset plug.
*/ */
private fun pauseFromPlug() { private fun pauseFromPlug() {
if (playbackManager.song != null && settingsManager.doPlugMgt) { if (playbackManager.song != null) {
logD("Device disconnected, pausing...") logD("Device disconnected, pausing")
playbackManager.setPlaying(false) playbackManager.setPlaying(false)
}
} }
} }

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.coil.loadBitmap
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD
/** /**
* Nightmarish class that coordinates communication between [MediaSessionCompat], [Player], * Nightmarish class that coordinates communication between [MediaSessionCompat], [Player],
@ -158,6 +159,8 @@ class PlaybackSessionConnector(
// --- MISC --- // --- MISC ---
private fun invalidateSessionState() { private fun invalidateSessionState() {
logD("Updating media session state")
// Position updates arrive faster when you upload STATE_PAUSED for some insane reason. // Position updates arrive faster when you upload STATE_PAUSED for some insane reason.
val state = PlaybackStateCompat.Builder() val state = PlaybackStateCompat.Builder()
.setActions(ACTIONS) .setActions(ACTIONS)

View file

@ -24,9 +24,9 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.AlbumViewHolder import org.oxycblt.auxio.ui.AlbumViewHolder
@ -43,7 +43,7 @@ import org.oxycblt.auxio.ui.SongViewHolder
class SearchAdapter( class SearchAdapter(
private val doOnClick: (data: Music) -> Unit, private val doOnClick: (data: Music) -> Unit,
private val doOnLongClick: (view: View, data: Music) -> Unit private val doOnLongClick: (view: View, data: Music) -> Unit
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback<BaseModel>()) { ) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback<Item>()) {
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
return when (getItem(position)) { return when (getItem(position)) {
@ -52,7 +52,6 @@ class SearchAdapter(
is Album -> AlbumViewHolder.ITEM_TYPE is Album -> AlbumViewHolder.ITEM_TYPE
is Song -> SongViewHolder.ITEM_TYPE is Song -> SongViewHolder.ITEM_TYPE
is Header -> HeaderViewHolder.ITEM_TYPE is Header -> HeaderViewHolder.ITEM_TYPE
else -> -1 else -> -1
} }
} }
@ -77,7 +76,7 @@ class SearchAdapter(
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context) HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type.") else -> error("Invalid ViewHolder item type")
} }
} }

View file

@ -114,7 +114,6 @@ class SearchFragment : Fragment() {
if (!launchedKeyboard) { if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown // Auto-open the keyboard when this view is shown
requestFocus() requestFocus()
postDelayed(200) { postDelayed(200) {
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
} }
@ -162,7 +161,7 @@ class SearchFragment : Fragment() {
imm.hide() imm.hide()
} }
logD("Fragment created.") logD("Fragment created")
return binding.root return binding.root
} }

View file

@ -25,14 +25,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
import java.text.Normalizer import java.text.Normalizer
/** /**
@ -40,13 +41,13 @@ import java.text.Normalizer
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SearchViewModel : ViewModel() { class SearchViewModel : ViewModel() {
private val mSearchResults = MutableLiveData(listOf<BaseModel>()) private val mSearchResults = MutableLiveData(listOf<Item>())
private var mIsNavigating = false private var mIsNavigating = false
private var mFilterMode: DisplayMode? = null private var mFilterMode: DisplayMode? = null
private var mLastQuery = "" private var mLastQuery = ""
/** Current search results from the last [search] call. */ /** Current search results from the last [search] call. */
val searchResults: LiveData<List<BaseModel>> get() = mSearchResults val searchResults: LiveData<List<Item>> get() = mSearchResults
val isNavigating: Boolean get() = mIsNavigating val isNavigating: Boolean get() = mIsNavigating
val filterMode: DisplayMode? get() = mFilterMode val filterMode: DisplayMode? get() = mFilterMode
@ -70,14 +71,17 @@ class SearchViewModel : ViewModel() {
mLastQuery = query mLastQuery = query
if (query.isEmpty() || musicStore == null) { if (query.isEmpty() || musicStore == null) {
logD("No music/query, ignoring search")
mSearchResults.value = listOf() mSearchResults.value = listOf()
return 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 { viewModelScope.launch {
val sort = Sort.ByName(true) val sort = Sort.ByName(true)
val results = mutableListOf<BaseModel>() val results = mutableListOf<Item>()
// Note: a filter mode of null means to not filter at all. // Note: a filter mode of null means to not filter at all.
@ -127,6 +131,8 @@ class SearchViewModel : ViewModel() {
else -> null else -> null
} }
logD("Updating filter mode to $mFilterMode")
settingsManager.searchFilterMode = mFilterMode settingsManager.searchFilterMode = mFilterMode
search(mLastQuery) search(mLastQuery)

View file

@ -74,7 +74,7 @@ class AboutFragment : Fragment() {
) )
} }
logD("Dialog created.") logD("Dialog created")
return binding.root return binding.root
} }
@ -83,6 +83,8 @@ class AboutFragment : Fragment() {
* Go through the process of opening a [link] in a browser. * Go through the process of opening a [link] in a browser.
*/ */
private fun openLinkInBrowser(link: String) { private fun openLinkInBrowser(link: String) {
logD("Opening $link")
val browserIntent = Intent(Intent.ACTION_VIEW, link.toUri()).setFlags( val browserIntent = Intent(Intent.ACTION_VIEW, link.toUri()).setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_NEW_TASK
) )

View file

@ -22,8 +22,7 @@ import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import org.oxycblt.auxio.accent.Accent import org.oxycblt.auxio.accent.Accent
// A couple of utils for migrating from old settings values to the new // A couple of utils for migrating from old settings values to the new formats
// formats used in 1.3.2 & 1.4.0
fun handleAccentCompat(prefs: SharedPreferences): Accent { fun handleAccentCompat(prefs: SharedPreferences): Accent {
if (prefs.contains(OldKeys.KEY_ACCENT2)) { if (prefs.contains(OldKeys.KEY_ACCENT2)) {

View file

@ -31,7 +31,7 @@ import androidx.preference.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.Coil import coil.Coil
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.accent.AccentDialog import org.oxycblt.auxio.accent.AccentCustomizeDialog
import org.oxycblt.auxio.excluded.ExcludedDialog import org.oxycblt.auxio.excluded.ExcludedDialog
import org.oxycblt.auxio.home.tabs.TabCustomizeDialog import org.oxycblt.auxio.home.tabs.TabCustomizeDialog
import org.oxycblt.auxio.playback.PlaybackViewModel 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?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -119,7 +119,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
SettingsManager.KEY_ACCENT -> { SettingsManager.KEY_ACCENT -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener { onPreferenceClickListener = Preference.OnPreferenceClickListener {
AccentDialog().show(childFragmentManager, AccentDialog.TAG) AccentCustomizeDialog().show(childFragmentManager, AccentCustomizeDialog.TAG)
true true
} }
@ -182,7 +182,6 @@ class SettingsListFragment : PreferenceFragmentCompat() {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> R.drawable.ic_auto AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> R.drawable.ic_auto
AppCompatDelegate.MODE_NIGHT_NO -> R.drawable.ic_day AppCompatDelegate.MODE_NIGHT_NO -> R.drawable.ic_day
AppCompatDelegate.MODE_NIGHT_YES -> R.drawable.ic_night AppCompatDelegate.MODE_NIGHT_YES -> R.drawable.ic_night
else -> R.drawable.ic_auto else -> R.drawable.ic_auto
} }
} }

View file

@ -37,27 +37,27 @@ import org.oxycblt.auxio.ui.Sort
class SettingsManager private constructor(context: Context) : class SettingsManager private constructor(context: Context) :
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
init { init {
sharedPrefs.registerOnSharedPreferenceChangeListener(this) prefs.registerOnSharedPreferenceChangeListener(this)
} }
// --- VALUES --- // --- VALUES ---
/** The current theme */ /** The current theme */
val theme: Int 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 */ /** Whether the dark theme should be black or not */
val useBlackTheme: Boolean val useBlackTheme: Boolean
get() = sharedPrefs.getBoolean(KEY_BLACK_THEME, false) get() = prefs.getBoolean(KEY_BLACK_THEME, false)
/** The current accent. */ /** The current accent. */
var accent: Accent var accent: Accent
get() = handleAccentCompat(sharedPrefs) get() = handleAccentCompat(prefs)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_ACCENT, value.index) putInt(KEY_ACCENT, value.index)
apply() apply()
} }
@ -68,14 +68,14 @@ class SettingsManager private constructor(context: Context) :
* False if loop, true if shuffle. * False if loop, true if shuffle.
*/ */
val useAltNotifAction: Boolean 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. */ /** The current library tabs preferred by the user. */
var libTabs: Array<Tab> var libTabs: Array<Tab>
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)!! ?: Tab.fromSequence(Tab.SEQUENCE_DEFAULT)!!
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_LIB_TABS, Tab.toSequence(value)) putInt(KEY_LIB_TABS, Tab.toSequence(value))
apply() apply()
} }
@ -83,51 +83,51 @@ class SettingsManager private constructor(context: Context) :
/** Whether to load embedded covers */ /** Whether to load embedded covers */
val showCovers: Boolean val showCovers: Boolean
get() = sharedPrefs.getBoolean(KEY_SHOW_COVERS, true) get() = prefs.getBoolean(KEY_SHOW_COVERS, true)
/** Whether to ignore MediaStore covers */ /** Whether to ignore MediaStore covers */
val useQualityCovers: Boolean val useQualityCovers: Boolean
get() = sharedPrefs.getBoolean(KEY_QUALITY_COVERS, false) get() = prefs.getBoolean(KEY_QUALITY_COVERS, false)
/** Whether to round album covers */ /** Whether to round album covers */
val roundCovers: Boolean val roundCovers: Boolean
get() = sharedPrefs.getBoolean(KEY_ROUND_COVERS, false) get() = prefs.getBoolean(KEY_ROUND_COVERS, false)
/** Whether to do Audio focus. */ /** Whether to do Audio focus. */
val doAudioFocus: Boolean 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. */ /** Whether to resume playback when a headset is connected (may not work well in all cases) */
val doPlugMgt: Boolean val headsetAutoplay: Boolean
get() = sharedPrefs.getBoolean(KEY_PLUG_MANAGEMENT, true) get() = prefs.getBoolean(KEY_HEADSET_AUTOPLAY, false)
/** The current ReplayGain configuration */ /** The current ReplayGain configuration */
val replayGainMode: ReplayGainMode 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 ?: ReplayGainMode.OFF
/** What queue to create when a song is selected (ex. From All Songs or Search) */ /** What queue to create when a song is selected (ex. From All Songs or Search) */
val songPlaybackMode: PlaybackMode val songPlaybackMode: PlaybackMode
get() = PlaybackMode.fromInt(sharedPrefs.getInt(KEY_SONG_PLAYBACK_MODE, Int.MIN_VALUE)) get() = PlaybackMode.fromInt(prefs.getInt(KEY_SONG_PLAYBACK_MODE, Int.MIN_VALUE))
?: PlaybackMode.ALL_SONGS ?: PlaybackMode.ALL_SONGS
/** Whether shuffle should stay on when a new song is selected. */ /** Whether shuffle should stay on when a new song is selected. */
val keepShuffle: Boolean 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. */ /** Whether to rewind when the back button is pressed. */
val rewindWithPrev: Boolean 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 */ /** Whether [org.oxycblt.auxio.playback.state.LoopMode.TRACK] should pause when the track repeats */
val pauseOnLoop: Boolean 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 */ /** The current filter mode of the search tab */
var searchFilterMode: DisplayMode? 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) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_SEARCH_FILTER_MODE, DisplayMode.toFilterInt(value)) putInt(KEY_SEARCH_FILTER_MODE, DisplayMode.toFilterInt(value))
apply() apply()
} }
@ -135,10 +135,10 @@ class SettingsManager private constructor(context: Context) :
/** The song sort mode on HomeFragment **/ /** The song sort mode on HomeFragment **/
var libSongSort: Sort var libSongSort: Sort
get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_SONGS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true) ?: Sort.ByName(true)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_LIB_SONGS_SORT, value.toInt()) putInt(KEY_LIB_SONGS_SORT, value.toInt())
apply() apply()
} }
@ -146,10 +146,10 @@ class SettingsManager private constructor(context: Context) :
/** The album sort mode on HomeFragment **/ /** The album sort mode on HomeFragment **/
var libAlbumSort: Sort var libAlbumSort: Sort
get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_ALBUMS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true) ?: Sort.ByName(true)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_LIB_ALBUMS_SORT, value.toInt()) putInt(KEY_LIB_ALBUMS_SORT, value.toInt())
apply() apply()
} }
@ -157,10 +157,10 @@ class SettingsManager private constructor(context: Context) :
/** The artist sort mode on HomeFragment **/ /** The artist sort mode on HomeFragment **/
var libArtistSort: Sort var libArtistSort: Sort
get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_ARTISTS_SORT, Int.MIN_VALUE))
?: Sort.ByName(true) ?: Sort.ByName(true)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_LIB_ARTISTS_SORT, value.toInt()) putInt(KEY_LIB_ARTISTS_SORT, value.toInt())
apply() apply()
} }
@ -168,10 +168,10 @@ class SettingsManager private constructor(context: Context) :
/** The genre sort mode on HomeFragment **/ /** The genre sort mode on HomeFragment **/
var libGenreSort: Sort var libGenreSort: Sort
get() = Sort.fromInt(sharedPrefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_LIB_GENRES_SORT, Int.MIN_VALUE))
?: Sort.ByName(true) ?: Sort.ByName(true)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_LIB_GENRES_SORT, value.toInt()) putInt(KEY_LIB_GENRES_SORT, value.toInt())
apply() apply()
} }
@ -179,10 +179,10 @@ class SettingsManager private constructor(context: Context) :
/** The detail album sort mode **/ /** The detail album sort mode **/
var detailAlbumSort: Sort var detailAlbumSort: Sort
get() = Sort.fromInt(sharedPrefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ALBUM_SORT, Int.MIN_VALUE))
?: Sort.ByName(true) ?: Sort.ByName(true)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_DETAIL_ALBUM_SORT, value.toInt()) putInt(KEY_DETAIL_ALBUM_SORT, value.toInt())
apply() apply()
} }
@ -190,10 +190,10 @@ class SettingsManager private constructor(context: Context) :
/** The detail artist sort mode **/ /** The detail artist sort mode **/
var detailArtistSort: Sort var detailArtistSort: Sort
get() = Sort.fromInt(sharedPrefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_ARTIST_SORT, Int.MIN_VALUE))
?: Sort.ByYear(false) ?: Sort.ByYear(false)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_DETAIL_ARTIST_SORT, value.toInt()) putInt(KEY_DETAIL_ARTIST_SORT, value.toInt())
apply() apply()
} }
@ -201,10 +201,10 @@ class SettingsManager private constructor(context: Context) :
/** The detail genre sort mode **/ /** The detail genre sort mode **/
var detailGenreSort: Sort var detailGenreSort: Sort
get() = Sort.fromInt(sharedPrefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE)) get() = Sort.fromInt(prefs.getInt(KEY_DETAIL_GENRE_SORT, Int.MIN_VALUE))
?: Sort.ByName(true) ?: Sort.ByName(true)
set(value) { set(value) {
sharedPrefs.edit { prefs.edit {
putInt(KEY_DETAIL_GENRE_SORT, value.toInt()) putInt(KEY_DETAIL_GENRE_SORT, value.toInt())
apply() 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_USE_ALT_NOTIFICATION_ACTION = "KEY_ALT_NOTIF_ACTION"
const val KEY_AUDIO_FOCUS = "KEY_AUDIO_FOCUS" 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_REPLAY_GAIN = "auxio_replay_gain"
const val KEY_SONG_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" const val KEY_SONG_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2"
@ -331,7 +331,7 @@ class SettingsManager private constructor(context: Context) :
return instance return instance
} }
error("SettingsManager must be initialized with init() before getting its instance.") error("SettingsManager must be initialized with init() before getting its instance")
} }
} }
} }

View file

@ -18,25 +18,28 @@
package org.oxycblt.auxio.settings.pref package org.oxycblt.auxio.settings.pref
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.TypedArray import android.content.res.TypedArray
import android.util.AttributeSet import android.util.AttributeSet
import androidx.preference.DialogPreference import androidx.preference.DialogPreference
import androidx.preference.Preference
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import androidx.preference.R as prefR
class IntListPreference @JvmOverloads constructor( class IntListPreference @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = prefR.attr.dialogPreferenceStyle, defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
defStyleRes: Int = 0 defStyleRes: Int = 0
) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { ) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) {
// Reflect into Preference to get the (normally inaccessible) default value.
private val defValueField = Preference::class.java.getDeclaredField("mDefaultValue").apply {
isAccessible = true
}
val entries: Array<CharSequence> val entries: Array<CharSequence>
val values: IntArray val values: IntArray
private var currentValue: Int? = null private var currentValue: Int? = null
private val defValue: Int private val defValue: Int get() = defValueField.get(this) as Int
init { init {
val prefAttrs = context.obtainStyledAttributes( val prefAttrs = context.obtainStyledAttributes(
@ -49,8 +52,6 @@ class IntListPreference @JvmOverloads constructor(
prefAttrs.getResourceId(R.styleable.IntListPreference_entryValues, -1) prefAttrs.getResourceId(R.styleable.IntListPreference_entryValues, -1)
) )
defValue = prefAttrs.getInt(prefR.styleable.Preference_defaultValue, Int.MIN_VALUE)
prefAttrs.recycle() prefAttrs.recycle()
summaryProvider = IntListSummaryProvider() summaryProvider = IntListSummaryProvider()
@ -96,7 +97,6 @@ class IntListPreference @JvmOverloads constructor(
} }
} }
@SuppressLint("PrivateResource")
private inner class IntListSummaryProvider : SummaryProvider<IntListPreference> { private inner class IntListSummaryProvider : SummaryProvider<IntListPreference> {
override fun provideSummary(preference: IntListPreference): CharSequence { override fun provideSummary(preference: IntListPreference): CharSequence {
val index = getValueIndex() val index = getValueIndex()
@ -105,7 +105,8 @@ class IntListPreference @JvmOverloads constructor(
return entries[index] return entries[index]
} }
return context.getString(prefR.string.not_set) // Usually an invalid state, don't bother translating
return "<not set>"
} }
} }
} }

View file

@ -30,8 +30,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.showToast 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]. * Extension method for creating and showing a new [ActionMenu].
* @param anchor [View] This should be centered around * @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. * @param flag (Optional, defaults to [ActionMenu.FLAG_NONE]) Any extra flags to accompany the data.
* @see ActionMenu * @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() 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. * 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 activity [AppCompatActivity] required as both a context and ViewModelStore owner.
* @param anchor [View] This should be centered around * @param anchor [View] This should be centered around
* @param data [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. * @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM], [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
* @throws IllegalStateException When there is no menu for this specific datatype/flag * @throws IllegalStateException When there is no menu for this specific datatype/flag
* @author OxygenCobalt * @author OxygenCobalt
* TODO: Stop scrolling when a menu is open
* TODO: Prevent duplicate menus from showing up
* TODO: Maybe replace this with a bottom sheet?
*/ */
class ActionMenu( class ActionMenu(
activity: AppCompatActivity, activity: AppCompatActivity,
anchor: View, anchor: View,
private val data: BaseModel, private val data: Item,
private val flag: Int private val flag: Int
) : PopupMenu(activity, anchor) { ) : PopupMenu(activity, anchor) {
private val context = activity.applicationContext private val context = activity.applicationContext

View file

@ -19,14 +19,14 @@
package org.oxycblt.auxio.ui package org.oxycblt.auxio.ui
import androidx.recyclerview.widget.DiffUtil 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.** * **Use this instead of creating a DiffCallback for each adapter.**
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class DiffCallback<T : BaseModel> : DiffUtil.ItemCallback<T>() { class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.hashCode() == newItem.hashCode() return oldItem.hashCode() == newItem.hashCode()
} }

View file

@ -24,12 +24,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.StyleRes import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.appbar.AppBarLayout 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 import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
open class EdgeAppBarLayout @JvmOverloads constructor( open class EdgeAppBarLayout @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@StyleRes defStyleAttr: Int = -1 @AttrRes defStyleAttr: Int = 0
) : AppBarLayout(context, attrs, defStyleAttr) { ) : AppBarLayout(context, attrs, defStyleAttr) {
private var scrollingChild: View? = null private var scrollingChild: View? = null
private val tConsumed = IntArray(2) private val tConsumed = IntArray(2)
@ -51,7 +51,6 @@ open class EdgeAppBarLayout @JvmOverloads constructor(
if (child != null) { if (child != null) {
val coordinator = parent as CoordinatorLayout val coordinator = parent as CoordinatorLayout
(layoutParams as CoordinatorLayout.LayoutParams).behavior?.onNestedPreScroll( (layoutParams as CoordinatorLayout.LayoutParams).behavior?.onNestedPreScroll(
coordinator, this, coordinator, 0, 0, tConsumed, 0 coordinator, this, coordinator, 0, 0, tConsumed, 0
) )
@ -66,15 +65,12 @@ open class EdgeAppBarLayout @JvmOverloads constructor(
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
super.onApplyWindowInsets(insets) super.onApplyWindowInsets(insets)
updatePadding(top = insets.systemBarInsetsCompat.top) updatePadding(top = insets.systemBarInsetsCompat.top)
return insets return insets
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
super.onDetachedFromWindow() super.onDetachedFromWindow()
viewTreeObserver.removeOnPreDrawListener(onPreDraw) viewTreeObserver.removeOnPreDrawListener(onPreDraw)
} }
@ -94,9 +90,10 @@ open class EdgeAppBarLayout @JvmOverloads constructor(
if (liftOnScrollTargetViewId != ResourcesCompat.ID_NULL) { if (liftOnScrollTargetViewId != ResourcesCompat.ID_NULL) {
scrollingChild = (parent as ViewGroup).findViewById(liftOnScrollTargetViewId) scrollingChild = (parent as ViewGroup).findViewById(liftOnScrollTargetViewId)
} else { } else {
logE("liftOnScrollTargetViewId was not specified. ignoring scroll events.") logW("liftOnScrollTargetViewId was not specified. ignoring scroll events")
} }
} }
return scrollingChild return scrollingChild
} }
} }

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.children import androidx.core.view.children
@ -33,7 +34,7 @@ import androidx.core.view.children
class EdgeCoordinatorLayout @JvmOverloads constructor( class EdgeCoordinatorLayout @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = -1 @AttrRes defStyleAttr: Int = 0
) : CoordinatorLayout(context, attrs, defStyleAttr) { ) : CoordinatorLayout(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
for (child in children) { for (child in children) {

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -31,7 +32,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
class EdgeRecyclerView @JvmOverloads constructor( class EdgeRecyclerView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = -1 @AttrRes defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) { ) : RecyclerView(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarInsetsCompat.bottom) updatePadding(bottom = insets.systemBarInsetsCompat.bottom)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <T : ViewDataBinding> 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<T : ViewDataBinding>(
private val fragment: Fragment,
private val inflate: (LayoutInflater) -> T,
private val onDestroy: T.() -> Unit
) : ReadOnlyProperty<Fragment, T>, 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()
}
}
})
}
}

View file

@ -102,7 +102,7 @@ sealed class Sort(open val isAscending: Boolean) {
is ByName -> songs.stringSort { it.name } is ByName -> songs.stringSort { it.name }
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album -> else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
album.songs.intSort(true) { it.track } 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) is ByArtist -> sortParents(albums.groupBy { it.artist }.keys)
.flatMap { ByYear(false).sortAlbums(it.albums) } .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 * @see sortSongs
*/ */
fun sortAlbum(album: Album): List<Song> { fun sortAlbum(album: Album): List<Song> {
return album.songs.intSort { it.track } return album.songs.intSort { it.track ?: 0 }
} }
/** /**

View file

@ -32,21 +32,21 @@ import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders. * 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 binding Basic [ViewDataBinding] required to set up click listeners & sizing.
* @param doOnClick (Optional) Function that calls on a click. * @param doOnClick (Optional) Function that calls on a click.
* @param doOnLongClick (Optional) Functions that calls on a long-click. * @param doOnLongClick (Optional) Functions that calls on a long-click.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class BaseViewHolder<T : BaseModel>( abstract class BaseViewHolder<T : Item>(
private val binding: ViewDataBinding, private val binding: ViewDataBinding,
private val doOnClick: ((data: T) -> Unit)? = null, private val doOnClick: ((data: T) -> Unit)? = null,
private val doOnLongClick: ((view: View, data: T) -> Unit)? = null private val doOnLongClick: ((view: View, data: T) -> Unit)? = null
@ -59,7 +59,7 @@ abstract class BaseViewHolder<T : BaseModel>(
} }
/** /**
* 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. * Will call [onBind] on the inheriting ViewHolder.
* @param data Data that the viewholder should be bound with * @param data Data that the viewholder should be bound with
*/ */

View file

@ -39,7 +39,6 @@ import androidx.annotation.PluralsRes
import androidx.annotation.Px import androidx.annotation.Px
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainActivity import org.oxycblt.auxio.MainActivity
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -75,8 +74,7 @@ fun Context.getPluralSafe(@PluralsRes pluralsRes: Int, value: Int): String {
return try { return try {
resources.getQuantityString(pluralsRes, value, value) resources.getQuantityString(pluralsRes, value, value)
} catch (e: Exception) { } catch (e: Exception) {
logE("plural load failed") handleResourceFailure(e, "plural", "<plural error>")
return "<plural error>"
} }
} }
@ -191,16 +189,9 @@ fun Context.pxOfDp(@Dimension dp: Float): Int {
} }
private fun <T> Context.handleResourceFailure(e: Exception, what: String, default: T): T { private fun <T> Context.handleResourceFailure(e: Exception, what: String, default: T): T {
logE("$what load failed.") logE("$what load failed")
e.logTraceOrThrow()
if (BuildConfig.DEBUG) { return default
// 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
}
} }
/** /**

View file

@ -34,15 +34,6 @@ fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
*/ */
fun assertBackgroundThread() { fun assertBackgroundThread() {
check(Looper.myLooper() != Looper.getMainLooper()) { check(Looper.myLooper() != Looper.getMainLooper()) {
"This operation must be ran on a background thread." "This operation must be ran on a background thread"
}
}
/**
* Assert that we are on a foreground thread.
*/
fun assertMainThread() {
check(Looper.myLooper() == Looper.getMainLooper()) {
"This operation must be ran on the main thread"
} }
} }

View file

@ -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 * 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. * Logs an error in production while still throwing it in debug mode. This is useful for
* This also applies a special "Auxio" prefix so that messages can be filtered to just from the main codebase. * 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" * @return The name of the object, otherwise "Anonymous Object"
*/ */
private fun Any.getName(): String = "Auxio.${this::class.simpleName ?: "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 * Do you want to live a fulfilling existence on this planet? Or do you want to spend your life
* taking work others did and making it objectively worse so you could arbitrage a fraction of a * taking work others did and making it objectively worse so you could arbitrage a fraction of a
* penny on every AdMob impression you get? You could do so many great things if you simply had * penny on every AdMob impression you get? You could do so many great things if you simply had
* the 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() { private fun basedCopyleftNotice() {
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" && if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&

View file

@ -22,6 +22,7 @@ import android.content.res.ColorStateList
import android.graphics.Insets import android.graphics.Insets
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@ -63,7 +64,20 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height 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. * a view that properly follows all the frustrating changes that were made between 8-11.
*/ */
val WindowInsets.systemBarInsetsCompat: Rect get() { val WindowInsets.systemBarInsetsCompat: Rect get() {
@ -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 { return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
WindowInsets.Builder(this) WindowInsets.Builder(this)

View file

@ -53,7 +53,7 @@ fun createTinyWidget(context: Context, state: WidgetState): RemoteViews {
fun createSmallWidget(context: Context, state: WidgetState): RemoteViews { fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_small) return createViews(context, R.layout.widget_small)
.applyCover(context, state) .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 { fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_medium) return createViews(context, R.layout.widget_medium)
.applyMeta(context, state) .applyMeta(context, state)
.applyControls(context, state) .applyBasicControls(context, state)
} }
/** /**
@ -142,7 +142,7 @@ private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState):
return this return this
} }
private fun RemoteViews.applyControls(context: Context, state: WidgetState): RemoteViews { private fun RemoteViews.applyBasicControls(context: Context, state: WidgetState): RemoteViews {
applyPlayControls(context, state) applyPlayControls(context, state)
setOnClickPendingIntent( setOnClickPendingIntent(
@ -163,7 +163,7 @@ private fun RemoteViews.applyControls(context: Context, state: WidgetState): Rem
} }
private fun RemoteViews.applyFullControls(context: Context, state: WidgetState): RemoteViews { private fun RemoteViews.applyFullControls(context: Context, state: WidgetState): RemoteViews {
applyControls(context, state) applyBasicControls(context, state)
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_loop, 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 // Like notifications, use the remote variants of icons since we really don't want to hack
// actually less efficient than using duplicate drawables. // indicators.
// And no, we can't control state drawables with RemoteViews. Because of course we can't.
val shuffleRes = when { val shuffleRes = when {
state.isShuffled -> R.drawable.ic_shuffle_on state.isShuffled -> R.drawable.ic_remote_shuffle_on
else -> R.drawable.ic_shuffle else -> R.drawable.ic_remote_shuffle_off
} }
val loopRes = when (state.loopMode) { 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.ALL -> R.drawable.ic_loop_on
LoopMode.TRACK -> R.drawable.ic_loop_one LoopMode.TRACK -> R.drawable.ic_loop_one
} }

View file

@ -23,6 +23,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.logD
/** /**
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the * A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
@ -53,6 +54,8 @@ class WidgetController(private val context: Context) :
* Release this instance, removing the callbacks and resetting all widgets * Release this instance, removing the callbacks and resetting all widgets
*/ */
fun release() { fun release() {
logD("Releasing instance")
widget.reset(context) widget.reset(context)
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
settingsManager.removeCallback(this) settingsManager.removeCallback(this)

View file

@ -34,11 +34,13 @@ import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.coil.SquareFrameTransform
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getDimenSizeSafe import org.oxycblt.auxio.util.getDimenSizeSafe
import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.isLandscape
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import kotlin.math.min 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) { 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) val coverRequest = ImageRequest.Builder(context)
.data(song.album) .data(song.album)
.size(imageSize)
.target( .target(
onError = { onDone(null) }, onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) } onSuccess = { onDone(it.toBitmap()) }
) )
// If we are on Android 12 or higher, round out the album cover. // The widget has two distinct styles that we must transform the album art to accommodate:
// This is simply to maintain stylistic cohesion with other widgets. // - Before Android 12, the widget has hard edges, so we don't need to round out the album
// Here, we actually have to use RoundedCornersTransformation since the way // art.
// we get a 1:1 aspect ratio image results in clipToOutline not working well. // - 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) { 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( val transform = RoundedCornersTransformation(
context.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius) context.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius)
.toFloat() .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()) context.imageLoader.enqueue(coverRequest.build())
@ -148,6 +157,8 @@ class WidgetProvider : AppWidgetProvider() {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { 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 // We can't resize the widget until we can generate the views, so request an update
// from PlaybackService. // from PlaybackService.
requestUpdate(context) requestUpdate(context)
@ -214,7 +225,6 @@ class WidgetProvider : AppWidgetProvider() {
// Find the layout with the greatest area that fits entirely within // Find the layout with the greatest area that fits entirely within
// the widget. This is what we will use. // the widget. This is what we will use.
val candidates = mutableListOf<SizeF>() val candidates = mutableListOf<SizeF>()
for (size in views.keys) { for (size in views.keys) {
@ -231,7 +241,7 @@ class WidgetProvider : AppWidgetProvider() {
continue continue
} else { } else {
// Default to the smallest view if no layout fits // Default to the smallest view if no layout fits
logD("No widget layout found") logW("No good widget layout found")
val minimum = requireNotNull( val minimum = requireNotNull(
views.minByOrNull { it.key.width * it.key.height }?.value views.minByOrNull { it.key.width * it.key.height }?.value

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorOnSurface" android:alpha="0.12" android:state_enabled="false" />
<item android:color="?attr/colorOnPrimary" android:state_checked="true" /> <item android:color="?attr/colorOnPrimary" android:state_checked="true" />
<item android:color="?attr/colorSurfaceVariant" /> <item android:color="?attr/colorSurfaceVariant" />
</selector> </selector>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorOnSurface" android:alpha="0.38" android:state_enabled="false" />
<item android:color="?attr/colorPrimary" android:state_checked="true" /> <item android:color="?attr/colorPrimary" android:state_checked="true" />
<item android:color="?attr/colorOnSurfaceVariant" /> <item android:color="?attr/colorOnSurfaceVariant" />
</selector> </selector>

View file

@ -2,6 +2,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -2,9 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:fillColor="#80ffffff" android:fillColor="#80FFFFFF"
android:pathData="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm0.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" /> android:pathData="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm0.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
</vector> </vector>

View file

@ -2,7 +2,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal" android:tint="@color/sel_accented"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size android:height="4dp"
android:width="4dp" />
<solid android:color="?attr/colorPrimary" />
</shape>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" <ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?attr/colorControlHighlight" android:color="?attr/colorControlHighlight"
android:radius="@dimen/size_small_unb_ripple" /> android:radius="24dp" />

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/spacing_small" />
</shape>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" <ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?attr/colorControlHighlight" android:color="?attr/colorControlHighlight"
android:radius="@dimen/size_unb_ripple" /> android:radius="20dp" />

Some files were not shown because too many files have changed in this diff Show more